improve leaderboard chart
This commit is contained in:
4 files changed
+272
-23
No files matched your search
@@ -29,6 +29,7 @@ from stats import (
|
||||
session_breakdown_series,
|
||||
session_events,
|
||||
session_label,
|
||||
session_sort_key,
|
||||
unique_player_names,
|
||||
)
|
||||
from storage import append_event, ensure_data_file, load_events
|
||||
@@ -102,7 +103,7 @@ def home() -> str:
|
||||
def leaderboard() -> str:
|
||||
events = load_events(DATA_PATH)
|
||||
all_sessions = build_session_summaries(events)
|
||||
ordered_sessions = sorted(all_sessions, key=lambda s: s.session_date)
|
||||
ordered_sessions = sorted(all_sessions, key=session_sort_key)
|
||||
|
||||
session_ids = [session.session_id for session in ordered_sessions]
|
||||
selected_session_id = request.args.get("through_session", "").strip()
|
||||
@@ -111,6 +112,7 @@ def leaderboard() -> str:
|
||||
if session_ids:
|
||||
if selected_session_id in session_ids:
|
||||
cutoff_index = session_ids.index(selected_session_id)
|
||||
label = session_label(ordered_sessions[cutoff_index])
|
||||
else:
|
||||
cutoff_index = len(session_ids) - 1
|
||||
selected_session = ordered_sessions[cutoff_index]
|
||||
@@ -137,6 +139,11 @@ def leaderboard() -> str:
|
||||
available_sessions=all_sessions,
|
||||
selected_session_id=selected_session_id,
|
||||
selected_session_label=(label if selected_session_id else "Latest session"),
|
||||
selected_session_date=(
|
||||
ordered_sessions[cutoff_index].session_date
|
||||
if selected_session_id and session_ids
|
||||
else ""
|
||||
),
|
||||
session_label=session_label,
|
||||
)
|
||||
|
||||
|
||||
@@ -318,6 +318,10 @@ h2 {
|
||||
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 0.95fr);
|
||||
}
|
||||
|
||||
.leaderboard-layout {
|
||||
grid-template-columns: minmax(0, 2.3fr) minmax(280px, 0.7fr);
|
||||
}
|
||||
|
||||
.three-col {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
@@ -415,7 +419,7 @@ h2 {
|
||||
}
|
||||
|
||||
.chart-panel-large {
|
||||
min-height: 540px;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.chart-frame,
|
||||
@@ -425,7 +429,7 @@ h2 {
|
||||
}
|
||||
|
||||
.chart-panel-large .chart-frame {
|
||||
min-height: 450px;
|
||||
min-height: 510px;
|
||||
}
|
||||
|
||||
.chart-frame-interactive {
|
||||
|
||||
@@ -212,20 +212,21 @@ def cents_to_dollars(cents: int) -> str:
|
||||
return f"${value:,.2f}"
|
||||
|
||||
|
||||
def session_sort_key(session: SessionSummary) -> tuple[str, int, str]:
|
||||
def session_sort_key(session: SessionSummary) -> tuple[str, int, str, str]:
|
||||
session_id = session.session_id.strip()
|
||||
session_date = session.session_date.strip()
|
||||
|
||||
if session_id == session.session_date:
|
||||
return (session.session_date, 1, session_id)
|
||||
if session_id == session_date:
|
||||
return (session_date, 1, session.opened_at, session_id)
|
||||
|
||||
suffix = session_id.replace(f"{session.session_date}-", "")
|
||||
suffix = session_id.replace(f"{session_date}-", "")
|
||||
if suffix.isdigit():
|
||||
return (session.session_date, int(suffix), session_id)
|
||||
return (session_date, int(suffix), session.opened_at, session_id)
|
||||
|
||||
if suffix.lower().startswith("s") and suffix[1:].isdigit():
|
||||
return (session.session_date, int(suffix[1:]), session_id)
|
||||
return (session_date, int(suffix[1:]), session.opened_at, session_id)
|
||||
|
||||
return (session.session_date, 9999, session_id)
|
||||
return (session_date, 9999, session.opened_at, session_id)
|
||||
|
||||
|
||||
def session_chart_label(session: SessionSummary) -> str:
|
||||
@@ -518,6 +519,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
|
||||
for player_name in all_players:
|
||||
running_total = 0
|
||||
seen_player = False
|
||||
sessions_played = 0
|
||||
data: list[float | None] = []
|
||||
|
||||
for session in ordered_sessions:
|
||||
@@ -535,6 +537,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
|
||||
continue
|
||||
|
||||
seen_player = True
|
||||
sessions_played += 1
|
||||
running_total += matching_entry.net_cents
|
||||
data.append(round(running_total / 100, 2))
|
||||
|
||||
@@ -542,6 +545,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
|
||||
{
|
||||
"label": player_name,
|
||||
"data": data,
|
||||
"sessions_played": sessions_played,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+247
-13
@@ -17,6 +17,7 @@
|
||||
<h2>Cumulative profit over time</h2>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="ghost-button compact-button" type="button" id="resetLeaderboardZoom" hidden>Reset zoom</button>
|
||||
<button class="secondary-button" type="button" id="expandLeaderboardChart">Expand chart</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +138,70 @@
|
||||
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||
|
||||
function buildLeaderboardChartConfig(chartData) {
|
||||
function mainLeaderboardChartData(chartData) {
|
||||
const repeatedPlayerDatasets = chartData.datasets.filter((dataset) => {
|
||||
return Number(dataset.sessions_played || 0) > 1;
|
||||
});
|
||||
const visibleDatasets = repeatedPlayerDatasets.length > 0
|
||||
? repeatedPlayerDatasets
|
||||
: chartData.datasets;
|
||||
|
||||
return {
|
||||
labels: [...chartData.labels],
|
||||
datasets: visibleDatasets.map((dataset) => ({
|
||||
...dataset,
|
||||
data: [...dataset.data],
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 5,
|
||||
borderWidth: 2.5,
|
||||
tension: 0.28,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function moneyTickLabel(value) {
|
||||
const numericValue = Number(value);
|
||||
const absoluteValue = Math.abs(numericValue);
|
||||
const formattedValue = Number.isInteger(absoluteValue)
|
||||
? absoluteValue.toFixed(0)
|
||||
: absoluteValue.toFixed(2);
|
||||
|
||||
return `${numericValue < 0 ? '-' : ''}$${formattedValue}`;
|
||||
}
|
||||
|
||||
function chartYScaleOptions(chartData, compact) {
|
||||
const values = chartData.datasets
|
||||
.flatMap((dataset) => dataset.data)
|
||||
.filter((value) => value !== null && value !== undefined)
|
||||
.map(Number);
|
||||
|
||||
if (values.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const maxAbsValue = Math.max(Math.abs(minValue), Math.abs(maxValue));
|
||||
const stepSize = maxAbsValue <= 75 ? 25 : undefined;
|
||||
|
||||
return {
|
||||
suggestedMin: minValue < 0 ? minValue * 1.12 : 0,
|
||||
suggestedMax: maxValue > 0 ? maxValue * 1.12 : 0,
|
||||
ticks: {
|
||||
color: textMuted,
|
||||
maxTicksLimit: compact ? 8 : 10,
|
||||
stepSize,
|
||||
callback(value) {
|
||||
return moneyTickLabel(value);
|
||||
},
|
||||
},
|
||||
grid: { color: lineColor },
|
||||
};
|
||||
}
|
||||
|
||||
function buildLeaderboardChartConfig(chartData, options = {}) {
|
||||
const compact = options.compact === true;
|
||||
|
||||
return {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
@@ -145,15 +209,26 @@
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'nearest', intersect: false },
|
||||
elements: {
|
||||
point: {
|
||||
radius: compact ? 0 : 2,
|
||||
hoverRadius: 5,
|
||||
},
|
||||
line: {
|
||||
borderWidth: compact ? 2.5 : 2,
|
||||
tension: 0.28,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: textMuted,
|
||||
usePointStyle: true,
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
padding: 18,
|
||||
boxWidth: compact ? 8 : 10,
|
||||
boxHeight: compact ? 8 : 10,
|
||||
padding: compact ? 12 : 18,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
@@ -170,27 +245,186 @@
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: textMuted, maxRotation: 0 },
|
||||
grid: { color: lineColor },
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: textMuted,
|
||||
callback(value) {
|
||||
return `$${value}`;
|
||||
},
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: compact ? 6 : 10,
|
||||
},
|
||||
grid: {
|
||||
color: lineColor,
|
||||
display: !compact,
|
||||
},
|
||||
grid: { color: lineColor },
|
||||
},
|
||||
y: chartYScaleOptions(chartData, compact),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const zoomSelectionPlugin = {
|
||||
id: 'zoomSelection',
|
||||
beforeDraw(chart) {
|
||||
const drag = chart.$zoomDrag;
|
||||
if (!drag || !drag.active || drag.startX === drag.endX) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctx, chartArea } = chart;
|
||||
const left = Math.max(chartArea.left, Math.min(drag.startX, drag.endX));
|
||||
const right = Math.min(chartArea.right, Math.max(drag.startX, drag.endX));
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(229, 139, 58, 0.14)';
|
||||
ctx.strokeStyle = 'rgba(229, 139, 58, 0.74)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top);
|
||||
ctx.strokeRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top);
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
|
||||
Chart.register(zoomSelectionPlugin);
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function enableDragZoom(chart, resetButton) {
|
||||
if (!chart || !chart.canvas || !resetButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = chart.canvas;
|
||||
const minDragDistance = 14;
|
||||
let draggedToZoom = false;
|
||||
|
||||
function pointerX(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return event.clientX - rect.left;
|
||||
}
|
||||
|
||||
function eventInChartArea(event) {
|
||||
const x = pointerX(event);
|
||||
const y = event.clientY - canvas.getBoundingClientRect().top;
|
||||
const area = chart.chartArea;
|
||||
return x >= area.left && x <= area.right && y >= area.top && y <= area.bottom;
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointerdown', (event) => {
|
||||
if (!eventInChartArea(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
const x = pointerX(event);
|
||||
chart.$zoomDrag = { active: true, startX: x, endX: x };
|
||||
chart.update('none');
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointermove', (event) => {
|
||||
if (!chart.$zoomDrag?.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
chart.$zoomDrag.endX = pointerX(event);
|
||||
chart.draw();
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerup', (event) => {
|
||||
const drag = chart.$zoomDrag;
|
||||
if (!drag?.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
drag.active = false;
|
||||
const distance = Math.abs(drag.endX - drag.startX);
|
||||
if (distance < minDragDistance) {
|
||||
chart.draw();
|
||||
return;
|
||||
}
|
||||
|
||||
draggedToZoom = true;
|
||||
const area = chart.chartArea;
|
||||
const startPixel = clamp(Math.min(drag.startX, drag.endX), area.left, area.right);
|
||||
const endPixel = clamp(Math.max(drag.startX, drag.endX), area.left, area.right);
|
||||
const xScale = chart.scales.x;
|
||||
const minIndex = clamp(Math.floor(xScale.getValueForPixel(startPixel)), 0, chart.data.labels.length - 1);
|
||||
const maxIndex = clamp(Math.ceil(xScale.getValueForPixel(endPixel)), 0, chart.data.labels.length - 1);
|
||||
|
||||
if (maxIndex <= minIndex) {
|
||||
chart.draw();
|
||||
return;
|
||||
}
|
||||
|
||||
xScale.options.min = minIndex;
|
||||
xScale.options.max = maxIndex;
|
||||
resetButton.hidden = false;
|
||||
chart.update();
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', (event) => {
|
||||
if (!draggedToZoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
draggedToZoom = false;
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointercancel', () => {
|
||||
if (chart.$zoomDrag) {
|
||||
chart.$zoomDrag.active = false;
|
||||
chart.draw();
|
||||
}
|
||||
});
|
||||
|
||||
resetButton.addEventListener('click', () => {
|
||||
delete chart.options.scales.x.min;
|
||||
delete chart.options.scales.x.max;
|
||||
resetButton.hidden = true;
|
||||
chart.update();
|
||||
});
|
||||
}
|
||||
|
||||
function suppressLegendClickExpansion(chart) {
|
||||
if (!chart?.canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
chart.canvas.addEventListener('click', (event) => {
|
||||
const legend = chart.legend;
|
||||
if (!legend || !legend.legendHitBoxes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = chart.canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
const clickedLegendItem = legend.legendHitBoxes.some((box) => {
|
||||
return x >= box.left
|
||||
&& x <= box.left + box.width
|
||||
&& y >= box.top
|
||||
&& y <= box.top + box.height;
|
||||
});
|
||||
|
||||
if (clickedLegendItem) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const leaderboardCtx = document.getElementById('leaderboardChart');
|
||||
const resetZoomButton = document.getElementById('resetLeaderboardZoom');
|
||||
let leaderboardChart = null;
|
||||
if (leaderboardCtx && leaderboardChartData.labels.length > 0) {
|
||||
leaderboardChart = new Chart(leaderboardCtx, buildLeaderboardChartConfig(leaderboardChartData));
|
||||
leaderboardChart = new Chart(
|
||||
leaderboardCtx,
|
||||
buildLeaderboardChartConfig(mainLeaderboardChartData(leaderboardChartData), { compact: true })
|
||||
);
|
||||
enableDragZoom(leaderboardChart, resetZoomButton);
|
||||
suppressLegendClickExpansion(leaderboardChart);
|
||||
}
|
||||
|
||||
const chartModal = document.getElementById('chartModal');
|
||||
|
||||
Reference in new issue
Block a user