Files
TheEvilList/js/pages/Leaderboard.js
2026-04-17 12:29:41 -04:00

309 lines
16 KiB
JavaScript
Raw 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.
import { fetchLeaderboard, fetchPackLevels } from "../content.js";
import { localize, getFontColour, getLeaderboardLevelThumbnail } from "../util.js";
import { packScore } from "../score.js";
import Spinner from "../components/Spinner.js";
import { store } from '../main.js';
export default {
components: {
Spinner,
},
data: () => ({
leaderboard: [],
profiles: [],
loading: true,
selected: 0,
err: [],
}),
template: `
<main v-if="loading" class="surface">
<Spinner></Spinner>
</main>
<main v-else class="page-leaderboard-container">
<div class="page-leaderboard">
<div class="error-container">
<p class="error" v-if="err.length > 0">
Leaderboard may be incorrect, as the following levels could not be loaded: {{ err.join(', ') }}
</p>
</div>
<div class="board-container surface">
<table class="board">
<tr v-for="(ientry, i) in leaderboard">
<td class="rank">
<p class="type-label-lg">#{{ i + 1 }}</p>
</td>
<td class="rank-image">
<img v-if="i + 1 == 1" class="trophy" src="assets/Top1Trophy.png" />
<img v-if="i + 1 == 2" class="trophy" src="assets/Top2Trophy.png" />
<img v-if="i + 1 == 3" class="trophy" src="assets/Top3Trophy.png" />
</td>
<td class="total">
<p class="type-label-lg">{{ localize(ientry.total) }}</p>
</td>
<td class="userIcon">
<img class="ico" :src="\`/assets/icons/\${ientry.user}.png\`" :alt="ientry.user">
</td>
<td class="user" :class="{ 'active': selected == i }">
<button @click="selected = i">
<span class="type-label-lg">{{ ientry.user }}</span>
</button>
</td>
</tr>
</table>
</div>
<div class="player-container surface">
<div class="player">
<h1>#{{ selected + 1 }} {{ entry.user }}</h1>
<h3>{{ entry.total }} points</h3>
<p class="extra_points_info" v-if="entry.packPoints > 0">+{{ entry.total - entry.packPoints }} from levels</p>
<p class="extra_points_info" v-if="entry.packPoints > 0">+{{ entry.packPoints }} from packs</p>
<h3 v-if="entry.verified.length > 0 || entry.completed.length > 0">
Hardest: {{ [...entry.verified, ...entry.completed].reduce((min, current) => current.rank < min.rank ? current : min).level }}
</h3>
<!-- ── Score breakdown ── -->
<div class="lb-breakdown" v-if="breakdown.total > 0">
<!-- Donut chart -->
<div class="lb-donut-wrap">
<svg class="lb-donut" viewBox="0 0 120 120">
<!-- background track -->
<circle
cx="60" cy="60" r="48"
fill="none"
stroke="rgba(255,255,255,0.06)"
stroke-width="14"
/>
<!-- verified segment -->
<circle
v-if="breakdown.verified > 0"
cx="60" cy="60" r="48"
fill="none"
stroke="#e8b84b"
stroke-width="14"
stroke-linecap="butt"
:stroke-dasharray="donutDash(breakdown.verified, breakdown.total)"
:stroke-dashoffset="donutOffset(0, breakdown.total)"
transform="rotate(-90 60 60)"
/>
<!-- completed segment -->
<circle
v-if="breakdown.completed > 0"
cx="60" cy="60" r="48"
fill="none"
stroke="#4b9de8"
stroke-width="14"
stroke-linecap="butt"
:stroke-dasharray="donutDash(breakdown.completed, breakdown.total)"
:stroke-dashoffset="donutOffset(breakdown.verified, breakdown.total)"
transform="rotate(-90 60 60)"
/>
<!-- progressed segment -->
<circle
v-if="breakdown.progressed > 0"
cx="60" cy="60" r="48"
fill="none"
stroke="#4be88a"
stroke-width="14"
stroke-linecap="butt"
:stroke-dasharray="donutDash(breakdown.progressed, breakdown.total)"
:stroke-dashoffset="donutOffset(breakdown.verified + breakdown.completed, breakdown.total)"
transform="rotate(-90 60 60)"
/>
<!-- pack segment -->
<circle
v-if="breakdown.packs > 0"
cx="60" cy="60" r="48"
fill="none"
stroke="#e84b9d"
stroke-width="14"
stroke-linecap="butt"
:stroke-dasharray="donutDash(breakdown.packs, breakdown.total)"
:stroke-dashoffset="donutOffset(breakdown.verified + breakdown.completed + breakdown.progressed, breakdown.total)"
transform="rotate(-90 60 60)"
/>
<!-- centre label -->
<text x="60" y="55" text-anchor="middle" class="lb-donut-total">{{ localize(breakdown.total) }}</text>
<text x="60" y="70" text-anchor="middle" class="lb-donut-label">pts</text>
</svg>
</div>
<!-- Bar breakdown -->
<div class="lb-bars">
<div class="lb-bar-row" v-if="breakdown.verified > 0">
<span class="lb-bar-dot" style="background:#e8b84b"></span>
<span class="lb-bar-cat">Verified</span>
<div class="lb-bar-track">
<div class="lb-bar-fill" style="background:#e8b84b" :style="{ width: pct(breakdown.verified, breakdown.total) + '%' }"></div>
</div>
<span class="lb-bar-val">{{ localize(breakdown.verified) }}</span>
</div>
<div class="lb-bar-row" v-if="breakdown.completed > 0">
<span class="lb-bar-dot" style="background:#4b9de8"></span>
<span class="lb-bar-cat">Completed</span>
<div class="lb-bar-track">
<div class="lb-bar-fill" style="background:#4b9de8" :style="{ width: pct(breakdown.completed, breakdown.total) + '%' }"></div>
</div>
<span class="lb-bar-val">{{ localize(breakdown.completed) }}</span>
</div>
<div class="lb-bar-row" v-if="breakdown.progressed > 0">
<span class="lb-bar-dot" style="background:#4be88a"></span>
<span class="lb-bar-cat">Progressed</span>
<div class="lb-bar-track">
<div class="lb-bar-fill" style="background:#4be88a" :style="{ width: pct(breakdown.progressed, breakdown.total) + '%' }"></div>
</div>
<span class="lb-bar-val">{{ localize(breakdown.progressed) }}</span>
</div>
<div class="lb-bar-row" v-if="breakdown.packs > 0">
<span class="lb-bar-dot" style="background:#e84b9d"></span>
<span class="lb-bar-cat">Packs</span>
<div class="lb-bar-track">
<div class="lb-bar-fill" style="background:#e84b9d" :style="{ width: pct(breakdown.packs, breakdown.total) + '%' }"></div>
</div>
<span class="lb-bar-val">{{ localize(breakdown.packs) }}</span>
</div>
</div>
</div>
<!-- ── End score breakdown ── -->
<div class="packs" v-if="entry.packs.length > 0">
<div v-for="pack in entry.packs" class="tag" :style="{background:pack.colour, color:getFontColour(pack.colour)}">
{{pack.name}}
</div>
</div>
<h2 v-if="entry.verified.length > 0">Verified ({{ entry.verified.length}})</h2>
<table v-if="entry.verified.length > 0" class="table">
<tr v-for="score in entry.verified">
<td class="rank">
<p>#{{ score.rank }}</p>
</td>
<td class="level" :style="getLeaderboardLevelThumbnail(score.level)">
<a class="type-label-lg" target="_blank" :href="score.link">{{ score.level }}</a>
<p class="type-label-sm">+{{ localize(Math.round(score.score)) }}</p>
</td>
</tr>
</table>
<h2 v-if="entry.completed.length > 0">Completed ({{ entry.completed.length }})</h2>
<table v-if="entry.completed.length > 0" class="table">
<tr v-for="score in entry.completed">
<td class="rank">
<p>#{{ score.rank }}</p>
</td>
<td class="level" :style="getLeaderboardLevelThumbnail(score.level)">
<a class="type-label-lg" target="_blank" :href="score.link">{{ score.level }}</a>
<p class="type-label-sm">+{{ localize(Math.round(score.score)) }}</p>
</td>
</tr>
</table>
<h2 v-if="entry.progressed.length > 0">Progressed ({{entry.progressed.length}})</h2>
<table v-if="entry.progressed.length > 0" class="table">
<tr v-for="score in entry.progressed">
<td class="rank">
<p>#{{ score.rank }}</p>
</td>
<td class="level" :style="getLeaderboardLevelThumbnail(score.level)">
<a class="type-label-lg" target="_blank" :href="score.link">{{ score.level }}</a>
<p class="type-label-sm">+{{ localize(Math.round(score.score)) }}</p>
</td>
</tr>
</table>
</div>
</div>
</div>
</main>
`,
computed: {
entry() {
return this.leaderboard[this.selected];
},
// Sums each category's scores from the raw arrays
breakdown() {
const e = this.entry;
if (!e) return { verified: 0, completed: 0, progressed: 0, packs: 0, total: 0 };
const sum = arr => (arr || []).reduce((acc, s) => acc + Math.round(s.score || 0), 0);
const verified = Math.round(sum(e.verified));
const completed = Math.round(sum(e.completed));
const progressed = Math.round(sum(e.progressed));
const packs = e.packPoints || 0;
const total = verified + completed + progressed + packs;
return { verified, completed, progressed, packs, total };
},
},
methods: {
localize,
getFontColour,
getLeaderboardLevelThumbnail,
// Returns stroke-dasharray string for a donut segment
// circumference = 2 * π * r = 2 * π * 48 ≈ 301.59
donutDash(value, total) {
const circ = 2 * Math.PI * 48;
const filled = (value / total) * circ;
return `${filled} ${circ - filled}`;
},
// Returns stroke-dashoffset to rotate a segment into position
// offset starts at 0 (top of circle after the -90deg transform)
// and advances by each preceding segment's arc length
donutOffset(preceding, total) {
const circ = 2 * Math.PI * 48;
// dashoffset is negative of how far around the circle we've gone
return -((preceding / total) * circ);
},
// Percentage of total, clamped 0100
pct(value, total) {
if (!total) return 0;
return Math.min(100, Math.round((value / total) * 100));
},
},
async mounted() {
store.leaderboard = this;
await resetLeaderboard();
},
};
export async function resetLeaderboard() {
store.leaderboard.loading = true;
const [leaderboard, err] = await fetchLeaderboard();
// calculate pack points
for (const entry of leaderboard) {
let packPoints = 0;
if (entry.packs && entry.packs.length) {
for (const pack of entry.packs) {
const packLevels = await fetchPackLevels(pack.name);
const levels = packLevels
.map(l => ({
listRank: l[0]?.listRank,
percentToQualify: l[0]?.level?.percentToQualify ?? 0
}))
.filter(l => l.listRank);
packPoints += packScore(levels);
}
}
entry.total += Math.round(packPoints);
entry.packPoints = Math.round(packPoints);
}
// pack points resort
leaderboard.sort((a, b) => b.total - a.total);
for (const entry of leaderboard) {
entry.verified = entry.verified.map(s => ({ ...s, score: Math.round(s.score) }));
entry.completed = entry.completed.map(s => ({ ...s, score: Math.round(s.score) }));
entry.progressed = entry.progressed.map(s => ({ ...s, score: Math.round(s.score) }));
}
store.leaderboard.leaderboard = leaderboard;
store.leaderboard.err = err;
store.leaderboard.loading = false;
}