{% extends "layout.html" %}
{% import "icons.html" as icons %}
{% import "card.html" as card %}
{% block content %}
<div class="container mb-6">
<div class="w-full mt-4 relative" style="height: 600px">
<div class="size-full absolute" id="chart" hx-preserve="true"></div>
<div
id="empty-banner"
style="display: none;"
class="absolute z-10 size-full items-center justify-center"
>
<span class="text-2xl font-semibold">No historical data yet!</span>
</div>
</div>
<div class="flex gap-6 mt-6 flex-col lg:flex-row">
{% if divisions | length > 1 %}
<div class="lg:max-w-96">
{% call card.root() %}
{% call card.header() %}
{% call card.title() %}
Division
{% endcall %}
{% call card.description() %}
Select the division for the scoreboard
{% endcall %}
{% endcall %}
{% call card.content() %}
<div class="flex gap-2 flex-wrap">
{% for division_id, division in divisions | items %}
<a
hx-boost="true"
hx-select="#screen"
hx-target="#screen"
hx-swap="outerHTML"
href="/scoreboard/{{ division_id }}"
class="relative"
>
<div
class="flex items-center px-3 py-2 rounded-md border border-input text-sm hover:bg-secondary"
>
{{ division.name }}
</div>
{% if selected_division_id == division_id %}
<div
class="size-3 rounded-full cursor-pointer bg-primary absolute -top-1 -right-1"
></div>
{% endif %}
</a>
{% endfor %}
</div>
{% if user.is_admin %}
<div class="flex mt-4">
<button
class="px-4 py-2 gap-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex items-center justify-center rounded-md text-sm font-medium whitespace-nowrap ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
onclick="copyDivisionCTFtimeScoreboardFeed()"
title="Copy CTFtime scoreboard feed to clipboard"
>
<span>CTFtime Scoreboard Feed</span> {{ icons.copy() }}
</button>
<script>
function copyDivisionCTFtimeScoreboardFeed() {
navigator.clipboard.writeText(`${location}/ctftime`).then(
() => {
rhombus.toast.success(
"Copied CTFtime scoreboard feed to clipboard",
);
},
() => {
rhombus.toast.error(
"Failed to copy CTFtime scoreboard feed to clipboard",
);
},
);
}
</script>
</div>
{% endif %}
{% endcall %}
{% endcall %}
</div>
{% endif %}
<div class="grow">
{% call card.root() %}
{% call card.header() %}
{% call card.title() %}
Leaderboard
{% endcall %}
{% call card.description() %}
Current standing of all teams in the division
{% endcall %}
{% endcall %}
{% call card.content() %}
{% if leaderboard.entries | length > 0 %}
<div class="w-full flex">
<table class="table-fixed grow">
{% for entry in leaderboard.entries %}
<tr class="even:bg-secondary *:p-2">
<td>{{ entry.rank }}</td>
<td class="w-2/3">
<a
hx-boost="true"
hx-select="#screen"
hx-target="#screen"
hx-swap="outerHTML"
href="/team/{{ entry.team_id }}"
>{{ entry.team_name }}</a
>
</td>
<td>{{ entry.score }} points</td>
</tr>
{% endfor %}
</table>
</div>
{% if leaderboard.num_pages > 1 %}
<div class="flex justify-center">
<div>
{% for i in range(leaderboard.num_pages) %}
{% if i == 0 and page_num != 0 %}
<a
hx-boost="true"
hx-select="#screen"
hx-target="#screen"
hx-swap="outerHTML"
class="underline"
href="/scoreboard/{{ selected_division_id }}"
>1</a
>
{% elif i != page_num %}
<a
hx-boost="true"
hx-select="#screen"
hx-target="#screen"
hx-swap="outerHTML"
class="underline"
href="/scoreboard/{{ selected_division_id }}?page={{ i + 1 }}"
>{{ i + 1 }}</a
>
{% else %}
<span>{{ i + 1 }}</span>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% else %}
<p>No teams in this division have solved a challenge yet!</p>
{% endif %}
{% endcall %}
{% endcall %}
</div>
</div>
</div>
<div class="hidden" id="initial-scoreboard-json">
{{- scoreboard | tojson -}}
</div>
<script>
(function () {
const chartElement = document.getElementById("chart");
const setScoreboardTheme = () => {
const isDark = document.documentElement.classList.contains("dark");
if (window.scoreboardChart) {
window.scoreboardChart.dispose();
}
if (isDark) {
window.scoreboardChart = echarts.init(
chartElement,
"dark",
undefined,
);
} else {
window.scoreboardChart = echarts.init(chartElement, null, undefined);
}
if (window.mostRecentOptions)
window.scoreboardChart.setOption(window.mostRecentOptions);
window.scoreboardChart.resize();
};
setScoreboardTheme();
const observer = new MutationObserver(() => setScoreboardTheme());
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const refetchHandler = async () => {
const scoreboard_data = await (
await fetch(window.location.pathname + ".json", {
headers: { accept: "application/json" },
})
).json();
render(scoreboard_data);
};
const resizeHandler = () => {
window.scoreboardChart.resize();
};
window.addEventListener("resize", resizeHandler);
window.addEventListener("focus", refetchHandler);
const periodicRefetch = setInterval(refetchHandler, 1000 * 60 * 2);
window.deregister = () => {
window.removeEventListener("resize", resizeHandler);
window.removeEventListener("focus", refetchHandler);
clearInterval(periodicRefetch);
};
render(
JSON.parse(
document.getElementById("initial-scoreboard-json").innerHTML,
),
);
function render(scoreboard_data) {
if (Object.values(scoreboard_data).length === 0) {
document.getElementById("empty-banner").style.display = "flex";
}
const seriesCommon = {
type: "line",
emphasis: {
focus: "series",
},
lineStyle: {
width: 4,
// cap: "round",
},
// symbolSize: 10,
};
const optionsCommon = {
tooltip: {
trigger: "axis",
},
grid: {
top: "70",
left: "3%",
right: "3%",
bottom: "60",
containLabel: true,
},
toolbox: {
feature: {
saveAsImage: {},
dataZoom: {},
myFullscreen: {
show: true,
title: "Fullscreen",
icon: "path://M432.45,595.444c0,2.177-4.661,6.82-11.305,6.82c-6.475,0-11.306-4.567-11.306-6.82s4.852-6.812,11.306-6.812C427.841,588.632,432.452,593.191,432.45,595.444L432.45,595.444z M421.155,589.876c-3.009,0-5.448,2.495-5.448,5.572s2.439,5.572,5.448,5.572c3.01,0,5.449-2.495,5.449-5.572C426.604,592.371,424.165,589.876,421.155,589.876L421.155,589.876z M421.146,591.891c-1.916,0-3.47,1.589-3.47,3.549c0,1.959,1.554,3.548,3.47,3.548s3.469-1.589,3.469-3.548C424.614,593.479,423.062,591.891,421.146,591.891L421.146,591.891zM421.146,591.891",
onclick() {
document.exitFullscreen().catch(() => {});
chartElement.requestFullscreen();
},
},
},
},
xAxis: {
type: "time",
minInterval: 1000 * 60 * 60,
},
dataZoom: [
{
type: "slider",
height: 20,
top: 35,
},
],
animationDuration: 0,
backgroundColor: "transparent",
yAxis: {
type: "value",
min: 0,
minInterval: 100,
},
};
const timestampOffset = new Date().getTimezoneOffset() * 60;
const series = Object.values(scoreboard_data).map((team) => ({
name: team.team_name,
data: team.series.map((d) => ({
value: [d.timestamp * 1000 + timestampOffset, d.total_score],
})),
...seriesCommon,
}));
const options = {
...optionsCommon,
legend: {
data: series.map((s) => s.name),
type: "scroll",
orient: "horizontal",
align: "left",
bottom: 20,
},
series,
};
window.mostRecentOptions = options;
window.scoreboardChart.setOption(options, true);
}
})();
</script>
{% endblock %}