309 lines
16 KiB
JavaScript
309 lines
16 KiB
JavaScript
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 0–100
|
||
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;
|
||
} |