From 2939415f76ca6c2edf1d4ee8815cfe671fa957e7 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Tue, 28 Apr 2026 13:15:59 -0700 Subject: [PATCH] improve leaderboard chart --- app.py | 9 +- static/css/style.css | 8 +- stats.py | 18 ++- templates/leaderboard.html | 260 +++++++++++++++++++++++++++++++++++-- 4 files changed, 272 insertions(+), 23 deletions(-) diff --git a/app.py b/app.py index 35a7d66..48ae2f9 100644 --- a/app.py +++ b/app.py @@ -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, ) diff --git a/static/css/style.css b/static/css/style.css index f77f9b9..1655265 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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 { diff --git a/stats.py b/stats.py index 7d8d75a..59fe04e 100644 --- a/stats.py +++ b/stats.py @@ -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, } ) diff --git a/templates/leaderboard.html b/templates/leaderboard.html index 8228f9c..1bf7cc9 100644 --- a/templates/leaderboard.html +++ b/templates/leaderboard.html @@ -17,6 +17,7 @@

Cumulative profit over time

+
@@ -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');