feat: Add player profile performance charts and visualizations

Implemented comprehensive performance analysis for player profiles with interactive charts
and detailed statistics visualization.

Key Features:
- Performance Trend Chart (K/D and KAST over last 15 matches)
- Map Performance Chart (win rate per map with color coding)
- Utility Effectiveness Stats (flash assists, enemies blinded, HE/flame damage)
- Responsive charts using Chart.js LineChart and BarChart components

Technical Updates:
- Enhanced page loader to fetch 15 detailed matches with player stats
- Fixed DataTable Svelte 5 compatibility and type safety
- Updated MatchCard and PlayerCard to use PlayerMeta properties
- Proper error handling and typed data structures

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-04 21:49:36 +01:00
parent 274f5b3b53
commit 8b73a68a6b
6 changed files with 328 additions and 96 deletions

View File

@@ -1,12 +1,13 @@
<script lang="ts" generics="T">
/* eslint-disable no-undef */
import { ArrowUp, ArrowDown } from 'lucide-svelte';
interface Column<T> {
key: keyof T;
label: string;
sortable?: boolean;
format?: (value: any, row: T) => string;
render?: (value: any, row: T) => any;
format?: (value: T[keyof T], row: T) => string;
render?: (value: T[keyof T], row: T) => unknown;
align?: 'left' | 'center' | 'right';
class?: string;
}
@@ -43,19 +44,19 @@
}
};
const sortedData = $derived(() => {
if (!sortKey) return data;
const sortedData = $derived(
!sortKey
? data
: [...data].sort((a, b) => {
const aVal = a[sortKey as keyof T];
const bVal = b[sortKey as keyof T];
return [...data].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (aVal === bVal) return 0;
if (aVal === bVal) return 0;
const comparison = aVal < bVal ? -1 : 1;
return sortDirection === 'asc' ? comparison : -comparison;
});
})();
const comparison = aVal < bVal ? -1 : 1;
return sortDirection === 'asc' ? comparison : -comparison;
})
);
const getValue = (row: T, column: Column<T>) => {
const value = row[column.key];
@@ -77,7 +78,11 @@
class="text-{column.align || 'left'} {column.class || ''}"
onclick={() => handleSort(column)}
>
<div class="flex items-center gap-2" class:justify-end={column.align === 'right'} class:justify-center={column.align === 'center'}>
<div
class="flex items-center gap-2"
class:justify-end={column.align === 'right'}
class:justify-center={column.align === 'center'}
>
<span>{column.label}</span>
{#if column.sortable}
<div class="flex flex-col opacity-40">
@@ -87,7 +92,7 @@
: ''}"
/>
<ArrowDown
class="h-3 w-3 -mt-1 {sortKey === column.key && sortDirection === 'desc'
class="-mt-1 h-3 w-3 {sortKey === column.key && sortDirection === 'desc'
? 'text-primary opacity-100'
: ''}"
/>
@@ -99,7 +104,7 @@
</tr>
</thead>
<tbody>
{#each sortedData as row, i}
{#each sortedData as row}
<tr class:hover={hoverable}>
{#each columns as column}
<td class="text-{column.align || 'left'} {column.class || ''}">

View File

@@ -54,13 +54,13 @@
{/if}
</div>
<!-- Result Badge -->
<!-- Result Badge (inferred from score) -->
<div class="mt-3 flex justify-center">
{#if match.match_result === 0}
{#if match.score_team_a === match.score_team_b}
<Badge variant="warning" size="sm">Tie</Badge>
{:else if match.match_result === 1}
{:else if match.score_team_a > match.score_team_b}
<Badge variant="success" size="sm">Team A Win</Badge>
{:else if match.match_result === 2}
{:else}
<Badge variant="error" size="sm">Team B Win</Badge>
{/if}
</div>

View File

@@ -10,10 +10,11 @@
let { player, showStats = true }: Props = $props();
const kd = player.deaths > 0 ? (player.kills / player.deaths).toFixed(2) : player.kills.toFixed(2);
const winRate = player.wins + player.losses > 0
? ((player.wins / (player.wins + player.losses)) * 100).toFixed(1)
: '0.0';
const kd =
player.avg_deaths > 0
? (player.avg_kills / player.avg_deaths).toFixed(2)
: player.avg_kills.toFixed(2);
const winRate = (player.win_rate * 100).toFixed(1);
</script>
<a
@@ -26,7 +27,7 @@
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-base-100">
<User class="h-6 w-6 text-primary" />
</div>
<div class="flex-1 min-w-0">
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-bold text-base-content">{player.name}</h3>
<p class="text-sm text-base-content/60">ID: {player.id}</p>
</div>
@@ -56,7 +57,7 @@
<div class="mb-1 flex items-center justify-center">
<User class="mr-1 h-4 w-4 text-info" />
</div>
<div class="text-xl font-bold text-base-content">{player.wins + player.losses}</div>
<div class="text-xl font-bold text-base-content">{player.recent_matches}</div>
<div class="text-xs text-base-content/60">Matches</div>
</div>
</div>
@@ -64,11 +65,8 @@
<!-- Footer -->
<div class="border-t border-base-300 bg-base-200 px-4 py-3">
<div class="flex items-center justify-between text-sm">
<span class="text-base-content/60">Record:</span>
<div class="flex gap-2">
<Badge variant="success" size="sm">{player.wins}W</Badge>
<Badge variant="error" size="sm">{player.losses}L</Badge>
</div>
<span class="text-base-content/60">Avg KAST:</span>
<Badge variant="info" size="sm">{player.avg_kast.toFixed(1)}%</Badge>
</div>
</div>
{/if}

View File

@@ -1,24 +1,28 @@
<script lang="ts">
import { User, Target, TrendingUp, Calendar, Trophy, Heart } from 'lucide-svelte';
import { User, Target, TrendingUp, Calendar, Trophy, Heart, Crosshair } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Button from '$lib/components/ui/Button.svelte';
import MatchCard from '$lib/components/match/MatchCard.svelte';
import LineChart from '$lib/components/charts/LineChart.svelte';
import BarChart from '$lib/components/charts/BarChart.svelte';
import { preferences } from '$lib/stores';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { profile, recentMatches } = data;
const { profile, recentMatches, playerStats } = data;
// Calculate stats
const kd = profile.deaths > 0 ? (profile.kills / profile.deaths).toFixed(2) : profile.kills.toFixed(2);
const winRate = profile.wins + profile.losses > 0
? ((profile.wins / (profile.wins + profile.losses)) * 100).toFixed(1)
: '0.0';
const totalMatches = profile.wins + profile.losses;
const hsPercent = profile.headshots > 0 && profile.kills > 0
? ((profile.headshots / profile.kills) * 100).toFixed(1)
: '0.0';
// Calculate stats from PlayerMeta and aggregated match data
const kd =
profile.avg_deaths > 0
? (profile.avg_kills / profile.avg_deaths).toFixed(2)
: profile.avg_kills.toFixed(2);
const winRate = (profile.win_rate * 100).toFixed(1);
// Calculate headshot percentage from playerStats if available
const totalKills = playerStats.reduce((sum, stat) => sum + stat.kills, 0);
const totalHeadshots = playerStats.reduce((sum, stat) => sum + (stat.headshot || 0), 0);
const hsPercent =
totalHeadshots > 0 && totalKills > 0 ? ((totalHeadshots / totalKills) * 100).toFixed(1) : '0.0';
// Check if player is favorited
const isFavorite = $derived($preferences.favoritePlayers.includes(profile.id));
@@ -30,6 +34,118 @@
preferences.addFavoritePlayer(profile.id);
}
};
// Performance trend chart data (K/D ratio over time)
const performanceTrendData = {
labels: playerStats.map((stat, i) => `Match ${playerStats.length - i}`).reverse(),
datasets: [
{
label: 'K/D Ratio',
data: playerStats
.map((stat) => (stat.deaths > 0 ? stat.kills / stat.deaths : stat.kills))
.reverse(),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'KAST %',
data: playerStats.map((stat) => stat.kast).reverse(),
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
tension: 0.4,
fill: true,
yAxisID: 'y1'
}
]
};
const performanceTrendOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false
},
scales: {
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
title: {
display: true,
text: 'K/D Ratio'
}
},
y1: {
type: 'linear' as const,
display: true,
position: 'right' as const,
title: {
display: true,
text: 'KAST %'
},
grid: {
drawOnChartArea: false
}
}
}
};
// Map performance data (win rate per map)
const mapStats = playerStats.reduce(
(acc, stat) => {
if (!acc[stat.map]) {
acc[stat.map] = { wins: 0, total: 0 };
}
const mapStat = acc[stat.map]!;
mapStat.total++;
if (stat.won) mapStat.wins++;
return acc;
},
{} as Record<string, { wins: number; total: number }>
);
const mapPerformanceData = {
labels: Object.keys(mapStats).map((map) => map.replace('de_', '')),
datasets: [
{
label: 'Win Rate %',
data: Object.values(mapStats).map((stat) => (stat.wins / stat.total) * 100),
backgroundColor: Object.values(mapStats).map((stat) => {
const winRate = stat.wins / stat.total;
if (winRate >= 0.6) return 'rgba(34, 197, 94, 0.8)'; // Green for high win rate
if (winRate >= 0.4) return 'rgba(59, 130, 246, 0.8)'; // Blue for medium
return 'rgba(239, 68, 68, 0.8)'; // Red for low win rate
})
}
]
};
const mapPerformanceOptions = {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100,
title: {
display: true,
text: 'Win Rate %'
}
}
}
};
// Utility effectiveness stats
const utilityStats = {
flashAssists: playerStats.reduce((sum, stat) => sum + (stat.flash_assists || 0), 0),
enemiesBlinded: playerStats.reduce((sum, stat) => sum + (stat.flash_total_enemy || 0), 0),
heDamage: playerStats.reduce((sum, stat) => sum + (stat.ud_he || 0), 0),
flameDamage: playerStats.reduce((sum, stat) => sum + (stat.ud_flames || 0), 0),
totalMatches: playerStats.length
};
</script>
<svelte:head>
@@ -42,7 +158,9 @@
<Card variant="elevated" padding="lg">
<div class="flex flex-col items-start gap-6 md:flex-row md:items-center">
<!-- Avatar -->
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-primary to-secondary">
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-primary to-secondary"
>
<User class="h-12 w-12 text-white" />
</div>
@@ -60,9 +178,7 @@
</div>
<div class="flex flex-wrap gap-3 text-sm text-base-content/60">
<span>Steam ID: {profile.id}</span>
{#if profile.rank}
<Badge variant="info">Rating: {profile.rank}</Badge>
{/if}
<span>Last match: {new Date(profile.last_match_date).toLocaleDateString()}</span>
</div>
</div>
@@ -86,7 +202,7 @@
</div>
<div class="text-3xl font-bold text-base-content">{kd}</div>
<div class="mt-1 text-xs text-base-content/60">
{profile.kills} K / {profile.deaths} D
{profile.avg_kills.toFixed(1)} K / {profile.avg_deaths.toFixed(1)} D avg
</div>
</Card>
@@ -97,19 +213,17 @@
</div>
<div class="text-3xl font-bold text-base-content">{winRate}%</div>
<div class="mt-1 text-xs text-base-content/60">
{profile.wins}W - {profile.losses}L
Last {profile.recent_matches} matches
</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<Trophy class="h-5 w-5 text-warning" />
<span class="text-sm font-medium text-base-content/70">Total Matches</span>
</div>
<div class="text-3xl font-bold text-base-content">{totalMatches}</div>
<div class="mt-1 text-xs text-base-content/60">
{profile.wins} wins, {profile.losses} losses
<span class="text-sm font-medium text-base-content/70">KAST %</span>
</div>
<div class="text-3xl font-bold text-base-content">{profile.avg_kast.toFixed(1)}%</div>
<div class="mt-1 text-xs text-base-content/60">Kill/Assist/Survive/Trade average</div>
</Card>
<Card padding="lg">
@@ -119,7 +233,7 @@
</div>
<div class="text-3xl font-bold text-base-content">{hsPercent}%</div>
<div class="mt-1 text-xs text-base-content/60">
{profile.headshots} headshots
{totalHeadshots} of {totalKills} kills
</div>
</Card>
</div>
@@ -129,9 +243,7 @@
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-2xl font-bold text-base-content">Recent Matches</h2>
<Button variant="ghost" href={`/matches?player_id=${profile.id}`}>
View All
</Button>
<Button variant="ghost" href={`/matches?player_id=${profile.id}`}>View All</Button>
</div>
{#if recentMatches.length > 0}
@@ -150,15 +262,98 @@
{/if}
</div>
<!-- Performance Charts (Coming Soon) -->
<Card padding="lg">
<div class="text-center">
<TrendingUp class="mx-auto mb-4 h-16 w-16 text-primary" />
<h3 class="mb-2 text-xl font-semibold text-base-content">Performance Charts</h3>
<p class="mb-4 text-base-content/60">
Rating trends, map performance, favorite weapons, and more visualization coming soon.
</p>
<Badge variant="warning" size="lg">Coming in Future Update</Badge>
<!-- Performance Charts -->
{#if playerStats.length > 0}
<div>
<h2 class="mb-4 text-2xl font-bold text-base-content">Performance Analysis</h2>
<div class="grid gap-6 lg:grid-cols-2">
<!-- Performance Trend Chart -->
<Card padding="lg">
<h3 class="mb-4 text-lg font-semibold text-base-content">
<div class="flex items-center gap-2">
<TrendingUp class="h-5 w-5 text-primary" />
Performance Trend (Last {playerStats.length} Matches)
</div>
</h3>
<div class="h-64">
<LineChart data={performanceTrendData} options={performanceTrendOptions} />
</div>
<p class="mt-4 text-sm text-base-content/60">
Shows K/D ratio and KAST percentage across recent matches. Higher is better.
</p>
</Card>
<!-- Map Performance Chart -->
<Card padding="lg">
<h3 class="mb-4 text-lg font-semibold text-base-content">
<div class="flex items-center gap-2">
<Target class="h-5 w-5 text-secondary" />
Map Win Rate
</div>
</h3>
<div class="h-64">
<BarChart data={mapPerformanceData} options={mapPerformanceOptions} />
</div>
<p class="mt-4 text-sm text-base-content/60">
Win rate percentage by map. Green = strong (≥60%), Blue = average (40-60%), Red = weak
(&lt;40%).
</p>
</Card>
</div>
</div>
</Card>
<!-- Utility Effectiveness -->
<div>
<h2 class="mb-4 text-2xl font-bold text-base-content">Utility Effectiveness</h2>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<Crosshair class="h-5 w-5 text-info" />
<span class="text-sm font-medium text-base-content/70">Flash Assists</span>
</div>
<div class="text-3xl font-bold text-base-content">{utilityStats.flashAssists}</div>
<div class="mt-1 text-xs text-base-content/60">
{(utilityStats.flashAssists / utilityStats.totalMatches).toFixed(1)} per match
</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<Target class="h-5 w-5 text-warning" />
<span class="text-sm font-medium text-base-content/70">Enemies Blinded</span>
</div>
<div class="text-3xl font-bold text-base-content">{utilityStats.enemiesBlinded}</div>
<div class="mt-1 text-xs text-base-content/60">
{(utilityStats.enemiesBlinded / utilityStats.totalMatches).toFixed(1)} per match
</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<Trophy class="h-5 w-5 text-error" />
<span class="text-sm font-medium text-base-content/70">HE Damage</span>
</div>
<div class="text-3xl font-bold text-base-content">
{Math.round(utilityStats.heDamage)}
</div>
<div class="mt-1 text-xs text-base-content/60">
{Math.round(utilityStats.heDamage / utilityStats.totalMatches)} per match
</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<Trophy class="h-5 w-5 text-terrorist" />
<span class="text-sm font-medium text-base-content/70">Flame Damage</span>
</div>
<div class="text-3xl font-bold text-base-content">
{Math.round(utilityStats.flameDamage)}
</div>
<div class="mt-1 text-xs text-base-content/60">
{Math.round(utilityStats.flameDamage / utilityStats.totalMatches)} per match
</div>
</Card>
</div>
</div>
{/if}
</div>

View File

@@ -13,19 +13,50 @@ export const load: PageLoad = async ({ params }) => {
// Fetch player profile and recent matches in parallel
const [profile, matchesData] = await Promise.all([
api.players.getPlayerMeta(playerId),
api.matches.getMatches({ player_id: playerId, limit: 10 })
api.matches.getMatches({ player_id: playerId, limit: 20 })
]);
// Fetch full match details with player stats for performance charts
// Limit to first 15 matches to avoid too many API calls
const matchDetailsPromises = matchesData.matches
.slice(0, 15)
.map((match) => api.matches.getMatch(match.match_id));
const matchesWithDetails = await Promise.all(matchDetailsPromises);
// Extract player stats from each match
const playerStats = matchesWithDetails
.map((match) => {
const playerData = match.players?.find((p) => p.id === playerId);
if (!playerData) return null;
return {
match_id: match.match_id,
map: match.map,
date: match.date,
...playerData,
// Add match result (did player win?)
won:
(playerData.team_id === 2 && match.score_team_a > match.score_team_b) ||
(playerData.team_id === 3 && match.score_team_b > match.score_team_a)
};
})
.filter((stat): stat is NonNullable<typeof stat> => stat !== null);
return {
profile,
recentMatches: matchesData.matches,
recentMatches: matchesData.matches.slice(0, 10), // Show 10 in recent matches section
playerStats, // Full stats for charts
meta: {
title: `${profile.name} - Player Profile | CS2.WTF`,
description: `View ${profile.name}'s CS2 statistics, match history, and performance metrics.`
}
};
} catch (err) {
console.error(`Failed to load player ${playerId}:`, err);
console.error(
`Failed to load player ${playerId}:`,
err instanceof Error ? err.message : String(err)
);
throw error(404, `Player ${playerId} not found`);
}
};