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));
}