listener bars: a bar is never narrower than the number standing on it
Two faults in the tracklist's listener column, both visible on any record with one runaway hit. The bar was a LINEAR share of the album's biggest song, so a single outlier took the whole column and pressed everything else into 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 nine of the ten bars lived inside the bottom 8% of a 168px column and stopped being tellable apart. And the fill was absolutely positioned with the number floating on top of it, so a short bar left its own digits hanging off the end onto the row background. Six of the ten rows on the Blue Album did this. The share is now raised to a power below 1 (0.42). That is strictly monotonic and still anchored to the PEAK, so the ordering survives and a genuinely flat record still reads flat -- a min/max normalisation against the album's own quietest track was rejected for exactly that reason, since it would stretch Maladroit to look as varied as Green Album. Measured in the running app, shortest bar of a 168px column: album top:bottom before after Weezer (Green Album) 11.9x 8% 38% Make Believe 6.7x 15% 45% Weezer (Blue Album) 3.9x 26% 56% Weezer (White Album) 2.3x 43% 70% Maladroit 2.2x 46% 72% 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. For the second fault the number moves INSIDE the fill, and the fill gets `min-width: max-content`. The floor is then per-row and measured by the browser in the real font -- nothing in JS could do that honestly -- and the width handed over is only ever a lower bound. Across the four albums above, numbers hanging off their bar went from 6 to 0 and every bar stayed monotonic in its count. Green Album lands at 38% rather than the 35% the curve alone gives, because the text floor takes over on its shortest row. A track Last.fm has no count for keeps its dash and now gets no paint at all; a floor-width pill behind an em dash would read as a measured tiny value rather than an absent one. The scale moves to src/lib/listenerBars.js because the album page and the artist page's open-in-place album panel draw this identical column, and a second copy would quietly disagree with the first about how big a record's hit was -- the same reason editions.js was lifted out of the artist page. Both call sites verified over CDP; 85 JS tests pass.
This commit is contained in:
1 parent
7a517dca59
commit
122157e061
3 files changed
+126
-28
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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,13 +694,15 @@
|
||||
{#if showCount}
|
||||
<span
|
||||
class="tcount"
|
||||
class:empty={!t.listeners}
|
||||
title={t.listeners == null
|
||||
? "No Last.fm listener count for this track"
|
||||
: `${t.listeners.toLocaleString()} Last.fm listeners`}
|
||||
>
|
||||
<span class="tcount-fill" style={`width:${countShare(t)}%`}></span>
|
||||
<span class="tcount-fill" style={`width:${countShare(t)}%`}>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if locked}
|
||||
<span class="tag-unreleased">Unreleased</span>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,10 +4852,11 @@
|
||||
{#if libraryTicks.has(norm(t.title || ""))}<span class="tick" title="Saved in your library"><Icon name="check" size={11} /></span>{/if}
|
||||
</span>
|
||||
{#if countPeak > 0}
|
||||
<span class="tcount" title={t.listeners == null ? "No Last.fm listener count for this track" : `${t.listeners.toLocaleString()} Last.fm listeners`}>
|
||||
<span class="tcount-fill" style={`width:${countShare(t)}%`}></span>
|
||||
<span class="tcount" class:empty={!t.listeners} title={t.listeners == null ? "No Last.fm listener count for this track" : `${t.listeners.toLocaleString()} Last.fm listeners`}>
|
||||
<span class="tcount-fill" style={`width:${countShare(t)}%`}>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if locked}
|
||||
<span class="tag-unreleased">Unreleased</span>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user