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_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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user