improve leaderboard chart

This commit is contained in:
SowinskiBraeden committed 2026-04-28 13:15:59 -07:00
1 parent 32d212ad46
commit 1265e3047f
4 files changed
+272 -23

No files matched your search

+8 -1
View File
@@ -29,6 +29,7 @@ from stats import (
session_breakdown_series, session_breakdown_series,
session_events, session_events,
session_label, session_label,
session_sort_key,
unique_player_names, unique_player_names,
) )
from storage import append_event, ensure_data_file, load_events from storage import append_event, ensure_data_file, load_events
@@ -102,7 +103,7 @@ def home() -> str:
def leaderboard() -> str: def leaderboard() -> str:
events = load_events(DATA_PATH) events = load_events(DATA_PATH)
all_sessions = build_session_summaries(events) 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] session_ids = [session.session_id for session in ordered_sessions]
selected_session_id = request.args.get("through_session", "").strip() selected_session_id = request.args.get("through_session", "").strip()
@@ -111,6 +112,7 @@ def leaderboard() -> str:
if session_ids: if session_ids:
if selected_session_id in session_ids: if selected_session_id in session_ids:
cutoff_index = session_ids.index(selected_session_id) cutoff_index = session_ids.index(selected_session_id)
label = session_label(ordered_sessions[cutoff_index])
else: else:
cutoff_index = len(session_ids) - 1 cutoff_index = len(session_ids) - 1
selected_session = ordered_sessions[cutoff_index] selected_session = ordered_sessions[cutoff_index]
@@ -137,6 +139,11 @@ def leaderboard() -> str:
available_sessions=all_sessions, available_sessions=all_sessions,
selected_session_id=selected_session_id, selected_session_id=selected_session_id,
selected_session_label=(label if selected_session_id else "Latest session"), 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, session_label=session_label,
) )
+6 -2
View File
@@ -318,6 +318,10 @@ h2 {
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 0.95fr); 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 { .three-col {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
@@ -415,7 +419,7 @@ h2 {
} }
.chart-panel-large { .chart-panel-large {
min-height: 540px; min-height: 600px;
} }
.chart-frame, .chart-frame,
@@ -425,7 +429,7 @@ h2 {
} }
.chart-panel-large .chart-frame { .chart-panel-large .chart-frame {
min-height: 450px; min-height: 510px;
} }
.chart-frame-interactive { .chart-frame-interactive {
+11 -7
View File
@@ -212,20 +212,21 @@ def cents_to_dollars(cents: int) -> str:
return f"${value:,.2f}" 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_id = session.session_id.strip()
session_date = session.session_date.strip()
if session_id == session.session_date: if session_id == session_date:
return (session.session_date, 1, session_id) 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(): 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(): 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: 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: for player_name in all_players:
running_total = 0 running_total = 0
seen_player = False seen_player = False
sessions_played = 0
data: list[float | None] = [] data: list[float | None] = []
for session in ordered_sessions: for session in ordered_sessions:
@@ -535,6 +537,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
continue continue
seen_player = True seen_player = True
sessions_played += 1
running_total += matching_entry.net_cents running_total += matching_entry.net_cents
data.append(round(running_total / 100, 2)) data.append(round(running_total / 100, 2))
@@ -542,6 +545,7 @@ def cumulative_profit_series(sessions: list[SessionSummary]) -> dict[str, object
{ {
"label": player_name, "label": player_name,
"data": data, "data": data,
"sessions_played": sessions_played,
} }
) )
+247 -13
View File
@@ -17,6 +17,7 @@
<h2>Cumulative profit over time</h2> <h2>Cumulative profit over time</h2>
</div> </div>
<div class="panel-actions"> <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> <button class="secondary-button" type="button" id="expandLeaderboardChart">Expand chart</button>
</div> </div>
</div> </div>
@@ -137,7 +138,70 @@
const textMuted = rootStyles.getPropertyValue('--text-muted').trim(); const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
const lineColor = rootStyles.getPropertyValue('--line').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 { return {
type: 'line', type: 'line',
data: chartData, data: chartData,
@@ -145,15 +209,26 @@
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false }, interaction: { mode: 'nearest', intersect: false },
elements: {
point: {
radius: compact ? 0 : 2,
hoverRadius: 5,
},
line: {
borderWidth: compact ? 2.5 : 2,
tension: 0.28,
},
},
plugins: { plugins: {
legend: { legend: {
display: true,
position: 'bottom', position: 'bottom',
labels: { labels: {
color: textMuted, color: textMuted,
usePointStyle: true, usePointStyle: true,
boxWidth: 10, boxWidth: compact ? 8 : 10,
boxHeight: 10, boxHeight: compact ? 8 : 10,
padding: 18, padding: compact ? 12 : 18,
}, },
}, },
tooltip: { tooltip: {
@@ -170,27 +245,186 @@
}, },
scales: { scales: {
x: { x: {
ticks: { color: textMuted, maxRotation: 0 },
grid: { color: lineColor },
},
y: {
ticks: { ticks: {
color: textMuted, color: textMuted,
callback(value) { maxRotation: 0,
return `$${value}`; 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 leaderboardCtx = document.getElementById('leaderboardChart');
const resetZoomButton = document.getElementById('resetLeaderboardZoom');
let leaderboardChart = null; let leaderboardChart = null;
if (leaderboardCtx && leaderboardChartData.labels.length > 0) { 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'); const chartModal = document.getElementById('chartModal');