Skip to content

Commit 85cb8d2

Browse files
committed
add related graph
1 parent f42c54e commit 85cb8d2

11 files changed

Lines changed: 294 additions & 46 deletions

File tree

src/server.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,19 @@ app.get('/api/events', authenticateJWT, (req: AuthenticatedRequest, res) => {
180180
Events.setupAnalysisSSE(req, res, spotifyId);
181181
});
182182

183+
app.get('/api/related-tracks', authenticateJWT, async (req: AuthenticatedRequest, res: Response) => {
184+
try {
185+
const user = req.user;
186+
if (!user) return res.status(401).json({ error: 'Not logged in' });
187+
const trackId = req.query.trackId as string;
188+
const relatedTracks = await Store.getRelatedTracks(trackId, user.spotifyId);
189+
res.json(relatedTracks);
190+
} catch (error: any) {
191+
console.error('Error during related tracks', error?.message);
192+
res.status(500).json({ error: 'Internal Server Error' });
193+
}
194+
})
195+
183196
const startServer = async () => {
184197
await connectRedis();
185198
app.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));

src/store.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,34 @@ export const getTrackHistory = async (userId: string, count = 50): Promise<Track
4242
return cachedTracks
4343
.filter((data): data is string => data !== null)
4444
.map(data => JSON.parse(data));
45-
};
45+
};
46+
47+
// based on trackId get items with at least one tag in common
48+
// then rank by number of tags in common
49+
export const getRelatedTracks = async (trackId: string, userId: string, count = 5) => {
50+
const track = await getCacheTrack(trackId);
51+
if (!track || !track.analysis) return [];
52+
const history = await getTrackHistory(userId);
53+
const topTracks = findTracksWithCommonTags(track, history).slice(0, count);
54+
return topTracks;
55+
};
56+
57+
const findTracksWithCommonTags = (track: TrackDetails, tracks: TrackDetails[]) => {
58+
const targetTags = track?.analysis?.tags ?? [];
59+
const commonTracks = tracks.reduce<TrackDetails[]>((prev, curr) => {
60+
if (curr.trackId === track.trackId) return prev;
61+
if (curr.analysis?.tags?.length === 0) return prev;
62+
if (curr.analysis?.tags?.some(tag => targetTags.includes(tag))) {
63+
return [...prev, curr];
64+
}
65+
return prev;
66+
}, []);
67+
68+
const countTagsInCommon = (track: TrackDetails) => {
69+
const tags = track.analysis?.tags ?? [];
70+
return targetTags.filter(tag => tags.includes(tag)).length;
71+
};
72+
return commonTracks.sort((a, b) => {
73+
return countTagsInCommon(b) - countTagsInCommon(a);
74+
});
75+
};

ui/package-lock.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@xyflow/react": "^12.10.0",
2020
"class-variance-authority": "^0.7.1",
2121
"clsx": "^2.1.1",
22+
"dagre": "^0.8.5",
2223
"lucide-react": "^0.561.0",
2324
"next-themes": "^0.4.6",
2425
"radix-ui": "^1.4.3",
@@ -29,6 +30,7 @@
2930
},
3031
"devDependencies": {
3132
"@eslint/js": "^9.39.1",
33+
"@types/dagre": "^0.7.53",
3234
"@types/node": "^24.10.1",
3335
"@types/react": "^19.2.5",
3436
"@types/react-dom": "^19.2.3",

ui/src/components/analyse-recent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export default function AnalyseRecent({
4848
<ChevronsUpDown />
4949
</Button>
5050
</DropdownMenuTrigger>
51-
<DropdownMenuContent align="end" className="max-h-[600px]">
51+
<DropdownMenuContent align="end" className="max-h-[600px] max-w-[250px]">
5252
{loading && tracks.length === 0 && (
5353
<DropdownMenuItem disabled>
5454
<Loader2 className="mr-2 h-4 w-4 animate-spin" />

ui/src/components/home.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ import PlatformLogin from "./platform-connect";
1919
import { NavUser } from "./nav-user";
2020
import { Button } from "./ui/button";
2121
import AnalyseRecent from "./analyse-recent";
22-
import TrackCard, { TrackItem } from "./track";
22+
import { TrackItem, TrackCard } from "./track";
2323
import { ResizableOverlay } from "./resizable-overlay";
2424
import { RelatedGraph } from "./related-graph";
2525
import { useWindowSize } from "../hooks/use-window-size";
26+
import { ReactFlowProvider } from "@xyflow/react";
27+
28+
const SIDEBAR_WIDTH = 255;
2629

2730
export default function Home({
2831
history,
@@ -37,7 +40,6 @@ export default function Home({
3740
isLoggedIn: boolean,
3841
setHistory: React.Dispatch<React.SetStateAction<Array<TrackDetails>>>,
3942
}) {
40-
const SIDEBAR_WIDTH = 255;
4143
const { width: windowWidth } = useWindowSize();
4244
const [index, setIndex] = useState(0);
4345
const [isGraphOpen, setIsGraphOpen] = useState(false);
@@ -106,14 +108,16 @@ export default function Home({
106108
</div>
107109
</div>
108110
<ResizableOverlay
109-
defaultWidthPx={400}
110-
minWidthPx={200}
111+
defaultWidthPx={500}
112+
minWidthPx={180}
111113
maxWidthPx={windowWidth - SIDEBAR_WIDTH}
112114
isOpen={isGraphOpen}
113115
onOpenChange={setIsGraphOpen}
114116
>
115117
{selectedTrack
116-
? <RelatedGraph track={selectedTrack} />
118+
? <ReactFlowProvider>
119+
<RelatedGraph track={selectedTrack} />
120+
</ReactFlowProvider>
117121
: <p>Select a track to view related graph.</p>
118122
}
119123
</ResizableOverlay>
Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
import { ReactFlow, type Node, Controls } from '@xyflow/react';
21
import '@xyflow/react/dist/style.css';
32
import '@xyflow/react/dist/base.css';
3+
import { ReactFlow, type Node, Controls, Background, BackgroundVariant, type Edge, useNodesState, useEdgesState, ConnectionLineType, Handle, Position, useReactFlow } from '@xyflow/react';
44
import type { TrackDetails } from '../../../src/types';
5-
import { useMemo } from 'react';
5+
import { useEffect, useState } from 'react';
66
import { TrackItem } from './track';
7+
import * as Api from '../lib/api';
8+
import { cn } from '../lib/utils';
9+
import { useAutoLayout } from '../hooks/use-auto-layout';
10+
import { Loader2 } from 'lucide-react';
711

812
const TrackNode = ({ data }: { data: { track: TrackDetails } }) => {
913
return (
10-
<div className='border border flex p-2 rounded-md gap-2'>
14+
// add click to view track details
15+
<div className='border border flex p-2 rounded-md gap-2 w-[200px] bg-background'>
16+
<Handle type="target" position={Position.Top} className="!opacity-0" />
1117
<TrackItem track={data.track} selected={true} />
18+
{/* add tags for related tracks */}
19+
<Handle type="source" position={Position.Bottom} className="!opacity-0" />
1220
</div>
1321
);
1422
};
@@ -17,37 +25,100 @@ const nodeTypes = {
1725
track: TrackNode,
1826
};
1927

28+
export function RelatedGraph({ track }: { track: TrackDetails }) {
29+
const { layout } = useAutoLayout();
30+
const { fitView } = useReactFlow();
31+
const [isLoading, setIsLoading] = useState(true);
32+
const [relatedTracks, setRelatedTracks] = useState<TrackDetails[]>([]);
2033

34+
useEffect(() => {
35+
const fetchRelatedTracks = async () => {
36+
try {
37+
setIsLoading(true);
38+
const relatedTracks = await Api.getRelatedTracks(track.trackId);
39+
setRelatedTracks(relatedTracks);
40+
setIsLoading(false);
41+
} catch (error) {
42+
console.error('Error fetching related tracks', error);
43+
} finally {
44+
setIsLoading(false);
45+
}
46+
};
47+
fetchRelatedTracks();
48+
}, [track]);
2149

22-
export function RelatedGraph({ track }: { track: TrackDetails }) {
23-
// based on trackId get items with at least one tag in common
24-
// then rank by number of tags in common
25-
// return top 10
50+
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
51+
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
2652

27-
// const [relatedTracks, setRelatedTracks] = useState<TrackDetails[]>([]);
53+
useEffect(() => {
54+
let initialNodes: Node[] = [];
55+
let initialEdges: Edge[] = [];
2856

29-
const nodes: Node[] = useMemo(() => [
30-
{
31-
id: 'current-track',
57+
const selectedNode: Node = {
58+
id: track.trackId,
3259
type: 'track',
3360
position: { x: 0, y: 0 },
3461
data: { track: track },
35-
},
36-
// TODO: Fetch related tracks
37-
], [track]);
62+
};
63+
64+
if (relatedTracks.length > 0) {
65+
const relatedTrackNodes = relatedTracks.map((related) => ({
66+
id: related.trackId,
67+
type: 'track',
68+
position: { x: 0, y: 0 },
69+
data: { track: related },
70+
}));
71+
initialNodes = [selectedNode, ...relatedTrackNodes];
72+
initialEdges = relatedTracks.map((related) => ({
73+
id: `e-${selectedNode.id}-${related.trackId}`,
74+
source: selectedNode.id,
75+
target: related.trackId,
76+
type: ConnectionLineType.SmoothStep,
77+
style: { stroke: '#b1b1b7', strokeWidth: 2 },
78+
}));
79+
} else {
80+
initialNodes = [selectedNode];
81+
}
82+
83+
const { nodes: layoutedNodes, edges: layoutedEdges } = layout({
84+
direction: 'LR',
85+
nodes: initialNodes,
86+
edges: initialEdges,
87+
});
88+
89+
setNodes(layoutedNodes);
90+
setEdges(layoutedEdges);
91+
92+
}, [track, relatedTracks, layout, setNodes, setEdges]);
93+
94+
useEffect(() => {
95+
if (nodes.length === 0) return
96+
window.requestAnimationFrame(() => {
97+
fitView({ duration: 800, padding: 0.2 });
98+
});
99+
}, [nodes, fitView]);
38100

39101
return (
40102
<ReactFlow
41103
nodes={nodes}
42-
edges={[]}
104+
edges={edges}
105+
onNodesChange={onNodesChange}
106+
onEdgesChange={onEdgesChange}
43107
nodeTypes={nodeTypes}
44-
defaultViewport={{ x: 0, y: 0, zoom: 0.75 }}
45108
proOptions={{ hideAttribution: true }}
46109
minZoom={0.1}
47-
maxZoom={1.5}
110+
maxZoom={1.25}
111+
connectionLineType={ConnectionLineType.SmoothStep}
112+
className={cn(isLoading && 'opacity-50 pointer-events-none')}
48113
fitView
49114
>
50-
<Controls className="text-black dark:text-black hover:bg-white" />
115+
<Controls
116+
className="text-black dark:text-black hover:bg-white"
117+
position="bottom-right"
118+
showInteractive={false}
119+
/>
120+
<Background variant={BackgroundVariant.Dots} gap={24} size={1} />
121+
{isLoading && <Loader2 className="animate-spin absolute top-1/2 left-1/2" size={48} />}
51122
</ReactFlow>
52123
)
53124
}

0 commit comments

Comments
 (0)