Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion graph-frontend/.content-collections/generated/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// generated by content-collections at Sun Mar 01 2026 23:28:46 GMT-0800 (Pacific Standard Time)
// generated by content-collections at Mon Mar 02 2026 23:18:57 GMT-0800 (Pacific Standard Time)

import allBlogs from "./allBlogs.js";

Expand Down
44 changes: 24 additions & 20 deletions graph-frontend/src/contexts/graphContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,44 +52,48 @@ function toGraphology(

if (entries.length === 0) return graph

let maxPageRank = 1e-10
for (const [, n] of entries) {
const pr = n.pageRank ?? 0
if (pr > maxPageRank) maxPageRank = pr
}

for (const [key, node] of entries) {
graph.addNode(key, {
label: `${node.artists[0] ?? "Unknown"} — ${node.name}`,
artists: node.artists,
albumName: node.albumName,
spotifyId: node.spotifyId,
lastfmUrl: node.lastfmUrl,
imageUrl: node.imageUrl,
totalPlays: node.totalPlays,
sources: node.sources,
pageRank: node.pageRank ?? 0,
playDates: node.playDates ?? [],
size: 4, // placeholder; nodeReducer in RenderGraph sets actual size per layout
color: "#ffffff", // placeholder, overwritten by component coloring
size: 4,
color: "#ffffff",
x: 0,
y: 0,
})
}

for (const [fromKey, node] of entries) {
for (const [toKey, weight] of Object.entries(node.next)) {
if (!graph.hasNode(toKey)) continue
if (graph.hasEdge(fromKey, toKey)) continue

graph.addDirectedEdge(fromKey, toKey, {
weight,
size: Math.max(0.5, Math.min(3, Math.log(weight + 1))),
color: `rgba(0, 0, 0, ${Math.min(0.6, 0.15 + weight * 0.05)})`,
})
// Aggregate parallel edges: group by (from, to), collect timestamps, count as weight
const edgeMap = new Map<string, { from: string; to: string; timestamps: string[] }>()
for (const edge of listeningGraph.edges) {
const mapKey = `${edge.from}→${edge.to}`
const existing = edgeMap.get(mapKey)
if (existing) {
existing.timestamps.push(edge.timestamp)
} else {
edgeMap.set(mapKey, { from: edge.from, to: edge.to, timestamps: [edge.timestamp] })
}
}

for (const [, agg] of edgeMap) {
if (!graph.hasNode(agg.from) || !graph.hasNode(agg.to)) continue
if (graph.hasEdge(agg.from, agg.to)) continue

const weight = agg.timestamps.length
graph.addDirectedEdge(agg.from, agg.to, {
weight,
timestamps: agg.timestamps,
size: Math.max(0.5, Math.min(3, Math.log(weight + 1))),
color: `rgba(0, 0, 0, ${Math.min(0.6, 0.15 + weight * 0.05)})`,
})
}

// Color each disconnected component a different high-contrast color
const components = connectedComponents(graph)
for (let i = 0; i < components.length; i++) {
Expand Down
17 changes: 10 additions & 7 deletions graph-frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
/** Canonical song identity: `lowercase(artist)::lowercase(track_name)`. */
export type SongKey = `${string}::${string}`

/** Data source that contributed a scrobble or track ordering. */
export type ListeningSource = "lastfm" | "spotify-recent" | "spotify-playlist"
/** A single transition event between two songs, with a timestamp. */
export interface GraphEdge {
from: SongKey
to: SongKey
/** ISO 8601 timestamp of when this transition occurred. */
timestamp: string
}

/** A node in the listening graph representing a single song. */
export interface GraphNode {
name: string
artists: string[]
albumName?: string
spotifyId?: string
lastfmUrl?: string
imageUrl?: string
next: Record<SongKey, number>
previous: Record<SongKey, number>
totalPlays: number
sources: ListeningSource[]
pageRank: number
playDates: string[]
}
Expand All @@ -26,12 +29,12 @@ export interface GraphMetadata {
dateRange: { from: string; to: string }
exportTimestamp: string
lastfmUsername?: string
spotifyUsername?: string
}

/** The full listening graph as returned by GET /graph. */
export interface ListeningGraph {
nodes: Record<SongKey, GraphNode>
edges: GraphEdge[]
metadata: GraphMetadata
}

Expand All @@ -44,11 +47,9 @@ export interface NodeAttributes {
label: string
artists: string[]
albumName?: string
spotifyId?: string
lastfmUrl?: string
imageUrl?: string
totalPlays: number
sources: string[]
pageRank: number
playDates: string[]
size: number
Expand All @@ -60,6 +61,8 @@ export interface NodeAttributes {
/** Attributes stored on each graphology edge. */
export interface EdgeAttributes {
weight: number
/** ISO 8601 timestamps of each individual transition aggregated into this edge. */
timestamps: string[]
size: number
color: string
}
133 changes: 109 additions & 24 deletions graph-frontend/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { SigmaContainer, useLoadGraph, useSigma } from '@react-sigma/core'
import { EdgeArrowProgram, NodePointProgram } from 'sigma/rendering'
Expand All @@ -11,10 +11,24 @@ import { SunIcon, MoonIcon } from 'lucide-react'

export const Route = createFileRoute('/')({ component: App })

function RenderGraph({ layout, dateRange, isDark }: { layout: LayoutMode; dateRange: DateRange | undefined; isDark: boolean }) {
interface EdgeTooltip {
x: number
y: number
sourceLabel: string
targetLabel: string
timestamps: string[]
}

function formatTimestamp(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}

function RenderGraph({ layout, dateRange, isDark, onEdgeHover }: { layout: LayoutMode; dateRange: DateRange | undefined; isDark: boolean; onEdgeHover: (tooltip: EdgeTooltip | null) => void }) {
const loadGraph = useLoadGraph()
const sigma = useSigma()
const { graph } = useGraph()
const { graph, raw } = useGraph()
const [selectedNode, setSelectedNode] = useState<string | null>(null)

// Build set of neighbors for the selected node
Expand Down Expand Up @@ -45,6 +59,35 @@ function RenderGraph({ layout, dateRange, isDark }: { layout: LayoutMode; dateRa
}
}, [sigma])

// Edge hover tooltip
useEffect(() => {
if (!sigma || !graph) return
const handleEnterEdge = ({ edge, event }: { edge: string; event: { original: MouseEvent } }) => {
const g = sigma.getGraph()
const source = g.source(edge)
const target = g.target(edge)
const attrs = g.getEdgeAttributes(edge)
const sourceLabel = g.getNodeAttribute(source, 'label') ?? source
const targetLabel = g.getNodeAttribute(target, 'label') ?? target
onEdgeHover({
x: event.original.clientX,
y: event.original.clientY,
sourceLabel,
targetLabel,
timestamps: (attrs.timestamps as string[]) ?? [],
})
}
const handleLeaveEdge = () => {
onEdgeHover(null)
}
sigma.on('enterEdge', handleEnterEdge)
sigma.on('leaveEdge', handleLeaveEdge)
return () => {
sigma.off('enterEdge', handleEnterEdge)
sigma.off('leaveEdge', handleLeaveEdge)
}
}, [sigma, graph, onEdgeHover])

useEffect(() => {
if (graph) {
setNodePositions(graph, layout)
Expand All @@ -53,22 +96,41 @@ function RenderGraph({ layout, dateRange, isDark }: { layout: LayoutMode; dateRa
}
}, [graph, layout, loadGraph])

// Build a map of node keys → play count within the selected date range
const filteredPlayCounts = useMemo(() => {
if (!dateRange?.from || !graph) return null
// Date filtering: compute filtered edge weights and visible nodes from raw edge timestamps
const dateFilter = useMemo(() => {
if (!dateRange?.from || !raw) return null
const fromStr = dateRange.from.toISOString().slice(0, 10)
const toStr = (dateRange.to ?? dateRange.from).toISOString().slice(0, 10)
const counts = new Map<string, number>()
graph.forEachNode((key, attrs) => {
let count = 0
for (const d of (attrs.playDates ?? [])) {
const day = d.slice(0, 10)
if (day >= fromStr && day <= toStr) count++

// Count edges within date range, grouped by (from, to)
const edgeWeights = new Map<string, number>()
const visibleNodes = new Set<string>()

for (const edge of raw.edges) {
const day = edge.timestamp.slice(0, 10)
if (day >= fromStr && day <= toStr) {
const mapKey = `${edge.from}→${edge.to}`
edgeWeights.set(mapKey, (edgeWeights.get(mapKey) ?? 0) + 1)
visibleNodes.add(edge.from)
visibleNodes.add(edge.to)
}
if (count > 0) counts.set(key, count)
})
return counts
}, [dateRange, graph])
}

// Also include nodes that have playDates within range (even if they have no edges)
if (graph) {
graph.forEachNode((key, attrs) => {
for (const d of (attrs.playDates ?? [])) {
const day = d.slice(0, 10)
if (day >= fromStr && day <= toStr) {
visibleNodes.add(key)
break
}
}
})
}

return { edgeWeights, visibleNodes }
}, [dateRange, raw, graph])

// Compute per-node sizing metric based on the active layout algorithm
const nodeMetrics = useMemo(() => {
Expand Down Expand Up @@ -102,8 +164,8 @@ function RenderGraph({ layout, dateRange, isDark }: { layout: LayoutMode; dateRa
if (!nodeMetrics) return 1
let max = 1e-10

if (filteredPlayCounts) {
for (const key of filteredPlayCounts.keys()) {
if (dateFilter) {
for (const key of dateFilter.visibleNodes) {
const val = nodeMetrics.get(key)
if (val !== undefined && val > max) max = val
}
Expand All @@ -114,7 +176,7 @@ function RenderGraph({ layout, dateRange, isDark }: { layout: LayoutMode; dateRa
}

return max
}, [nodeMetrics, filteredPlayCounts])
}, [nodeMetrics, dateFilter])

// Update sigma reducers to hide nodes/edges not matching the filter and rescale sizes
useEffect(() => {
Expand All @@ -127,7 +189,7 @@ function RenderGraph({ layout, dateRange, isDark }: { layout: LayoutMode; dateRa
sigma.setSetting('labelColor', { color: isDark ? '#ffffff' : '#000000' })

sigma.setSetting('nodeReducer', (_node: string, data: Record<string, unknown>) => {
if (filteredPlayCounts && !filteredPlayCounts.has(_node)) {
if (dateFilter && !dateFilter.visibleNodes.has(_node)) {
return { ...data, hidden: true }
}
if (selectedNeighbors && !selectedNeighbors.has(_node)) {
Expand All @@ -142,11 +204,17 @@ function RenderGraph({ layout, dateRange, isDark }: { layout: LayoutMode; dateRa
sigma.setSetting('edgeReducer', (edge: string, data: Record<string, unknown>) => {
const source = g.source(edge)
const target = g.target(edge)
if (filteredPlayCounts) {
if (!filteredPlayCounts.has(source) || !filteredPlayCounts.has(target)) {

if (dateFilter) {
const mapKey = `${source}→${target}`
const filteredWeight = dateFilter.edgeWeights.get(mapKey)
if (!filteredWeight) {
return { ...data, hidden: true }
}
// Use filtered weight for visual sizing
return { ...data, color: edgeBase(Math.min(0.6, 0.15 + filteredWeight * 0.05)) }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge reducer skips neighbor filter when date filter active

Medium Severity

When dateFilter is active and an edge passes the date range check (has a filteredWeight), the edgeReducer returns early with styling, completely skipping the selectedNeighbors check. This means that when a user has both a date filter and a clicked/focused node active simultaneously, nodes are correctly filtered by both criteria (the nodeReducer checks both), but edges only respect the date filter and ignore the node neighborhood selection. The old code's filteredPlayCounts block only returned early to hide edges, otherwise falling through to the selectedNeighbors check — so this is a regression.

Fix in Cursor Fix in Web

}

if (selectedNeighbors) {
if (!selectedNeighbors.has(source) || !selectedNeighbors.has(target)) {
return { ...data, hidden: true }
Expand All @@ -156,7 +224,7 @@ function RenderGraph({ layout, dateRange, isDark }: { layout: LayoutMode; dateRa
return { ...data, color: edgeBase(Math.min(0.6, 0.15 + w * 0.05)) }
})
sigma.refresh()
}, [filteredPlayCounts, selectedNeighbors, nodeMetrics, maxMetric, sigma, isDark])
}, [dateFilter, selectedNeighbors, nodeMetrics, maxMetric, sigma, isDark])

return null
}
Expand All @@ -166,6 +234,8 @@ function App() {
const [layout, setLayout] = useState<LayoutMode>('pagerank')
const [dateRange, setDateRange] = useState<DateRange | undefined>()
const [isDark, setIsDark] = useState(true)
const [edgeTooltip, setEdgeTooltip] = useState<EdgeTooltip | null>(null)
const handleEdgeHover = useCallback((t: EdgeTooltip | null) => setEdgeTooltip(t), [])

// Toggle .dark class on <html> so dark: variants work everywhere
useEffect(() => {
Expand Down Expand Up @@ -218,10 +288,25 @@ function App() {
edgeProgramClasses: { arrow: EdgeArrowProgram },
defaultDrawNodeHover: () => {},
labelColor: { color: isDark ? '#ffffff' : '#000000' },
enableEdgeEvents: true,
}}
>
<RenderGraph layout={layout} dateRange={dateRange} isDark={isDark} />
<RenderGraph layout={layout} dateRange={dateRange} isDark={isDark} onEdgeHover={handleEdgeHover} />
</SigmaContainer>
{edgeTooltip && (
<div
className="fixed z-50 pointer-events-none px-3 py-2 text-xs max-w-xs bg-neutral-900 text-neutral-100 dark:bg-neutral-100 dark:text-neutral-900 shadow-lg"
style={{ left: edgeTooltip.x + 12, top: edgeTooltip.y + 12 }}
>
<div className="font-medium mb-1">{edgeTooltip.sourceLabel} &rarr; {edgeTooltip.targetLabel}</div>
<div className="text-[10px] opacity-70 mb-1">{edgeTooltip.timestamps.length} transition{edgeTooltip.timestamps.length !== 1 ? 's' : ''}</div>
<div className="flex flex-col gap-0.5 max-h-40 overflow-y-auto">
{edgeTooltip.timestamps.slice().sort().map((ts, i) => (
<span key={i} className="tabular-nums">{formatTimestamp(ts)}</span>
))}
</div>
</div>
)}
</main>
)
}
Loading