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:
KeelrLeek committed 2026-07-28 17:00:18 -05:00
1 parent 7a517dca59
commit 122157e061
3 files changed
+128 -30

No files matched your search

+27 -13
View File
@@ -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}
<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-val">{fmtCount(t.listeners)}</span>
<span class="tcount-fill" style={`width:${countShare(t)}%`}>
<span class="tcount-val">{fmtCount(t.listeners)}</span>
</span>
</span>
{/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));
}
+29 -17
View File
@@ -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 || ""))}<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-val">{fmtCount(t.listeners)}</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}
@@ -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));
}