Files
letterboxd-rating-sorter/ranker.html
2026-09-22 23:01:56 +02:00

1369 lines
46 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Letterboxd Pairwise Ranker</title>
<script>
// Runs before the body renders, so light mode does not flash dark first.
try {
if (localStorage.getItem("lbx-ranker-theme") === "light") { document.documentElement.setAttribute("data-theme", "light"); }
} catch (error) { /* site data blocked, stay on the dark default */ }
</script>
<style>
:root {
--bg: #14181c;
--fg: #e7ebee;
--muted: #99aabb;
--card: #1d242c;
--border: #2c343c;
--accent: #00e054;
--accent-fg: #14181c;
--shadow: 0 2px 12px rgba(0, 0, 0, .45);
}
:root[data-theme="light"] {
--bg: #f6f5f3;
--fg: #14181c;
--muted: #6a737c;
--card: #ffffff;
--border: #dfdcd6;
--accent: #00a942;
--accent-fg: #ffffff;
--shadow: 0 2px 10px rgba(20, 24, 28, .10);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 15px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: 14px 22px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 14px;
flex-wrap: wrap;
}
header h1 { font-size: 16px; margin: 0; letter-spacing: .01em; }
header .sub { color: var(--muted); font-size: 13px; }
#themeBtn { padding: 3px 10px; font-size: 12px; border-radius: 6px; color: var(--muted); }
main { flex: 1; display: flex; flex-direction: column; padding: 22px; min-height: 0; }
.screen { display: none; }
.screen.active { display: flex; flex-direction: column; flex: 1; min-height: 0; }
h2 { font-size: 20px; margin: 0 0 6px; font-weight: 600; }
p.lead { color: var(--muted); margin: 0 0 18px; }
button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--fg);
padding: 9px 16px;
cursor: pointer;
}
button:hover { border-color: var(--muted); }
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; font-weight: 600; }
button.primary:hover { filter: brightness(1.08); }
button:disabled { opacity: .45; cursor: default; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
#drop {
border: 2px dashed var(--border);
border-radius: 14px;
padding: 44px 24px;
text-align: center;
background: var(--card);
transition: border-color .12s;
max-width: 720px;
}
#drop.over { border-color: var(--accent); }
#drop h2 { margin-bottom: 10px; }
.files { margin-top: 16px; font-size: 13px; color: var(--muted); }
.files div { margin: 3px 0; }
.ok { color: var(--accent); }
.err { color: #e0533d; }
.hint { color: var(--muted); font-size: 13px; }
code { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-size: 12px; }
#sessions { margin-top: 26px; max-width: 720px; }
#sessions h3 { font-size: 14px; font-weight: 600; margin: 0 0 10px; }
.session {
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card);
padding: 10px 12px;
margin-bottom: 8px;
}
.session .about { flex: 1; min-width: 0; }
.session .what { font-weight: 600; }
.session .meta { color: var(--muted); font-size: 12px; }
.session .titles { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session .drop-btn { color: var(--muted); padding: 6px 10px; }
.session .drop-btn:hover { color: #e0533d; border-color: #e0533d; }
.bar { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; flex-wrap: wrap; }
.count { font-variant-numeric: tabular-nums; font-size: 14px; color: var(--muted); }
.progress { flex: 1; min-width: 120px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
.progress > div { height: 100%; background: var(--accent); width: 0; transition: width .18s; }
.compare-body { display: flex; gap: 18px; flex: 1; min-height: 0; }
.arena {
flex: 1;
display: flex;
gap: 26px;
align-items: center;
justify-content: center;
padding: 18px 0;
flex-wrap: wrap;
}
#sidebar {
width: 272px;
flex: none;
position: relative;
border-left: 1px solid var(--border);
padding: 10px 0 10px 16px;
overflow-y: auto;
font-size: 13px;
}
#sidebar.hidden { display: none; }
@media (max-width: 900px) { #sidebar { display: none; } }
.side-head {
color: var(--muted);
margin-bottom: 8px;
position: sticky;
top: 0;
background: var(--bg);
padding-bottom: 6px;
z-index: 1;
}
.side-head b { color: var(--fg); font-weight: 600; }
.side-list { list-style: none; margin: 0; padding: 0; }
.side-list li {
display: flex;
gap: 8px;
align-items: center;
padding: 3px 6px 3px 0;
border-radius: 6px;
color: var(--muted);
}
.side-list li:last-child { margin-bottom: 4px; }
.side-list li.pivot {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--fg);
padding-left: 6px;
font-weight: 600;
}
.side-list .n { width: 30px; text-align: right; font-variant-numeric: tabular-nums; flex: none; }
.side-list img { width: 22px; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 2px; background: var(--border); flex: none; }
.side-list .ph { width: 22px; aspect-ratio: 2 / 3; border-radius: 2px; background: var(--border); flex: none; }
.side-list .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The break that says two blocks have no established order between them. */
.side-split {
display: block;
border-top: 1px dashed var(--muted);
margin: 9px 0 9px 38px;
padding: 0;
height: 0;
opacity: .55;
}
.pick {
width: min(38vw, 300px);
padding: 0;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
background: var(--card);
box-shadow: var(--shadow);
transition: transform .1s, border-color .1s;
display: flex;
flex-direction: column;
text-align: left;
}
.pick:hover, .pick:focus-visible { transform: translateY(-3px); border-color: var(--accent); outline: none; }
.poster { aspect-ratio: 2 / 3; width: 100%; background: var(--bg); display: block; object-fit: cover; }
.poster.blank {
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
text-align: center;
font-size: 17px;
font-weight: 600;
color: var(--muted);
}
.cap { padding: 10px 12px; }
.cap .t { font-weight: 600; }
.cap .y { color: var(--muted); font-size: 13px; }
.key { display: inline-block; min-width: 18px; text-align: center; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; font-size: 11px; color: var(--muted); }
/* Result: band editor + ranked list */
.bands {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(122px, 1fr));
gap: 8px;
margin: 12px 0 6px;
}
.band-cell {
border: 1px solid var(--border);
border-radius: 9px;
background: var(--card);
padding: 8px 9px;
}
.band-cell .stars { font-size: 13px; color: var(--accent); letter-spacing: -1px; white-space: nowrap; }
.band-cell .value { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; margin-left: 5px; }
.band-cell .ctrl { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
.band-cell input {
width: 100%;
min-width: 0;
font: inherit;
font-variant-numeric: tabular-nums;
text-align: center;
padding: 3px 2px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
}
.band-cell button { padding: 2px 8px; border-radius: 6px; line-height: 1.2; }
.band-cell .range { font-size: 11px; color: var(--muted); margin-top: 4px; font-variant-numeric: tabular-nums; }
.ranking { list-style: none; margin: 10px 0 0; padding: 0; max-width: 820px; }
.ranking li { display: flex; gap: 12px; align-items: center; padding: 7px 0; border-bottom: 1px solid var(--border); }
.ranking li.band-head {
border-bottom: none;
padding: 16px 0 4px;
gap: 8px;
color: var(--accent);
font-weight: 600;
position: sticky;
top: 0;
background: var(--bg);
}
.ranking li.band-head .n { color: var(--accent); }
.ranking .n { width: 42px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
.ranking img { width: 34px; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 3px; background: var(--border); }
.ranking .ph { width: 34px; aspect-ratio: 2 / 3; border-radius: 3px; background: var(--border); }
.ranking a { color: inherit; text-decoration: none; }
.ranking a:hover { color: var(--accent); }
.scroll { overflow-y: auto; flex: 1; min-height: 0; }
</style>
</head>
<body>
<header>
<h1>Letterboxd Pairwise Ranker</h1>
<span class="sub">two films at a time, until the whole list has an order</span>
<span class="sub" id="posterStatus" style="margin-left:auto"></span>
<button id="themeBtn">Theme</button>
</header>
<main>
<section class="screen active" id="screenLoad">
<div id="drop">
<h2>Drop your <code>ratings.csv</code> here</h2>
<p class="lead" id="dropLead">Also accepts <code>posters.json</code> from <code>fetch_posters.py</code>, for box art.</p>
<div class="row" style="justify-content:center">
<button class="primary" id="pickBtn">Choose files</button>
<input type="file" id="fileInput" accept=".csv,.json" multiple hidden>
</div>
<div class="files" id="fileStatus"></div>
</div>
<div id="sessions" hidden>
<h3>Continue where you left off</h3>
<div id="sessionList"></div>
</div>
</section>
<section class="screen" id="screenStart">
<h2 id="startTitle"></h2>
<p class="lead" id="startLead"></p>
<div class="row">
<button class="primary" id="resumeBtn" hidden>Resume</button>
<button class="primary" id="startBtn">Start ranking</button>
<button id="backBtn">Load different files</button>
</div>
</section>
<section class="screen" id="screenCompare">
<div class="bar">
<span class="count" id="counter"></span>
<div class="progress"><div id="progressFill"></div></div>
<button id="undoBtn">Undo</button>
<button id="listBtn">Hide list</button>
<button id="quitBtn">Stop</button>
</div>
<div class="compare-body">
<div class="arena">
<button class="pick" id="pickA"></button>
<button class="pick" id="pickB"></button>
</div>
<aside id="sidebar">
<div class="side-head" id="sideHead"></div>
<ol class="side-list" id="sideList"></ol>
</aside>
</div>
<p class="hint" style="text-align:center">
<span class="key">1</span> <span class="key">&larr;</span> left &nbsp;&middot;&nbsp;
<span class="key">2</span> <span class="key">&rarr;</span> right &nbsp;&middot;&nbsp;
<span class="key">U</span> undo &nbsp;&middot;&nbsp; progress saved in browser cache
</p>
</section>
<section class="screen" id="screenResult">
<h2 id="resultTitle"></h2>
<div class="row">
<span class="hint">Star thresholds:</span>
<button id="presetOld">Match old ratings</button>
<button id="presetEven">Even split</button>
<button id="presetTop">Top heavy</button>
</div>
<div class="bands" id="bands"></div>
<div class="row" style="margin-top:8px">
<button class="primary" id="csvBtn">Download rated CSV</button>
<button id="copyBtn">Copy as text</button>
<button id="againBtn">Rank again</button>
<button id="newBtn">Load different files</button>
<span class="hint" id="copyNote"></span>
</div>
<div class="scroll"><ol class="ranking" id="ranking"></ol></div>
</section>
</main>
<script>
"use strict";
const STORAGE_PREFIX = "lbx-ranker-v1-";
const BAND_COUNT = 10; // 5.0 down to 0.5 in half steps
const POSTER_BATCH = 20;
let header = null; // original csv header cells
let films = null; // parsed csv rows, file order
let posters = {}; // uri -> poster image url
let state = null; // { key, header, items, answers, counts }
let answerMap = null; // "keyA|keyB" -> winning key
let current = null; // { done, pair, ranked }
let hasPosterServer = false;
let resolving = false;
function $(id) { return document.getElementById(id); }
function show(screenId) {
for (const section of document.querySelectorAll(".screen")) {
section.classList.toggle("active", section.id === screenId);
}
}
function hashKey(keys) {
let hash = 0x811c9dc5;
for (const key of keys.slice().sort()) {
for (let i = 0; i < key.length; i++) {
hash = ((hash ^ key.charCodeAt(i)) * 0x01000193) >>> 0;
}
}
return hash.toString(16);
}
function shuffle(list) {
const out = list.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const swap = out[i];
out[i] = out[j];
out[j] = swap;
}
return out;
}
// Worst case comparison count: every merge of two runs can cost their combined length minus one.
function worstCase(n) {
let total = 0;
let sizes = new Array(n).fill(1);
while (sizes.length > 1) {
const next = [];
for (let i = 0; i + 1 < sizes.length; i += 2) {
total += sizes[i] + sizes[i + 1] - 1;
next.push(sizes[i] + sizes[i + 1]);
}
if (sizes.length % 2 === 1) { next.push(sizes[sizes.length - 1]); }
sizes = next;
}
return total;
}
function roundCount(n) { return n > 1 ? Math.ceil(Math.log2(n)) : 0; }
// -- CSV in
function parseCsv(text) {
const rows = [];
let row = [], field = "", quoted = false;
if (text.charCodeAt(0) === 0xfeff) { text = text.slice(1); }
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c !== '"') { field += c; }
else if (text[i + 1] === '"') { field += '"'; i++; }
else { quoted = false; }
}
else if (c === '"') { quoted = true; }
else if (c === ",") { row.push(field); field = ""; }
else if (c === "\n") { row.push(field); rows.push(row); row = []; field = ""; }
else if (c !== "\r") { field += c; }
}
if (field !== "" || row.length) { row.push(field); rows.push(row); }
return rows;
}
// Each film keeps its original row so the export can reproduce the input format.
function filmsFromCsv(text) {
const rows = parseCsv(text).filter(row => row.some(cell => cell.trim() !== ""));
if (rows.length === 0) { throw new Error("that file is empty"); }
const head = rows[0];
const lower = head.map(cell => cell.trim().toLowerCase());
const nameAt = lower.indexOf("name");
const yearAt = lower.indexOf("year");
const uriAt = lower.indexOf("letterboxd uri");
const ratingAt = lower.indexOf("rating");
if (nameAt < 0) {
throw new Error("no 'Name' column, so this is not a Letterboxd ratings.csv. Header reads: " + head.join(", "));
}
const seen = new Set();
const out = [];
for (const row of rows.slice(1)) {
const title = (row[nameAt] || "").trim();
if (title === "") { continue; }
const uri = uriAt >= 0 ? (row[uriAt] || "").trim() : "";
const year = yearAt >= 0 ? (row[yearAt] || "").trim() : "";
const key = uri || title + "|" + year;
if (seen.has(key)) { continue; }
seen.add(key);
out.push({
title: title,
year: year,
uri: uri,
key: key,
oldRating: ratingAt >= 0 ? parseFloat(row[ratingAt]) : NaN,
row: row
});
}
if (out.length < 2) { throw new Error("need at least two films, found " + out.length); }
return { header: head, films: out };
}
// -- Sort engine: pure function of items + answers
function buildAnswerMap(answers) {
const map = Object.create(null);
for (const answer of answers) {
map[answer.a + "|" + answer.b] = answer.winner;
map[answer.b + "|" + answer.a] = answer.winner;
}
return map;
}
// Consume the answers this merge already has. Returns the pair it is waiting on, or null
// once the two runs are fully interleaved.
function advanceMerge(merge, map) {
while (merge.i < merge.left.length && merge.j < merge.right.length) {
const a = merge.left[merge.i];
const b = merge.right[merge.j];
const winner = map[a.key + "|" + b.key];
if (winner === undefined) { return { a: a, b: b }; }
if (winner === a.key) { merge.out.push(a); merge.i++; } else { merge.out.push(b); merge.j++; }
}
while (merge.i < merge.left.length) { merge.out.push(merge.left[merge.i++]); }
while (merge.j < merge.right.length) { merge.out.push(merge.right[merge.j++]); }
return null;
}
// Every group in a round is waiting on its own pair, so ask the one that shares no film with
// the previous question. Two fresh posters each time reads as progress; the same film held
// against a parade of opponents does not.
function pickFreshest(blocked, lastKeys) {
let halfFresh = null;
for (const entry of blocked) {
const seenA = lastKeys.has(entry.pending.a.key);
const seenB = lastKeys.has(entry.pending.b.key);
if (seenA === false && seenB === false) { return entry; }
if (halfFresh === null && (seenA === false || seenB === false)) { halfFresh = entry; }
}
return halfFresh !== null ? halfFresh : blocked[0];
}
// Bottom-up merge sort, best first. Rounds halve the number of runs; within a round the
// groups merge independently, which is what lets the questions rotate between them.
function runSort(items, map, lastKeys) {
let runs = items.map(item => [item]);
let round = 1;
while (runs.length > 1) {
const merges = [];
for (let i = 0; i + 1 < runs.length; i += 2) {
merges.push({ left: runs[i], right: runs[i + 1], i: 0, j: 0, out: [] });
}
const carry = runs.length % 2 === 1 ? runs[runs.length - 1] : null;
const blocked = [];
merges.forEach((merge, group) => {
const pending = advanceMerge(merge, map);
if (pending !== null) { blocked.push({ merge: merge, group: group, pending: pending }); }
});
if (blocked.length > 0) {
const chosen = pickFreshest(blocked, lastKeys || new Set());
return {
done: false,
pair: [chosen.pending.a, chosen.pending.b],
merge: chosen.merge,
group: chosen.group,
merges: merges,
carry: carry,
round: round,
ranked: longestRun(runs)
};
}
runs = merges.map(merge => merge.out);
if (carry !== null) { runs.push(carry); }
round++;
}
return { done: true, pair: null, ranked: runs[0] || [] };
}
// The best partial answer available: the largest group that is already fully ordered.
function longestRun(runs) {
let best = [];
for (const run of runs) {
if (run.length > best.length) { best = run; }
}
return best;
}
function lastAnswerKeys(answers) {
const keys = new Set();
const last = answers[answers.length - 1];
if (last !== undefined) { keys.add(last.a); keys.add(last.b); }
return keys;
}
// -- Persistence
function save() {
if (state === null) { return; }
try {
localStorage.setItem(STORAGE_PREFIX + state.key, JSON.stringify(state));
} catch (error) {
// Private window or blocked site data. Everything still works, minus resume.
}
}
function load(key) {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + key);
if (raw === null) { return null; }
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.items) || !Array.isArray(data.answers)) { return null; }
return data;
} catch (error) {
return null;
}
}
// -- Loading files
// -- Saved sessions
// A session stores the films with their original csv rows, so it can be continued and
// exported without the export being dropped again.
function listSessions() {
const sessions = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const storageKey = localStorage.key(i);
if (storageKey === null || storageKey.indexOf(STORAGE_PREFIX) !== 0) { continue; }
let data = null;
try { data = JSON.parse(localStorage.getItem(storageKey)); } catch (error) { continue; }
if (!data || !Array.isArray(data.items) || !Array.isArray(data.answers) || data.answers.length === 0) { continue; }
sessions.push({ storageKey: storageKey, key: data.key || storageKey.slice(STORAGE_PREFIX.length), data: data });
}
} catch (error) {
return [];
}
sessions.sort((a, b) => (b.data.savedAt || 0) - (a.data.savedAt || 0));
return sessions;
}
function timeAgo(stamp) {
if (!stamp) { return "date unknown"; }
const minutes = Math.floor((Date.now() - stamp) / 60000);
if (minutes < 1) { return "just now"; }
if (minutes < 60) { return minutes + (minutes === 1 ? " minute ago" : " minutes ago"); }
const hours = Math.floor(minutes / 60);
if (hours < 24) { return hours + (hours === 1 ? " hour ago" : " hours ago"); }
const days = Math.floor(hours / 24);
if (days <= 30) { return days + (days === 1 ? " day ago" : " days ago"); }
return new Date(stamp).toLocaleDateString();
}
function renderSessions() {
const sessions = listSessions();
const holder = $("sessions");
const list = $("sessionList");
holder.hidden = sessions.length === 0;
list.innerHTML = "";
for (const session of sessions) {
const items = session.data.items;
const answers = session.data.answers;
const finished = runSort(items, buildAnswerMap(answers), new Set()).done;
const row = document.createElement("div");
row.className = "session";
const about = document.createElement("div");
about.className = "about";
const what = document.createElement("div");
what.className = "what";
what.textContent = items.length + " films";
const meta = document.createElement("div");
meta.className = "meta";
meta.textContent = (finished
? "finished in " + answers.length.toLocaleString() + " comparisons"
: answers.length.toLocaleString() + " of ~" + worstCase(items.length).toLocaleString() + " answered")
+ " - " + timeAgo(session.data.savedAt);
const titles = document.createElement("div");
titles.className = "titles";
titles.textContent = items.slice(0, 3).map(film => film.title).join(", ") + (items.length > 3 ? ", ..." : "");
about.appendChild(what);
about.appendChild(meta);
about.appendChild(titles);
const open = document.createElement("button");
open.className = "primary";
open.textContent = finished ? "Open result" : "Continue";
open.onclick = () => continueSession(session);
const drop = document.createElement("button");
drop.className = "drop-btn";
drop.textContent = "Delete";
drop.title = "Forget this ranking";
drop.onclick = () => {
if (window.confirm("Delete this saved ranking of " + items.length + " films? This cannot be undone.") === false) { return; }
try { localStorage.removeItem(session.storageKey); } catch (error) { /* nothing to undo */ }
renderSessions();
};
row.appendChild(about);
row.appendChild(open);
row.appendChild(drop);
list.appendChild(row);
}
}
function continueSession(session) {
header = session.data.header || null;
films = session.data.items;
begin(session.key, session.data.items, session.data.answers, session.data.counts, session.data.savedAt);
resolvePosters();
}
function fileStatus(message, className) {
const line = document.createElement("div");
line.textContent = message;
if (className) { line.className = className; }
$("fileStatus").appendChild(line);
}
function handleFiles(fileList) {
const list = Array.from(fileList);
if (list.length === 0) { return; }
$("fileStatus").innerHTML = "";
let pending = list.length;
for (const file of list) {
const reader = new FileReader();
reader.onload = () => {
try {
if (/\.json$/i.test(file.name)) {
const data = JSON.parse(reader.result);
posters = {};
let count = 0;
for (const uri of Object.keys(data)) {
if (data[uri]) { posters[uri] = data[uri]; count++; }
}
fileStatus(file.name + ": " + count + " poster URLs", "ok");
} else {
const parsed = filmsFromCsv(reader.result);
header = parsed.header;
films = parsed.films;
fileStatus(file.name + ": " + films.length + " films", "ok");
}
} catch (error) {
fileStatus(file.name + ": " + error.message, "err");
}
pending--;
if (pending === 0) { afterLoad(); }
};
reader.onerror = () => {
fileStatus(file.name + ": could not be read", "err");
pending--;
if (pending === 0) { afterLoad(); }
};
reader.readAsText(file);
}
}
function afterLoad() {
if (films === null) {
fileStatus("Still waiting for a ratings.csv.", "");
return;
}
const key = hashKey(films.map(film => film.key));
const saved = load(key);
$("startTitle").textContent = films.length + " films";
updateStartLead();
const resumeBtn = $("resumeBtn");
if (saved !== null && saved.answers.length > 0) {
resumeBtn.hidden = false;
resumeBtn.textContent = "Resume (" + saved.answers.length + " answered)";
resumeBtn.onclick = () => begin(key, saved.items, saved.answers, saved.counts, saved.savedAt);
$("startBtn").className = "";
$("startBtn").textContent = "Start over";
} else {
resumeBtn.hidden = true;
$("startBtn").className = "primary";
$("startBtn").textContent = "Start ranking";
}
$("startBtn").onclick = () => begin(key, shuffle(films), [], null);
show("screenStart");
resolvePosters();
}
function updateStartLead() {
if (films === null) { return; }
const withPosters = films.filter(film => posters[film.uri]).length;
let art;
if (resolving) { art = "Looking up box art. "; }
else if (withPosters > 0) { art = withPosters + " of them have box art. "; }
else { art = "No posters loaded, so films show as text. "; }
$("startLead").textContent =
"About " + worstCase(films.length).toLocaleString() + " comparisons. " + art
+ "Progress saves as you go, so you can stop any time.";
}
// -- Posters from serve.py
// A file:// page cannot reach letterboxd.com, so serve.py fetches on the page's behalf.
async function detectPosterServer() {
if (location.protocol !== "http:" && location.protocol !== "https:") { return; }
try {
const response = await fetch("api/health");
hasPosterServer = response.ok;
} catch (error) {
hasPosterServer = false;
}
}
async function resolvePosters() {
await serverProbe;
if (hasPosterServer === false || resolving || films === null) { return; }
const missing = films.map(film => film.uri).filter(uri => uri && posters[uri] === undefined);
if (missing.length === 0) { return; }
resolving = true;
let done = 0;
updateStartLead();
for (let i = 0; i < missing.length; i += POSTER_BATCH) {
const batch = missing.slice(i, i + POSTER_BATCH);
try {
const response = await fetch("api/posters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uris: batch })
});
const data = await response.json();
for (const uri of Object.keys(data)) {
posters[uri] = data[uri] || null;
}
} catch (error) {
$("posterStatus").textContent = "poster lookup failed, films show as text";
break;
}
done += batch.length;
$("posterStatus").textContent = "box art " + done + " / " + missing.length;
refreshPosters(batch);
updateStartLead();
}
resolving = false;
updateStartLead();
const found = films.filter(film => posters[film.uri]).length;
$("posterStatus").textContent = "box art " + found + " / " + films.length;
}
// Redraw only when a film already on screen just got its poster.
function refreshPosters(arrived) {
if (current === null) { return; }
const fresh = new Set(arrived.filter(uri => posters[uri]));
if (current.done) {
if (current.ranked.some(film => fresh.has(film.uri))) { renderRanking(); }
} else if (current.pair.some(film => fresh.has(film.uri))) {
renderCard($("pickA"), current.pair[0]);
renderCard($("pickB"), current.pair[1]);
}
}
// -- Ranking
// savedAt tracks the last answer, not the last time the session was opened, so the list on
// the load screen keeps its order when you look at an old ranking.
function begin(key, items, answers, counts, savedAt) {
state = {
key: key,
header: header,
items: items,
answers: answers,
counts: counts || null,
savedAt: savedAt || Date.now()
};
save();
render();
}
function render() {
answerMap = buildAnswerMap(state.answers);
current = runSort(state.items, answerMap, lastAnswerKeys(state.answers));
if (current.done) { renderResult(); return; }
const total = worstCase(state.items.length);
const answered = state.answers.length;
$("counter").textContent = answered.toLocaleString() + " / ~" + total.toLocaleString();
$("progressFill").style.width = Math.min(100, (answered / Math.max(1, total)) * 100) + "%";
$("undoBtn").disabled = answered === 0;
renderCard($("pickA"), current.pair[0]);
renderCard($("pickB"), current.pair[1]);
renderSidebar();
show("screenCompare");
preloadNext();
}
// Everything known so far, best at the top. Each block is ordered within itself; a break
// between two blocks means their order has not been decided yet.
function orderedBlocks() {
const blocks = [];
for (const merge of current.merges) {
// Anything already emitted by a merge outranks whatever is left in both of its runs.
if (merge.out.length > 0) { blocks.push(merge.out); }
const leftRest = merge.left.slice(merge.i);
const rightRest = merge.right.slice(merge.j);
if (leftRest.length > 0) { blocks.push(leftRest); }
if (rightRest.length > 0) { blocks.push(rightRest); }
}
if (current.carry !== null) { blocks.push(current.carry); }
return blocks;
}
function renderSidebar() {
// A film nobody has judged yet says nothing about the order, so it stays out of the list.
const seen = new Set();
for (const answer of state.answers) {
seen.add(answer.a);
seen.add(answer.b);
}
seen.add(current.pair[0].key);
seen.add(current.pair[1].key);
const blocks = orderedBlocks()
.map(block => block.filter(film => seen.has(film.key)))
.filter(block => block.length > 0);
const head = $("sideHead");
head.innerHTML = "";
head.appendChild(headLine("Round " + current.round + " of " + roundCount(state.items.length)));
head.appendChild(headLine(seen.size + " of " + state.items.length + " films seen"));
head.appendChild(headLine("a break means the order across it is still unknown"));
const contenders = new Set([current.pair[0].key, current.pair[1].key]);
const list = $("sideList");
list.innerHTML = "";
let firstPivot = null;
blocks.forEach((block, index) => {
if (index > 0) {
const split = document.createElement("li");
split.className = "side-split";
list.appendChild(split);
}
block.forEach((film, position) => {
const row = sideRow(film, block.length > 1 ? position + 1 : null, contenders.has(film.key) ? "pivot" : "");
if (firstPivot === null && contenders.has(film.key)) { firstPivot = row; }
list.appendChild(row);
});
});
const sidebar = $("sidebar");
if (firstPivot !== null) {
sidebar.scrollTop = firstPivot.offsetTop - sidebar.clientHeight / 2 + firstPivot.offsetHeight / 2;
}
}
function sideRow(film, position, className) {
const item = document.createElement("li");
if (className) { item.className = className; }
const number = document.createElement("span");
number.className = "n";
number.textContent = position === null ? "" : position + ".";
const poster = posters[film.uri];
let art;
if (poster) {
art = document.createElement("img");
art.src = poster;
art.alt = "";
art.loading = "lazy";
} else {
art = document.createElement("span");
art.className = "ph";
}
const title = document.createElement("span");
title.className = "t";
title.textContent = film.title;
title.title = film.title + (film.year ? " (" + film.year + ")" : "");
item.appendChild(number);
item.appendChild(art);
item.appendChild(title);
return item;
}
function headLine(text) {
const line = document.createElement("div");
line.textContent = text;
return line;
}
function renderCard(button, film) {
const poster = posters[film.uri];
button.innerHTML = "";
if (poster) {
const img = document.createElement("img");
img.className = "poster";
img.src = poster;
img.alt = film.title;
img.onerror = () => { img.replaceWith(blankPoster(film)); };
button.appendChild(img);
} else {
button.appendChild(blankPoster(film));
}
const cap = document.createElement("div");
cap.className = "cap";
const title = document.createElement("div");
title.className = "t";
title.textContent = film.title;
const year = document.createElement("div");
year.className = "y";
year.textContent = film.year;
cap.appendChild(title);
cap.appendChild(year);
button.appendChild(cap);
}
function blankPoster(film) {
const blank = document.createElement("div");
blank.className = "poster blank";
blank.textContent = film.title;
return blank;
}
// Either answer leads to a known next pair, so fetch both ahead of the click.
function preloadNext() {
for (const winner of [current.pair[0].key, current.pair[1].key]) {
const map = Object.assign(Object.create(null), answerMap);
map[current.pair[0].key + "|" + current.pair[1].key] = winner;
map[current.pair[1].key + "|" + current.pair[0].key] = winner;
const next = runSort(state.items, map, new Set([current.pair[0].key, current.pair[1].key]));
if (next.done) { continue; }
for (const film of next.pair) {
const url = posters[film.uri];
if (url) { new Image().src = url; }
}
}
}
function answer(index) {
if (current === null || current.done) { return; }
state.answers.push({ a: current.pair[0].key, b: current.pair[1].key, winner: current.pair[index].key });
state.savedAt = Date.now();
save();
render();
}
function undo() {
if (state === null || state.answers.length === 0) { return; }
state.answers.pop();
state.savedAt = Date.now();
save();
render();
}
// -- Star bands
function bandValue(band) { return (BAND_COUNT - band) / 2; }
function formatRating(value) { return Number.isInteger(value) ? String(value) : value.toFixed(1); }
function starGlyphs(value) {
const full = Math.floor(value);
return "★".repeat(full) + (value - full >= 0.5 ? "½" : "");
}
function evenCounts(n) {
const counts = new Array(BAND_COUNT).fill(Math.floor(n / BAND_COUNT));
for (let i = 0; i < n % BAND_COUNT; i++) { counts[i]++; }
return counts;
}
// Same shape as the old ratings, so only which film sits where changes.
function oldRatingCounts(items) {
const counts = new Array(BAND_COUNT).fill(0);
const middle = BAND_COUNT - Math.round(3 * 2);
for (const item of items) {
const value = item.oldRating;
const band = Number.isFinite(value) ? BAND_COUNT - Math.round(value * 2) : middle;
counts[Math.min(BAND_COUNT - 1, Math.max(0, band))]++;
}
return counts;
}
function topHeavyCounts(n) {
const weights = [3, 6, 11, 15, 18, 17, 13, 9, 5, 3];
const total = weights.reduce((sum, weight) => sum + weight, 0);
const counts = weights.map(weight => Math.floor(n * weight / total));
let left = n - counts.reduce((sum, count) => sum + count, 0);
for (let i = 0; left > 0; i = (i + 1) % BAND_COUNT) { counts[i]++; left--; }
return counts;
}
function ensureCounts() {
const n = current.ranked.length;
const counts = state.counts;
if (Array.isArray(counts) && counts.length === BAND_COUNT
&& counts.reduce((sum, count) => sum + count, 0) === n) {
return;
}
const hasOld = current.ranked.some(film => Number.isFinite(film.oldRating));
state.counts = hasOld ? oldRatingCounts(current.ranked) : evenCounts(n);
save();
}
function setCounts(counts) {
state.counts = counts;
save();
renderResult();
}
// Moving a boundary takes from, or gives to, the neighbouring bands below it.
function nudge(band, delta) {
const counts = state.counts.slice();
if (delta > 0) {
let donor = -1;
for (let i = band + 1; i < BAND_COUNT; i++) { if (counts[i] > 0) { donor = i; break; } }
if (donor < 0) { return; }
counts[band]++;
counts[donor]--;
} else {
if (counts[band] === 0) { return; }
const target = band + 1 < BAND_COUNT ? band + 1 : band - 1;
counts[band]--;
counts[target]++;
}
setCounts(counts);
}
function setBandCount(band, wanted) {
const n = current.ranked.length;
const counts = state.counts.slice();
const value = Math.max(0, Math.min(n, Math.floor(wanted)));
let delta = value - counts[band];
counts[band] = value;
// Pull the difference from the bands below, then from the bands above.
const order = [];
for (let i = band + 1; i < BAND_COUNT; i++) { order.push(i); }
for (let i = band - 1; i >= 0; i--) { order.push(i); }
for (const other of order) {
if (delta === 0) { break; }
if (delta > 0) {
const take = Math.min(delta, counts[other]);
counts[other] -= take;
delta -= take;
} else {
counts[other] -= delta;
delta = 0;
}
}
if (delta !== 0) { counts[band] -= delta; }
setCounts(counts);
}
function bandOfIndex() {
const map = new Array(current.ranked.length);
let index = 0;
for (let band = 0; band < BAND_COUNT; band++) {
for (let i = 0; i < state.counts[band]; i++) { map[index++] = band; }
}
for (; index < map.length; index++) { map[index] = BAND_COUNT - 1; }
return map;
}
// -- Result
function renderResult() {
ensureCounts();
const ranked = current.ranked;
$("resultTitle").textContent =
"Your ranking - " + ranked.length + " films in " + state.answers.length.toLocaleString() + " comparisons";
renderBands();
renderRanking();
show("screenResult");
}
function renderBands() {
const holder = $("bands");
holder.innerHTML = "";
let start = 1;
for (let band = 0; band < BAND_COUNT; band++) {
const count = state.counts[band];
const cell = document.createElement("div");
cell.className = "band-cell";
const stars = document.createElement("div");
stars.className = "stars";
stars.textContent = starGlyphs(bandValue(band));
const value = document.createElement("span");
value.className = "value";
value.textContent = " " + formatRating(bandValue(band));
stars.appendChild(value);
const ctrl = document.createElement("div");
ctrl.className = "ctrl";
const minus = document.createElement("button");
minus.textContent = "−";
minus.onclick = () => nudge(band, -1);
const input = document.createElement("input");
input.type = "number";
input.min = "0";
input.value = String(count);
input.onchange = () => setBandCount(band, parseInt(input.value, 10) || 0);
const plus = document.createElement("button");
plus.textContent = "+";
plus.onclick = () => nudge(band, 1);
ctrl.appendChild(minus);
ctrl.appendChild(input);
ctrl.appendChild(plus);
const range = document.createElement("div");
range.className = "range";
range.textContent = count > 0 ? "#" + start + " - #" + (start + count - 1) : "empty";
cell.appendChild(stars);
cell.appendChild(ctrl);
cell.appendChild(range);
holder.appendChild(cell);
start += count;
}
}
function renderRanking() {
const list = $("ranking");
const bands = bandOfIndex();
list.innerHTML = "";
let lastBand = -1;
current.ranked.forEach((film, index) => {
if (bands[index] !== lastBand) {
lastBand = bands[index];
const head = document.createElement("li");
head.className = "band-head";
const label = document.createElement("span");
label.className = "n";
label.textContent = formatRating(bandValue(lastBand));
const stars = document.createElement("span");
stars.textContent = starGlyphs(bandValue(lastBand)) + " (" + state.counts[lastBand] + ")";
head.appendChild(label);
head.appendChild(stars);
list.appendChild(head);
}
list.appendChild(rankingRow(film, index));
});
}
function rankingRow(film, index) {
const item = document.createElement("li");
const number = document.createElement("span");
number.className = "n";
number.textContent = (index + 1) + ".";
const poster = posters[film.uri];
let art;
if (poster) {
art = document.createElement("img");
art.src = poster;
art.alt = "";
art.loading = "lazy";
} else {
art = document.createElement("span");
art.className = "ph";
}
const label = document.createElement("span");
if (film.uri) {
const link = document.createElement("a");
link.href = film.uri;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = film.title;
label.appendChild(link);
} else {
label.appendChild(document.createTextNode(film.title));
}
if (film.year) {
const year = document.createElement("span");
year.style.color = "var(--muted)";
year.textContent = " " + film.year;
label.appendChild(year);
}
item.appendChild(number);
item.appendChild(art);
item.appendChild(label);
return item;
}
// -- Export
function rankingText() {
const bands = bandOfIndex();
return current.ranked.map((film, index) =>
(index + 1) + ". " + film.title + (film.year ? " (" + film.year + ")" : "")
+ " - " + formatRating(bandValue(bands[index]))).join("\n");
}
// Input header and columns, in ranking order, with Rating replaced by the new value.
function rankingCsv() {
const escape = value => /[",\n]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value;
const head = (state.header || ["Name", "Year", "Letterboxd URI", "Rating"]).slice();
let ratingAt = head.findIndex(cell => cell.trim().toLowerCase() === "rating");
if (ratingAt < 0) { head.push("Rating"); ratingAt = head.length - 1; }
const bands = bandOfIndex();
const lines = [head.map(escape).join(",")];
current.ranked.forEach((film, index) => {
const row = (film.row || [film.title, film.year, film.uri]).slice(0, head.length);
while (row.length < head.length) { row.push(""); }
row[ratingAt] = formatRating(bandValue(bands[index]));
lines.push(row.map(escape).join(","));
});
return lines.join("\n");
}
function download(name, text) {
const url = URL.createObjectURL(new Blob([text], { type: "text/csv;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = name;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function copyText(text) {
const fallback = () => {
const area = document.createElement("textarea");
area.value = text;
document.body.appendChild(area);
area.select();
try { document.execCommand("copy"); } catch (error) { /* nothing else to try */ }
area.remove();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(fallback);
} else {
fallback();
}
$("copyNote").textContent = "Copied.";
setTimeout(() => { $("copyNote").textContent = ""; }, 2000);
}
// -- Wiring
const serverProbe = detectPosterServer().then(() => {
if (hasPosterServer) {
$("dropLead").textContent = "Box art is fetched for you, so the csv on its own is enough.";
$("posterStatus").textContent = "box art server connected";
}
});
$("pickBtn").onclick = () => $("fileInput").click();
$("fileInput").onchange = event => handleFiles(event.target.files);
$("pickA").onclick = () => answer(0);
$("pickB").onclick = () => answer(1);
let theme = "dark";
function applyTheme(next) {
theme = next;
if (theme === "light") {
document.documentElement.setAttribute("data-theme", "light");
} else {
document.documentElement.removeAttribute("data-theme");
}
$("themeBtn").textContent = theme === "light" ? "Dark mode" : "Light mode";
try { localStorage.setItem("lbx-ranker-theme", theme); } catch (error) { /* not important enough to matter */ }
}
try {
applyTheme(localStorage.getItem("lbx-ranker-theme") === "light" ? "light" : "dark");
} catch (error) {
applyTheme("dark");
}
$("themeBtn").onclick = () => applyTheme(theme === "light" ? "dark" : "light");
renderSessions();
$("undoBtn").onclick = undo;
$("listBtn").onclick = () => {
const hidden = $("sidebar").classList.toggle("hidden");
$("listBtn").textContent = hidden ? "Show list" : "Hide list";
try { localStorage.setItem("lbx-ranker-sidebar", hidden ? "hidden" : "shown"); } catch (error) { /* not important enough to matter */ }
};
try {
if (localStorage.getItem("lbx-ranker-sidebar") === "hidden") { $("listBtn").click(); }
} catch (error) { /* not important enough to matter */ }
$("quitBtn").onclick = () => { if (current !== null && current.ranked.length > 1) { renderResult(); } else { show("screenStart"); } };
$("backBtn").onclick = () => { renderSessions(); show("screenLoad"); };
$("newBtn").onclick = () => { renderSessions(); show("screenLoad"); };
$("againBtn").onclick = () => begin(state.key, shuffle(state.items), [], null);
$("copyBtn").onclick = () => copyText(rankingText());
$("csvBtn").onclick = () => download("ranked.csv", rankingCsv());
$("presetEven").onclick = () => setCounts(evenCounts(current.ranked.length));
$("presetOld").onclick = () => setCounts(oldRatingCounts(current.ranked));
$("presetTop").onclick = () => setCounts(topHeavyCounts(current.ranked.length));
const drop = $("drop");
for (const type of ["dragenter", "dragover"]) {
drop.addEventListener(type, event => { event.preventDefault(); drop.classList.add("over"); });
}
for (const type of ["dragleave", "drop"]) {
drop.addEventListener(type, event => { event.preventDefault(); drop.classList.remove("over"); });
}
drop.addEventListener("drop", event => { if (event.dataTransfer) { handleFiles(event.dataTransfer.files); } });
window.addEventListener("dragover", event => event.preventDefault());
window.addEventListener("drop", event => event.preventDefault());
document.addEventListener("keydown", event => {
if ($("screenCompare").classList.contains("active") === false) { return; }
if (event.key === "1" || event.key === "ArrowLeft") { event.preventDefault(); answer(0); }
else if (event.key === "2" || event.key === "ArrowRight") { event.preventDefault(); answer(1); }
else if (event.key === "u" || event.key === "U" || event.key === "Backspace") { event.preventDefault(); undo(); }
});
</script>
</body>
</html>