From 6f18fa512596a8b28f9b13716ed6ff5b50eba937 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 13 Aug 2026 15:41:14 +0100 Subject: [PATCH] feat(tourmaline): add Artists by Region ranked breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ranked list of artist country/region of origin (MusicBrainz `area`, added to ArtistInfo in a prior commit), toggleable between scrobble-count and distinct-artist-count weighting — same visual pattern as other ranked-list cards (ordered list, rank/name/count row), with a flag emoji derived from the ISO 3166-1 code when present. Deliberately a ranked list rather than an interactive choropleth map: no mapping library or GeoJSON boundary data is set up in this codebase, and adding one is a much larger lift than this feature warrants for the same information. Artists with no resolved MusicBrainz area are simply excluded rather than bucketed as "unknown". --- .../src/lib/analysis/region-breakdown.ts | 63 +++++++++++++++ .../tourmaline/src/lib/client/load-profile.ts | 3 + packages/tourmaline/src/lib/types.ts | 3 + .../src/routes/profile/[did]/+page.svelte | 2 + .../profile/[did]/RegionBreakdown.svelte | 78 +++++++++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 packages/tourmaline/src/lib/analysis/region-breakdown.ts create mode 100644 packages/tourmaline/src/routes/profile/[did]/RegionBreakdown.svelte diff --git a/packages/tourmaline/src/lib/analysis/region-breakdown.ts b/packages/tourmaline/src/lib/analysis/region-breakdown.ts new file mode 100644 index 0000000..e76d83a --- /dev/null +++ b/packages/tourmaline/src/lib/analysis/region-breakdown.ts @@ -0,0 +1,63 @@ +import type { AggregatedData } from "./aggregator"; +import type { ArtistInfo } from "$lib/types"; + +export interface RegionEntry { + area: string; + areaCode?: string; + weight: number; +} + +/** + * Ranked list of artist countries/regions of origin (MusicBrainz `area`), + * weighted two ways so callers can toggle between "how many scrobbles come + * from this region" and "how many distinct artists come from this region". + * + * Only covers artists whose enrichment resolved a MusicBrainz area — many + * artists (unmatched, or matched but with no area on file) are simply + * excluded rather than bucketed into an "unknown" entry. + */ +export interface RegionProfile { + byScrobbles: RegionEntry[]; + byArtistCount: RegionEntry[]; +} + +/** + * Build the region profile from aggregated data and enriched artist info. + * Mirrors buildGenreProfile()/buildTagsProfile()'s iteration pattern: walk + * the full artistPlayCounts map (not just top-N) and look up enrichment + * per artist. + */ +export function buildRegionProfile( + data: AggregatedData, + artistInfos: Map, + limit = 15, +): RegionProfile { + const scrobbleWeights = new Map(); + const artistWeights = new Map(); + + for (const [name, count] of data.artistPlayCounts) { + const info = artistInfos.get(name); + if (!info?.area) continue; + + const s = scrobbleWeights.get(info.area) ?? { areaCode: info.areaCode, weight: 0 }; + s.weight += count; + scrobbleWeights.set(info.area, s); + + const a = artistWeights.get(info.area) ?? { areaCode: info.areaCode, weight: 0 }; + a.weight += 1; + artistWeights.set(info.area, a); + } + + const toRanked = ( + weights: Map, + ): RegionEntry[] => + [...weights.entries()] + .map(([area, v]) => ({ area, areaCode: v.areaCode, weight: v.weight })) + .sort((a, b) => b.weight - a.weight) + .slice(0, limit); + + return { + byScrobbles: toRanked(scrobbleWeights), + byArtistCount: toRanked(artistWeights), + }; +} diff --git a/packages/tourmaline/src/lib/client/load-profile.ts b/packages/tourmaline/src/lib/client/load-profile.ts index ef0b82e..3840a70 100644 --- a/packages/tourmaline/src/lib/client/load-profile.ts +++ b/packages/tourmaline/src/lib/client/load-profile.ts @@ -34,6 +34,7 @@ import { filterScrobbles, presetRange } from "$lib/analysis/date-range"; import { topZScorePerMonth } from "$lib/analysis/zscore"; import { buildRecommendations } from "$lib/analysis/recommendations"; import { buildTagsProfile } from "$lib/analysis/tags-breakdown"; +import { buildRegionProfile } from "$lib/analysis/region-breakdown"; export type RangeKey = "all" | "7d" | "30d" | "90d" | "365d"; export const RANGES: RangeKey[] = ["all", "7d", "30d", "90d", "365d"]; @@ -114,6 +115,7 @@ export function computeProfile( .slice(0, 20); const tagsBreakdown = buildTagsProfile(data, artistInfos); + const regionBreakdown = buildRegionProfile(data, artistInfos); const profile: ListenerProfile = { did, @@ -163,6 +165,7 @@ export function computeProfile( longestNotListenedGap: data.longestNotListenedGap, recommendations: buildRecommendations(data.topArtists, data.allArtists, artistInfos), tagsBreakdown, + regionBreakdown, }; const sessions = deriveSessions(filtered); diff --git a/packages/tourmaline/src/lib/types.ts b/packages/tourmaline/src/lib/types.ts index e2a35ce..d5c0e01 100644 --- a/packages/tourmaline/src/lib/types.ts +++ b/packages/tourmaline/src/lib/types.ts @@ -1,6 +1,7 @@ import type { ListeningPhase } from "$lib/analysis/phases"; import type { Recommendation } from "$lib/analysis/recommendations"; import type { TagsProfile } from "$lib/analysis/tags-breakdown"; +import type { RegionProfile } from "$lib/analysis/region-breakdown"; export interface TealScrobble { trackName: string; @@ -144,6 +145,8 @@ export interface ListenerProfile { recommendations: Recommendation[]; /** Raw genre/tag strings (not normalised into top-level categories, unlike `genres`) ranked by scrobble count and by distinct-artist count. */ tagsBreakdown: TagsProfile; + /** Artists' MusicBrainz area (country/region of origin) ranked by scrobble count and by distinct-artist count. Only covers enriched artists with a resolved area. */ + regionBreakdown: RegionProfile; } export interface UnusualMonth { diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index 87ff4b9..b0e2a8c 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -14,6 +14,7 @@ } from '$lib/client/load-profile'; import GenreChart from './GenreChart.svelte'; import TagsChart from './TagsChart.svelte'; + import RegionBreakdown from './RegionBreakdown.svelte'; import TimelineHeatmap from './TimelineHeatmap.svelte'; import TimelineChart from '$lib/components/TimelineChart.svelte'; import TopArtistsChart from '$lib/components/TopArtistsChart.svelte'; @@ -399,6 +400,7 @@ + {#if profile.era.length > 0}
diff --git a/packages/tourmaline/src/routes/profile/[did]/RegionBreakdown.svelte b/packages/tourmaline/src/routes/profile/[did]/RegionBreakdown.svelte new file mode 100644 index 0000000..ad68ff6 --- /dev/null +++ b/packages/tourmaline/src/routes/profile/[did]/RegionBreakdown.svelte @@ -0,0 +1,78 @@ + + +{#if regions.byScrobbles.length > 0} +
+
+
+

Artists by Region

+

+ Country/region of origin (MusicBrainz), for artists that resolved one +

+
+
+ +
+ + +
+
+
+
    + {#each top as region, i (region.area)} + {@const pct = total > 0 ? Math.round((region.weight / total) * 100) : 0} +
  1. + {i + 1} + {#if flagEmoji(region.areaCode)} + + {/if} + {region.area} + {region.weight.toLocaleString()} ({pct}%) +
  2. + {/each} +
+
+{/if} -- 2.51.2