diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index 3029751..9c22d09 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -41,6 +41,7 @@ import GoldenOldies from './GoldenOldies.svelte'; import RankMovers from './RankMovers.svelte'; import EveryYearArtists from './EveryYearArtists.svelte'; + import WordCloud from './WordCloud.svelte'; import DateRangePicker from './DateRangePicker.svelte'; import ListeningPhases from './ListeningPhases.svelte'; import StoryRecap from './StoryRecap.svelte'; @@ -601,6 +602,14 @@ +
+ +
+ {#if profile.discoveredArtists.length > 0 || profile.discoveredTracks.length > 0 || profile.discoveredAlbums.length > 0}
+ import { Cloud } from '@lucide/svelte'; + + let { + topArtists = [], + topTracks = [], + topAlbums = [] + }: { + topArtists: Array<{ name: string; count: number }>; + topTracks: Array<{ name: string; artist: string; count: number }>; + topAlbums: Array<{ name: string; artist: string; count: number }>; + } = $props(); + + type Source = 'artists' | 'tracks' | 'albums'; + let source = $state('artists'); + + const STOP_WORDS = new Set([ + 'the', + 'a', + 'an', + 'of', + 'and', + 'in', + 'on', + 'feat', + 'ft', + 'with', + 'vs' + ]); + + // A handful of shades near the accent green, cycled per token. + const PALETTE = [ + 'var(--accent)', + 'var(--accent-bright)', + 'var(--accent-dim)', + 'var(--text-muted)' + ]; + + const MIN_FONT_REM = 0.75; + const MAX_FONT_REM = 2.5; + const MAX_TOKENS = 70; + + function tokenize(name: string): string[] { + return name + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter((token) => token.length >= 2 && !STOP_WORDS.has(token)); + } + + interface WeightedToken { + token: string; + weight: number; + } + + function buildTokens(src: Source): WeightedToken[] { + const weights = new Map(); + + function add(name: string, weight: number) { + for (const token of tokenize(name)) { + weights.set(token, (weights.get(token) ?? 0) + weight); + } + } + + if (src === 'artists') { + for (const artist of topArtists) add(artist.name, artist.count); + } else if (src === 'tracks') { + for (const track of topTracks) add(track.name, track.count); + } else { + for (const album of topAlbums) add(album.name, album.count); + } + + return Array.from(weights.entries()) + .map(([token, weight]) => ({ token, weight })) + .sort((a, b) => b.weight - a.weight) + .slice(0, MAX_TOKENS); + } + + const tokens = $derived(buildTokens(source)); + const maxWeight = $derived(tokens.reduce((max, t) => Math.max(max, t.weight), 1)); + const minWeight = $derived(tokens.reduce((min, t) => Math.min(min, t.weight), maxWeight)); + + function fontSize(weight: number): number { + if (maxWeight === minWeight) return (MIN_FONT_REM + MAX_FONT_REM) / 2; + // Square-root scale so the top token doesn't dwarf the rest. + const normalised = (Math.sqrt(weight) - Math.sqrt(minWeight)) / (Math.sqrt(maxWeight) - Math.sqrt(minWeight)); + return MIN_FONT_REM + normalised * (MAX_FONT_REM - MIN_FONT_REM); + } + + // Shuffle deterministically by weight rank so bigger words aren't all clustered at the start, + // while still keeping the layout stable across re-renders of the same source. + const displayTokens = $derived( + tokens + .map((t, i) => ({ ...t, i })) + .sort((a, b) => (a.i % 3) - (b.i % 3) || b.weight - a.weight) + ); + + +
+
+
+ +

Word Cloud

+
+
+ +
+ + + +
+ + {#if displayTokens.length === 0} +

Not enough data to build a word cloud yet.

+ {:else} +
+ {#each displayTokens as { token, weight, i } (token)} + + {token} + + {/each} +
+ {/if} +