diff --git a/src/lib/listenerBars.js b/src/lib/listenerBars.js new file mode 100644 index 0000000..10fa0c6 --- /dev/null +++ b/src/lib/listenerBars.js @@ -0,0 +1,72 @@ +/** + * The listener bar in a tracklist: how a Last.fm listener count becomes a bar + * width, and how warm that bar burns. + * + * Shared, because the album page and the artist page's open-in-place album + * panel both draw this column. A second implementation would quietly disagree + * with the other about how big a record's hit really was — the same reason + * `editions.js` was lifted out of the artist page. + */ + +/** + * A bar is a comparison against the album's own biggest song, but a LINEAR + * share hands the whole column to a runaway hit and leaves the rest of the + * record in a sliver. Weezer's Green Album is the case: Island In The Sun has + * 11.9x the listeners of that record's quietest track, so a linear scale puts + * the other nine tracks inside the bottom 8% of a 168px column, where they stop + * being tellable apart. + * + * The share is therefore raised to a power below 1. That is strictly monotonic + * and still anchored to the PEAK, so the ordering survives everywhere and — the + * reason a min/max normalisation against the album's own quietest track was + * rejected — a genuinely flat record still reads flat instead of being + * stretched to look varied. Shortest bar in the album, before and after: + * + * album top:bottom linear gamma 0.42 + * Weezer (Green Album) 11.9x 8%–100% 35%–100% + * Make Believe 6.7x 15%–100% 45%–100% + * Weezer (Blue Album) 3.9x 26%–100% 56%–100% + * Weezer (White Album) 2.3x 43%–100% 70%–100% + * Maladroit 2.2x 46%–100% 72%–100% + * Pinkerton 1.9x 53%–100% 77%–100% + * + * The flat records move least, which is the point: the curve spends its lift on + * the albums that were squashed and barely touches the ones that were not. + * + * 0.42 was picked off the interactive comparison in + * `research/album-page-redesign/listener-bars.html`, which runs these same six + * tracklists through every curve that was on the table. + */ +export const COUNT_GAMMA = 0.42; + +/** + * The album's most-heard track is the yardstick — a bar scaled to the artist's + * biggest hit would leave every track on a quiet record flat. 0 means Last.fm + * knew none of this tracklist, and the caller drops the whole column. + */ +export function listenerPeak(tracks) { + return (tracks ?? []).reduce((peak, t) => Math.max(peak, t.listeners ?? 0), 0); +} + +/** + * Curved share of the album's peak, as a percentage bar width. + * + * The 2% floor is only for a track Last.fm has no count for; every row that + * does have a number gets its real minimum from CSS instead, because the bar + * may never be narrower than the digits standing on it (`min-width: + * max-content` on the fill). That floor is per-row and measured by the browser + * in the actual font, which nothing here could do honestly. + */ +export function listenerShare(listeners, peak) { + if (!listeners || !peak) return 0; + return Math.max(2, Math.round(Math.pow(listeners / peak, COUNT_GAMMA) * 100)); +} + +/** + * The same curved share as a 0..1 temperature driving the fill's colour, so + * length and colour never tell two different stories about one track. + */ +export function listenerHeat(listeners, peak) { + if (!listeners || !peak) return "0.000"; + return Math.pow(listeners / peak, COUNT_GAMMA).toFixed(3); +} diff --git a/src/views/CatalogAlbum.svelte b/src/views/CatalogAlbum.svelte index 37159e2..fee83e8 100644 --- a/src/views/CatalogAlbum.svelte +++ b/src/views/CatalogAlbum.svelte @@ -25,6 +25,7 @@ import * as api from "../lib/api.js"; import { accentFromImage } from "../lib/color.js"; import { editionLabel, releaseFamilies } from "../lib/editions.js"; + import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js"; import Icon from "../components/Icon.svelte"; import GlassBar from "../components/GlassBar.svelte"; @@ -180,14 +181,14 @@ // play counts are the noisier of the two — distinct listeners is the figure // the Popular list is already ordered by, so the album and the artist page // now rank a tracklist the same way. - const countPeak = $derived(tracks.reduce((peak, t) => Math.max(peak, t.listeners ?? 0), 0)); + // + // The scale itself lives in src/lib/listenerBars.js — it is curved rather + // than linear so one runaway hit cannot squash the rest of the record into + // a sliver, and the artist page's album panel draws the identical column. + const countPeak = $derived(listenerPeak(tracks)); const showCount = $derived(countPeak > 0 && tracks.length >= 3); - const countShare = (t) => - !t.listeners || !countPeak - ? 0 - : Math.max(2, Math.round((t.listeners / countPeak) * 100)); - const countHeat = (t) => - (!t.listeners || !countPeak ? 0 : t.listeners / countPeak).toFixed(3); + const countShare = (t) => listenerShare(t.listeners, countPeak); + const countHeat = (t) => listenerHeat(t.listeners, countPeak); /** Unknown is a dash, never a zero — same rule as the artist page. */ const fmtCount = (n) => (n == null ? "—" : n.toLocaleString()); @@ -693,12 +694,14 @@ {#if showCount} - - {fmtCount(t.listeners)} + + {fmtCount(t.listeners)} + {/if} {#if locked} @@ -1345,9 +1348,16 @@ font-size: 12.5px; font-variant-numeric: tabular-nums; } + /* The number lives INSIDE the fill rather than floating over it, so + `min-width: max-content` makes every bar at least as wide as the digits + standing on it. The floor is per-row and measured by the browser in the + real font — the width JS hands over is only ever the lower bound. */ .tcount-fill { - position: absolute; - inset: 0 auto 0 0; + display: flex; + align-items: center; + height: 100%; + min-width: max-content; + max-width: 100%; border-radius: 4px; background: color-mix( in srgb, @@ -1356,10 +1366,14 @@ transparent ); } + /* A track Last.fm has no count for gets the dash and no paint at all — a + floor-width pill behind an em dash would read as a measured tiny value. */ + .tcount.empty .tcount-fill { + background: none; + } .tcount-val { - position: relative; - z-index: 1; padding: 0 8px; + white-space: nowrap; color: color-mix(in srgb, #fff calc(45% + var(--t) * 55%), var(--text-muted)); } diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index db0a315..9ae5892 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -38,6 +38,7 @@ isEdition, releaseFamilies, } from "../lib/editions.js"; + import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { buildArtistManifest } from "../lib/preview/manifest.js"; import { openPreview } from "../lib/preview/previewSession.svelte.js"; import { traceToSvg, svgDataUrl } from "../lib/trace.js"; @@ -3786,17 +3787,15 @@ // artist's biggest hit would leave every track on a quiet record flat. Zero // means Last.fm knew none of this tracklist, and the whole column (header // included) stays out of the layout rather than printing a row of dashes. - const countPeak = $derived( - openTracks.reduce((peak, t) => Math.max(peak, t.listeners ?? 0), 0), - ); - /** Share of the album's peak, as a bar width. */ - const countShare = (track) => - !track.listeners || !countPeak - ? 0 - : Math.max(2, Math.round((track.listeners / countPeak) * 100)); + // + // The scale is curved rather than linear so a runaway hit cannot squash the + // rest of the record; it lives in src/lib/listenerBars.js because the album + // page draws this identical column and the two must agree. + const countPeak = $derived(listenerPeak(openTracks)); + /** Curved share of the album's peak, as a bar width. */ + const countShare = (track) => listenerShare(track.listeners, countPeak); /** Same share as a 0..1 temperature, driving the fill's colour. */ - const countHeat = (track) => - (!track.listeners || !countPeak ? 0 : track.listeners / countPeak).toFixed(3); + const countHeat = (track) => listenerHeat(track.listeners, countPeak); /** Unknown is a dash, never a zero — same rule as the Popular column. */ const fmtCount = (n) => (n == null ? "—" : n.toLocaleString()); @@ -4853,9 +4852,10 @@ {#if libraryTicks.has(norm(t.title || ""))}{/if} {#if countPeak > 0} - - - {fmtCount(t.listeners)} + + + {fmtCount(t.listeners)} + {/if} {#if locked} @@ -8152,9 +8152,16 @@ font-variant-numeric: tabular-nums; } + /* The number lives INSIDE the fill rather than floating over it, so + `min-width: max-content` makes every bar at least as wide as the digits + standing on it. The floor is per-row and measured by the browser in the + real font — the width JS hands over is only ever the lower bound. */ .tcount-fill { - position: absolute; - inset: 0 auto 0 0; + display: flex; + align-items: center; + height: 100%; + min-width: max-content; + max-width: 100%; border-radius: 4px; background: color-mix( in srgb, @@ -8164,10 +8171,15 @@ ); } + /* A track Last.fm has no count for gets the dash and no paint at all — a + floor-width pill behind an em dash would read as a measured tiny value. */ + .tcount.empty .tcount-fill { + background: none; + } + .tcount-val { - position: relative; - z-index: 1; padding: 0 8px; + white-space: nowrap; color: color-mix(in srgb, #fff calc(45% + var(--t) * 55%), var(--text-muted)); }