add: pairwise elo voting workflow

This commit is contained in:
2026-03-22 20:25:41 -07:00
parent 26adbe617f
commit b3a8368bab
6 changed files with 1212 additions and 6 deletions

View File

@@ -61,6 +61,10 @@ npm run build:thumbs:force
`data/elo.json` stores the seed rating, Elo `kFactor`, and a win-loss record for each meal.
The page build keeps this file aligned with `data/meals.json`, so new meals automatically appear in `rankings.html` with the default seed rating.
The interactive voting flow on `rankings.html` uses browser `localStorage` for persistence.
That means Elo votes persist across reloads on the same browser and device, but they do not sync automatically across devices.
Use the reset button on the rankings page if you want to clear the local vote history and go back to the seeded board.
## Image Conventions
- Full-size images and thumbnails share the same numeric ID
@@ -94,5 +98,5 @@ The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the c
## Planned Features
1. A pairwise voting page that shows two food images at a time and updates Elo rankings based on the selected winner.
1. Optional shared sync or export/import for rankings if browser-local persistence becomes too limiting.
2. General cleanup and history cleanup once the bigger structural changes are in place.

View File

@@ -42,6 +42,186 @@ body.rankings-page #giftwo {
transform: none;
}
#voting {
padding: 0 2.25rem 1.75rem 2.25rem;
}
.voting-panel {
background:
linear-gradient(140deg, rgba(255, 255, 255, 0.97), rgba(240, 248, 247, 0.94));
border: 1px solid rgba(16, 16, 16, 0.08);
border-radius: 1.5rem;
box-shadow: 0 1.75rem 3.5rem rgba(16, 16, 16, 0.08);
padding: 1.5rem;
}
.voting-panel__intro h2,
.voting-panel__intro p {
margin: 0;
}
.voting-panel__eyebrow {
color: #00a892;
font-size: 0.8rem;
letter-spacing: 0.12em;
margin-bottom: 0.75rem;
text-transform: uppercase;
}
.voting-panel__intro h2 {
color: #333;
font-size: 2rem;
}
.vote-status {
color: #666;
margin-top: 0.5rem;
}
.voting-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
.vote-message {
color: #333;
font-size: 0.95rem;
margin: 1rem 0 0 0;
}
.duel-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 1.25rem;
}
.duel-placeholder {
color: #666;
grid-column: 1 / -1;
margin: 0;
}
.duel-card {
background: #ffffff;
border: 1px solid rgba(16, 16, 16, 0.08);
border-radius: 1.25rem;
box-shadow: 0 1rem 2.5rem rgba(16, 16, 16, 0.08);
display: flex;
flex-direction: column;
overflow: hidden;
}
.duel-card__button {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: block;
padding: 0;
text-align: left;
white-space: normal;
width: 100%;
}
.duel-card__button:hover,
.duel-card__button:focus-visible {
color: inherit;
}
.duel-card__button:focus-visible {
outline: 3px solid rgba(0, 211, 183, 0.55);
outline-offset: -3px;
}
.duel-card__button:hover .duel-card__media img,
.duel-card__button:focus-visible .duel-card__media img {
transform: scale(1.02);
}
.duel-card__media {
background: #eff3f8;
overflow: hidden;
}
.duel-card__media img {
aspect-ratio: 3 / 4;
display: block;
object-fit: cover;
transition: transform 0.2s ease;
width: 100%;
}
.duel-card__body {
display: flex;
flex-direction: column;
gap: 0.7rem;
padding: 1.25rem;
}
.duel-card__label,
.duel-card__meta,
.duel-card__description,
.duel-card__body h3 {
margin: 0;
}
.duel-card__label,
.duel-card__meta {
text-transform: uppercase;
}
.duel-card__label {
color: #00a892;
font-size: 0.78rem;
letter-spacing: 0.12em;
}
.duel-card__title {
color: #333;
font-size: 1.5rem;
}
.duel-card__meta {
color: #666;
font-size: 0.78rem;
letter-spacing: 0.08em;
}
.duel-card__description {
color: #777;
}
.duel-card__cta {
color: #101010;
display: inline-flex;
font-size: 0.92rem;
font-weight: 700;
margin-top: 0.35rem;
}
.duel-card__open {
align-self: flex-start;
border-bottom: 0;
color: #666;
font-size: 0.82rem;
letter-spacing: 0.08em;
margin: 0 1.25rem 1.25rem 1.25rem;
text-transform: uppercase;
}
.duel-card__open:hover {
color: #00a892;
}
.vote-hint {
color: #666;
font-size: 0.85rem;
margin: 1rem 0 0 0;
}
#rankings-summary {
padding: 0 2.25rem 1.25rem 2.25rem;
}
@@ -138,6 +318,7 @@ body.rankings-page #giftwo {
}
#rankings-summary,
#voting,
#rankings,
body.rankings-page #header,
body.rankings-page #footer {
@@ -145,6 +326,14 @@ body.rankings-page #giftwo {
padding-right: 1.25rem;
}
.voting-panel {
padding: 1.25rem;
}
.duel-grid {
grid-template-columns: 1fr;
}
.ranking-card {
grid-template-columns: 1fr;
}

543
assets/js/rankings.js Normal file
View File

@@ -0,0 +1,543 @@
(function () {
const STORAGE_KEY = "gallery.rankings.v1";
const STORAGE_TEST_KEY = `${STORAGE_KEY}.probe`;
const STATE_VERSION = 1;
const CLOSE_MATCH_COUNT = 6;
function $(id) {
return document.getElementById(id);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatRating(rating) {
return new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
}).format(rating);
}
function pluralize(count, singular, plural) {
return count === 1 ? singular : plural;
}
function createPairKey(leftId, rightId) {
return [leftId, rightId].sort().join(":");
}
function createDefaultEntry(id, defaultRating) {
return {
id,
rating: defaultRating,
wins: 0,
losses: 0,
};
}
function cloneEntry(entry) {
return {
id: entry.id,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
};
}
function roundRating(rating) {
return Math.round(rating * 1000) / 1000;
}
function pickRandom(items) {
return items[Math.floor(Math.random() * items.length)];
}
function parseSeedData() {
const seedElement = $("rankings-seed-data");
if (!seedElement) {
throw new Error("Missing rankings seed data");
}
const seedData = JSON.parse(seedElement.textContent);
if (!seedData || !Array.isArray(seedData.meals) || !seedData.elo) {
throw new Error("Invalid rankings seed data");
}
return seedData;
}
function createPersistence() {
let available = false;
try {
localStorage.setItem(STORAGE_TEST_KEY, "1");
localStorage.removeItem(STORAGE_TEST_KEY);
available = true;
} catch (error) {
available = false;
}
return {
get available() {
return available;
},
load() {
if (!available) {
return null;
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
},
save(state) {
if (!available) {
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
available = false;
}
},
clear() {
if (!available) {
return;
}
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
available = false;
}
},
};
}
function isValidStoredEntry(entry) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) {
return false;
}
if (typeof entry.rating !== "number" || !Number.isFinite(entry.rating) || entry.rating <= 0) {
return false;
}
return ["wins", "losses"].every(
(field) => Number.isInteger(entry[field]) && entry[field] >= 0
);
}
function createSeedState(seedData) {
return {
version: STATE_VERSION,
voteCount: 0,
lastPairKey: null,
updatedAt: null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries: seedData.meals.map((meal) => {
const seedEntry =
seedData.elo.entries.find((entry) => entry.id === meal.id) ||
createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
}),
},
};
}
function syncStoredState(seedData, storedState) {
if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
return createSeedState(seedData);
}
const storedEntries = Array.isArray(storedState.elo?.entries)
? storedState.elo.entries.filter(isValidStoredEntry)
: [];
const storedEntryById = new Map(storedEntries.map((entry) => [entry.id, entry]));
const seedEntryById = new Map(seedData.elo.entries.map((entry) => [entry.id, entry]));
const entries = seedData.meals.map((meal) => {
const storedEntry = storedEntryById.get(meal.id);
if (storedEntry) {
return cloneEntry(storedEntry);
}
const seedEntry =
seedEntryById.get(meal.id) || createDefaultEntry(meal.id, seedData.elo.defaultRating);
return cloneEntry(seedEntry);
});
const derivedVoteCount = entries.reduce((sum, entry) => sum + entry.wins, 0);
return {
version: STATE_VERSION,
voteCount:
Number.isInteger(storedState.voteCount) && storedState.voteCount >= derivedVoteCount
? storedState.voteCount
: derivedVoteCount,
lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
elo: {
defaultRating: seedData.elo.defaultRating,
kFactor: seedData.elo.kFactor,
entries,
},
};
}
function compareRankedMeals(left, right) {
const leftMatches = left.wins + left.losses;
const rightMatches = right.wins + right.losses;
if (right.rating !== left.rating) {
return right.rating - left.rating;
}
if (rightMatches !== leftMatches) {
return rightMatches - leftMatches;
}
return Number.parseInt(left.id, 10) - Number.parseInt(right.id, 10);
}
function getRankedMeals(meals, eloData) {
const entryById = new Map(eloData.entries.map((entry) => [entry.id, entry]));
return meals
.map((meal) => {
const entry =
entryById.get(meal.id) || createDefaultEntry(meal.id, eloData.defaultRating);
return {
...meal,
rating: entry.rating,
wins: entry.wins,
losses: entry.losses,
matches: entry.wins + entry.losses,
};
})
.sort(compareRankedMeals);
}
function getRankingMeta(rankedMeal) {
const ratingText = `Elo ${formatRating(rankedMeal.rating)}`;
if (rankedMeal.matches === 0) {
return `${ratingText} | no votes yet`;
}
return `${ratingText} | ${rankedMeal.wins}-${rankedMeal.losses} record across ${
rankedMeal.matches
} ${pluralize(rankedMeal.matches, "match", "matches")}`;
}
function expectedScore(rating, opponentRating) {
return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
}
function applyVote(seedData, state, winnerId, loserId, pairKey) {
const entryById = new Map(
state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)])
);
const winner = entryById.get(winnerId);
const loser = entryById.get(loserId);
if (!winner || !loser) {
return state;
}
const winnerExpected = expectedScore(winner.rating, loser.rating);
const loserExpected = expectedScore(loser.rating, winner.rating);
winner.rating = roundRating(winner.rating + state.elo.kFactor * (1 - winnerExpected));
loser.rating = roundRating(loser.rating + state.elo.kFactor * (0 - loserExpected));
winner.wins += 1;
loser.losses += 1;
return {
...state,
voteCount: state.voteCount + 1,
lastPairKey: pairKey,
updatedAt: new Date().toISOString(),
elo: {
...state.elo,
entries: seedData.meals.map((meal) => entryById.get(meal.id)),
},
};
}
function choosePair(rankedMeals, avoidedPairKeys) {
if (rankedMeals.length < 2) {
return null;
}
const avoided = new Set(avoidedPairKeys.filter(Boolean));
const rankedOrder = new Map(rankedMeals.map((meal, index) => [meal.id, index]));
for (let attempt = 0; attempt < 20; attempt += 1) {
const baseMeal = rankedMeals[Math.floor(Math.random() * rankedMeals.length)];
const candidates = rankedMeals
.filter((meal) => meal.id !== baseMeal.id)
.sort((left, right) => {
const ratingGap = Math.abs(left.rating - baseMeal.rating) - Math.abs(right.rating - baseMeal.rating);
if (ratingGap !== 0) {
return ratingGap;
}
return (
Math.abs(rankedOrder.get(left.id) - rankedOrder.get(baseMeal.id)) -
Math.abs(rankedOrder.get(right.id) - rankedOrder.get(baseMeal.id))
);
});
const closeCandidates = candidates.slice(0, Math.min(CLOSE_MATCH_COUNT, candidates.length));
const filteredCandidates = closeCandidates.filter(
(meal) => !avoided.has(createPairKey(baseMeal.id, meal.id))
);
const candidatePool =
filteredCandidates.length > 0
? filteredCandidates
: candidates.filter((meal) => !avoided.has(createPairKey(baseMeal.id, meal.id)));
const fallbackPool = candidatePool.length > 0 ? candidatePool : candidates;
const opponent = pickRandom(
fallbackPool.slice(0, Math.min(CLOSE_MATCH_COUNT, fallbackPool.length))
);
if (opponent) {
return Math.random() < 0.5
? { left: baseMeal, right: opponent }
: { left: opponent, right: baseMeal };
}
}
return {
left: rankedMeals[0],
right: rankedMeals[1],
};
}
function renderDuelCard(meal, sideLabel) {
return `<article class="duel-card">
<button class="duel-card__button" type="button" data-meal-id="${meal.id}">
<div class="duel-card__media">
<img src="images/fulls/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} photo`)}" />
</div>
<div class="duel-card__body">
<p class="duel-card__label">${escapeHtml(sideLabel)}</p>
<h3 class="duel-card__title">${escapeHtml(meal.title)}</h3>
<p class="duel-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
<p class="duel-card__description">${escapeHtml(meal.description)}</p>
<span class="duel-card__cta">Choose this meal</span>
</div>
</button>
<a class="duel-card__open" href="images/fulls/${meal.id}.jpg" target="_blank" rel="noreferrer">open full image</a>
</article>`;
}
function renderRankingCard(meal, placement) {
return `<article class="ranking-card">
<p class="ranking-card__placement">#${placement}</p>
<a class="ranking-card__thumbnail" href="images/fulls/${meal.id}.jpg">
<img src="images/thumbs/${meal.id}.jpg" alt="${escapeHtml(`${meal.title} thumbnail`)}" loading="lazy" />
</a>
<div class="ranking-card__body">
<h2>${escapeHtml(meal.title)}</h2>
<p class="ranking-card__meta">${escapeHtml(getRankingMeta(meal))}</p>
<p>${escapeHtml(meal.description)}</p>
</div>
</article>`;
}
function getSummaryText(seedData, state, persistence) {
const voteText = `${state.voteCount} ${pluralize(state.voteCount, "vote", "votes")}`;
if (persistence.available) {
return `${seedData.meals.length} meals ranked. ${voteText} saved in this browser.`;
}
return `${seedData.meals.length} meals ranked. ${voteText} active for this session only.`;
}
function getStatusText(persistence) {
if (persistence.available) {
return "Votes are saved in this browser on this device.";
}
return "Browser storage is unavailable, so votes reset when you reload.";
}
function init() {
let seedData;
try {
seedData = parseSeedData();
} catch (error) {
console.error(error);
return;
}
const elements = {
duelCards: $("duel-cards"),
rankings: $("rankings"),
rankingSummary: document.querySelector("#rankings-summary .ranking-summary"),
voteStatus: $("vote-status"),
voteMessage: $("vote-message"),
skipPair: $("skip-pair"),
resetRankings: $("reset-rankings"),
};
if (
!elements.duelCards ||
!elements.rankings ||
!elements.rankingSummary ||
!elements.voteStatus ||
!elements.voteMessage ||
!elements.skipPair ||
!elements.resetRankings
) {
return;
}
const persistence = createPersistence();
let state = syncStoredState(seedData, persistence.load());
let currentPair = null;
let currentPairKey = null;
let lastMessage = "Choose the better meal to start ranking.";
persistence.save(state);
function queueNextPair(rankedMeals) {
currentPair = choosePair(rankedMeals, [currentPairKey, state.lastPairKey]);
currentPairKey = currentPair
? createPairKey(currentPair.left.id, currentPair.right.id)
: null;
}
function render(message) {
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
if (message) {
lastMessage = message;
}
if (
!currentPair ||
!rankedMeals.some((meal) => meal.id === currentPair.left.id) ||
!rankedMeals.some((meal) => meal.id === currentPair.right.id)
) {
queueNextPair(rankedMeals);
}
elements.voteStatus.textContent = getStatusText(persistence);
elements.voteMessage.textContent = lastMessage;
elements.rankingSummary.textContent = getSummaryText(seedData, state, persistence);
elements.rankings.innerHTML = rankedMeals
.map((meal, index) => renderRankingCard(meal, index + 1))
.join("");
if (!currentPair) {
elements.duelCards.innerHTML =
'<p class="duel-placeholder">Add at least two meals before starting head-to-head voting.</p>';
elements.skipPair.disabled = true;
return;
}
elements.skipPair.disabled = false;
elements.duelCards.innerHTML = [
renderDuelCard(currentPair.left, "Left Pick"),
renderDuelCard(currentPair.right, "Right Pick"),
].join("");
}
function handleVote(winnerId) {
if (!currentPair) {
return;
}
const loserId = currentPair.left.id === winnerId ? currentPair.right.id : currentPair.left.id;
const winnerTitle =
currentPair.left.id === winnerId ? currentPair.left.title : currentPair.right.title;
const loserTitle =
currentPair.left.id === winnerId ? currentPair.right.title : currentPair.left.title;
state = applyVote(seedData, state, winnerId, loserId, currentPairKey);
persistence.save(state);
currentPair = null;
render(`Picked ${winnerTitle} over ${loserTitle}.`);
}
elements.duelCards.addEventListener("click", (event) => {
const button = event.target.closest("[data-meal-id]");
if (!button) {
return;
}
handleVote(button.getAttribute("data-meal-id"));
});
elements.skipPair.addEventListener("click", () => {
const rankedMeals = getRankedMeals(seedData.meals, state.elo);
queueNextPair(rankedMeals);
render("Skipped that pair.");
});
elements.resetRankings.addEventListener("click", () => {
if (!window.confirm("Reset the local Elo votes saved in this browser?")) {
return;
}
state = createSeedState(seedData);
persistence.clear();
persistence.save(state);
currentPair = null;
currentPairKey = null;
render("Local votes cleared. Back to the seeded board.");
});
document.addEventListener("keydown", (event) => {
const target = event.target;
if (
target &&
["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(target.tagName)
) {
return;
}
if (!currentPair) {
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
handleVote(currentPair.left.id);
} else if (event.key === "ArrowRight") {
event.preventDefault();
handleVote(currentPair.right.id);
}
});
render();
}
init();
})();

View File

@@ -28,7 +28,7 @@
<header id="header">
<img src="images/meow.gif" alt="meow" id="gifone">
<h1>food power rankings</h1>
<p>static Elo seeds for every meal before the head-to-head voting page exists.</p>
<p>pick the better meal, one pair at a time, and the board updates live in this browser.</p>
<p class="page-links">
<a href="index.html">gallery</a>
<span class="page-links__separator">/</span>
@@ -36,9 +36,29 @@
</p>
</header>
<!-- Voting -->
<section id="voting">
<div class="voting-panel">
<div class="voting-panel__intro">
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
<h2>Pick the winner.</h2>
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to save votes in this browser.</p>
</div>
<div class="voting-panel__actions">
<button class="button small" id="skip-pair" type="button">skip pair</button>
<button class="button small" id="reset-rankings" type="button">reset local votes</button>
</div>
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to start head-to-head voting.</p>
<div class="duel-grid" id="duel-cards">
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
</div>
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster.</p>
</div>
</section>
<!-- Rankings Summary -->
<section id="rankings-summary">
<p class="ranking-summary">33 meals seeded at Elo 1,000 until head-to-head voting starts.</p>
<p class="ranking-summary">33 meals seeded at Elo 1,000. Enable JavaScript to vote and reorder them.</p>
</section>
<!-- Rankings -->
@@ -351,5 +371,402 @@
</footer>
</div>
<script id="rankings-seed-data" type="application/json">
{
"meals": [
{
"id": "01",
"thumbnail": {
"focus": {
"x": 0.35,
"y": 0.5
}
},
"position": "left center",
"title": "sf on $10",
"description": "this was so not real i can't believe technically u paid for our first meal back. calmluh 3 years after. first hang !!!! pork buns were yummy 7/10"
},
{
"id": "02",
"title": "honey butter chicken",
"description": "the first thing you cooked for me ! so yum 10/10"
},
{
"id": "03",
"thumbnail": {
"focus": {
"x": 0.5,
"y": 0.35
}
},
"position": "top center",
"title": "aloha fresh",
"description": "we fucking love this place 10/10 i love poke i should have never quit pokehouse"
},
{
"id": "04",
"title": "mad yolks",
"description": "for our santa cruz hang! u in my city now. so so good but lwk so so tax 9/10"
},
{
"id": "05",
"thumbnail": {
"focus": {
"x": 0.5,
"y": 0.35
}
},
"position": "top center",
"title": "sizzling lunch",
"description": "better than pepper lunch. server was being a little bitchy but i would be too if i was the only one working the front. 8/10"
},
{
"id": "06",
"title": "braised pork belly",
"description": "omfg this is my favorite thing uve made 100/10"
},
{
"id": "07",
"title": "sushi w/ claire!",
"description": "and then we played bananagrams. sushi 8/10 thanks for paying mommy"
},
{
"id": "08",
"title": "myungrang hot dog",
"description": "main street tino nothing special 7/10"
},
{
"id": "09",
"title": "liangs village",
"description": "my peoples food. 9/10"
},
{
"id": "10",
"title": "cabonara",
"description": "insane safeway hang 9/10"
},
{
"id": "11",
"title": "heytea",
"description": "this fuckass blue drink"
},
{
"id": "12",
"title": "sparcos",
"description": "one of many.. 100/10"
},
{
"id": "13",
"title": "noahs bagels",
"description": "this is the plaza where i used to go to all the time before school 9/10"
},
{
"id": "14",
"title": "homeroom",
"description": "mac and cheese was gas. 10/10. you know its my fav comfort food. free the girl crying in the corner tho"
},
{
"id": "15",
"title": "sparcos x2",
"description": "spartan tacos"
},
{
"id": "16",
"title": "sparcos x3",
"description": "okay damn no way we got this b2b"
},
{
"id": "17",
"title": "aloha fresh",
"description": "this is lowkey the spot poke always hits so fucking good"
},
{
"id": "18",
"title": "house of bagels",
"description": "hobags ughgmmfmfm im such a fucking ho for hobags 100/10"
},
{
"id": "19",
"title": "toro sushi",
"description": "carmel by the sea! we love sushi but tax 8/10"
},
{
"id": "20",
"title": "sul and beans",
"description": "sweet treat -> claire dropping the most insane piece of information ever -> hti the yap"
},
{
"id": "21",
"title": "highland hand pulled noodles",
"description": "so good and soooo filling 10/10. also my peoples food."
},
{
"id": "22",
"title": "bloom",
"description": "even when its rich white people breakfast im getting salmon nox 10/10"
},
{
"id": "23",
"title": "happy donuts",
"description": "1k cal meal 0 protein 10/10"
},
{
"id": "24",
"title": "marugame",
"description": "i dont want to talk about this. 9/10"
},
{
"id": "25",
"title": "siam station!",
"description": "i can't believe u didnt eat ur leftovers. 10/10"
},
{
"id": "26",
"title": "muukata 6395",
"description": "for my birthday!! i love eating meat and i love you so perfect combination 1000/10"
},
{
"id": "27",
"title": "bambu",
"description": "why was the store so nice. i wonder about the 4 sisters"
},
{
"id": "28",
"title": "porridge at julias",
"description": "her boyfriend is so not real hes so stupid and funny. porridge was gas too i love eating free at julias 10/10"
},
{
"id": "29",
"title": "sparcos x4",
"description": "spartan tacos. i was moody lol."
},
{
"id": "30",
"title": "wonton udon",
"description": "i helped wrap the wontons w/ u !!! super fun and super yummy 10/10"
},
{
"id": "31",
"title": "steak dinna for vday",
"description": "marry me? yes. 100/10 best valentines day ever"
},
{
"id": "32",
"title": "poke house",
"description": "poke house 3 years later 9/10 but +1 point bc its basically free"
},
{
"id": "33",
"title": "hey tea",
"description": "mochi yinje black milk tea ts was actually so buss 10/10 only boba i've ever wanted to get again myself"
}
],
"elo": {
"defaultRating": 1000,
"kFactor": 32,
"entries": [
{
"id": "01",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "02",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "03",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "04",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "05",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "06",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "07",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "08",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "09",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "10",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "11",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "12",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "13",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "14",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "15",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "16",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "17",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "18",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "19",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "20",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "21",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "22",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "23",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "24",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "25",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "26",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "27",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "28",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "29",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "30",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "31",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "32",
"rating": 1000,
"wins": 0,
"losses": 0
},
{
"id": "33",
"rating": 1000,
"wins": 0,
"losses": 0
}
]
}
}
</script>
<script src="assets/js/rankings.js"></script>
</body>
</html>

View File

@@ -24,6 +24,20 @@ function escapeHtml(value) {
.replace(/"/g, "&quot;");
}
function serializeJsonForHtml(value) {
return JSON.stringify(value, null, 2)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
function indentBlock(text, indent) {
return text
.split("\n")
.map((line) => `${indent}${line}`)
.join("\n");
}
function renderGalleryItem(meal, eol) {
const attrs = [`class="thumbnail"`, `href="images/fulls/${meal.id}.jpg"`];
@@ -53,7 +67,7 @@ function renderRankingSummary(meals, eloData) {
return `\t\t\t\t<p class="ranking-summary">${meals.length} ${mealLabel} seeded at Elo ${formatRating(
eloData.defaultRating
)} until head-to-head voting starts.</p>`;
)}. Enable JavaScript to vote and reorder them.</p>`;
}
function renderRankingMeta(rankedMeal) {
@@ -91,6 +105,16 @@ function renderRankings(rankedMeals, eol) {
.join(eol);
}
function renderRankingsSeedData(meals, eloData) {
return indentBlock(
serializeJsonForHtml({
meals,
elo: eloData,
}),
"\t\t\t"
);
}
function replaceBlock(template, token, replacement) {
const pattern = new RegExp(`^[\\t ]*\\{\\{${token}\\}\\}$`, "m");
@@ -120,8 +144,13 @@ function buildRankings(
"ranking_summary",
renderRankingSummary(meals, eloData)
);
const withSeedData = replaceBlock(
withSummary,
"rankings_seed_data",
renderRankingsSeedData(meals, eloData)
);
return replaceBlock(withSummary, "ranking_items", renderRankings(rankedMeals, eol));
return replaceBlock(withSeedData, "ranking_items", renderRankings(rankedMeals, eol));
}
function writeFile(filePath, contents) {

View File

@@ -28,7 +28,7 @@
<header id="header">
<img src="images/meow.gif" alt="meow" id="gifone">
<h1>food power rankings</h1>
<p>static Elo seeds for every meal before the head-to-head voting page exists.</p>
<p>pick the better meal, one pair at a time, and the board updates live in this browser.</p>
<p class="page-links">
<a href="index.html">gallery</a>
<span class="page-links__separator">/</span>
@@ -36,6 +36,26 @@
</p>
</header>
<!-- Voting -->
<section id="voting">
<div class="voting-panel">
<div class="voting-panel__intro">
<p class="voting-panel__eyebrow">Head-To-Head Voting</p>
<h2>Pick the winner.</h2>
<p class="vote-status" id="vote-status" aria-live="polite">Enable JavaScript to save votes in this browser.</p>
</div>
<div class="voting-panel__actions">
<button class="button small" id="skip-pair" type="button">skip pair</button>
<button class="button small" id="reset-rankings" type="button">reset local votes</button>
</div>
<p class="vote-message" id="vote-message" aria-live="polite">Enable JavaScript to start head-to-head voting.</p>
<div class="duel-grid" id="duel-cards">
<p class="duel-placeholder">Enable JavaScript to compare meals here.</p>
</div>
<p class="vote-hint">Tip: use the left and right arrow keys to vote faster.</p>
</div>
</section>
<!-- Rankings Summary -->
<section id="rankings-summary">
{{ranking_summary}}
@@ -55,5 +75,9 @@
</footer>
</div>
<script id="rankings-seed-data" type="application/json">
{{rankings_seed_data}}
</script>
<script src="assets/js/rankings.js"></script>
</body>
</html>