feat: Add comprehensive weapons statistics tab for matches

Implement detailed weapon performance tracking and visualization.

## New Features

### Weapons Tab
- Added "Weapons" tab to match layout navigation
- Created `/match/[id]/weapons` route with server-side data loading
- Displays weapon statistics for all players in the match

### Statistics Displayed

**Overview Cards:**
- Total kills across all weapons
- Total damage dealt
- Total hits landed

**Charts & Visualizations:**
- Bar chart: Top 10 most-used weapons by kills
- Pie chart: Hit location distribution (head, chest, stomach, arms, legs)
- Legend with exact hit counts for each body part

**Player Performance Table:**
- Player name (with team color coding)
- Top weapon for each player
- Total kills per player
- Total damage per player
- Total hits per player
- Sortable columns for easy comparison

### Technical Implementation

- **Data Loading**: Server-side fetch of weapons data via `getMatchWeapons()` API
- **Type Safety**: Full TypeScript types with WeaponStats, PlayerWeaponStats, MatchWeaponsResponse
- **Error Handling**: Graceful fallback when weapons data unavailable
- **Aggregation**: Weapon stats aggregated across all players for match-wide insights
- **Team Colors**: Player names colored by team (terrorist/CT)

### UX Improvements

- Empty state with helpful message when no weapons data exists
- Responsive grid layouts for cards and charts
- Consistent styling with existing tabs
- Interactive data table with hover states and sorting

This completes Phase 1 feature 5 of 6 - comprehensive weapon performance analysis
gives players insight into their weapon choices and accuracy.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-12 19:39:38 +01:00
parent 7d642b0be3
commit 2215cab77f
3 changed files with 342 additions and 0 deletions

View File

@@ -19,6 +19,7 @@
{ label: 'Overview', href: `/match/${match.match_id}` },
{ label: 'Economy', href: `/match/${match.match_id}/economy` },
{ label: 'Details', href: `/match/${match.match_id}/details` },
{ label: 'Weapons', href: `/match/${match.match_id}/weapons` },
{ label: 'Flashes', href: `/match/${match.match_id}/flashes` },
{ label: 'Damage', href: `/match/${match.match_id}/damage` },
{ label: 'Chat', href: `/match/${match.match_id}/chat` }

View File

@@ -0,0 +1,318 @@
<script lang="ts">
import { Crosshair, Target, AlertCircle, TrendingUp } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import DataTable from '$lib/components/data-display/DataTable.svelte';
import BarChart from '$lib/components/charts/BarChart.svelte';
import PieChart from '$lib/components/charts/PieChart.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { match, weapons } = data;
// Check if we have weapons data
const hasWeaponsData = weapons && weapons.weapons && weapons.weapons.length > 0;
// Get player names map from match data
const playerNames = new Map(
match.players?.map((p) => [p.id, { name: p.name, team_id: p.team_id }]) || []
);
// Get unique team IDs
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
const firstTeamId = uniqueTeamIds[0] ?? 2;
// Process weapons data for display
const playerWeaponsData =
hasWeaponsData && weapons
? weapons.weapons.map((pw) => {
const playerInfo = playerNames.get(String(pw.player_id));
const totalKills = pw.weapon_stats.reduce((sum, w) => sum + w.kills, 0);
const totalDamage = pw.weapon_stats.reduce((sum, w) => sum + w.damage, 0);
const totalHits = pw.weapon_stats.reduce((sum, w) => sum + w.hits, 0);
// Find most used weapon (by kills)
const topWeapon = pw.weapon_stats.reduce(
(max, w) => (w.kills > max.kills ? w : max),
pw.weapon_stats[0] || { weapon_name: 'None', kills: 0 }
);
return {
player_id: pw.player_id,
player_name: playerInfo?.name || 'Unknown',
team_id: playerInfo?.team_id || 2,
total_kills: totalKills,
total_damage: totalDamage,
total_hits: totalHits,
top_weapon: topWeapon.weapon_name,
top_weapon_kills: topWeapon.kills,
weapon_stats: pw.weapon_stats
};
})
: [];
// Sort by total kills descending
const sortedPlayerWeapons = playerWeaponsData.sort((a, b) => b.total_kills - a.total_kills);
// Prepare data table columns
const weaponColumns = [
{
key: 'player_name' as const,
label: 'Player',
sortable: true,
render: (
value: string | number | boolean | undefined,
row: (typeof sortedPlayerWeapons)[0]
) => {
const strValue = value !== undefined ? String(value) : '';
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
return `<a href="/player/${row.player_id}" class="font-medium hover:underline ${teamClass}">${strValue}</a>`;
}
},
{
key: 'top_weapon' as const,
label: 'Top Weapon',
sortable: true,
align: 'left' as const,
class: 'font-medium'
},
{
key: 'total_kills' as const,
label: 'Total Kills',
sortable: true,
align: 'center' as const,
class: 'font-mono font-semibold'
},
{
key: 'total_damage' as const,
label: 'Total Damage',
sortable: true,
align: 'center' as const,
class: 'font-mono',
format: (v: string | number | undefined) =>
v !== undefined ? (v as number).toLocaleString() : '0'
},
{
key: 'total_hits' as const,
label: 'Total Hits',
sortable: true,
align: 'center' as const,
class: 'font-mono'
}
];
// Aggregate all weapon stats across players
const weaponAggregates = new Map<
string,
{ kills: number; damage: number; hits: number; headshot_pct: number }
>();
if (hasWeaponsData && weapons) {
for (const pw of weapons.weapons) {
for (const ws of pw.weapon_stats) {
const existing = weaponAggregates.get(ws.weapon_name);
if (existing) {
existing.kills += ws.kills;
existing.damage += ws.damage;
existing.hits += ws.hits;
existing.headshot_pct = ws.headshot_pct || 0; // Use latest
} else {
weaponAggregates.set(ws.weapon_name, {
kills: ws.kills,
damage: ws.damage,
hits: ws.hits,
headshot_pct: ws.headshot_pct || 0
});
}
}
}
}
const topWeapons = Array.from(weaponAggregates.entries())
.map(([name, stats]) => ({ name, ...stats }))
.sort((a, b) => b.kills - a.kills)
.slice(0, 10);
// Weapon usage chart data
const weaponUsageData = {
labels: topWeapons.map((w) => w.name),
datasets: [
{
label: 'Kills',
data: topWeapons.map((w) => w.kills),
backgroundColor: 'rgba(59, 130, 246, 0.8)'
}
]
};
// Hit group distribution (aggregate across all weapons)
const hitGroupTotals = {
head: 0,
chest: 0,
stomach: 0,
left_arm: 0,
right_arm: 0,
left_leg: 0,
right_leg: 0
};
if (hasWeaponsData && weapons) {
for (const pw of weapons.weapons) {
for (const ws of pw.weapon_stats) {
hitGroupTotals.head += ws.hit_groups.head || 0;
hitGroupTotals.chest += ws.hit_groups.chest || 0;
hitGroupTotals.stomach += ws.hit_groups.stomach || 0;
hitGroupTotals.left_arm += ws.hit_groups.left_arm || 0;
hitGroupTotals.right_arm += ws.hit_groups.right_arm || 0;
hitGroupTotals.left_leg += ws.hit_groups.left_leg || 0;
hitGroupTotals.right_leg += ws.hit_groups.right_leg || 0;
}
}
}
const hitGroupData = {
labels: ['Head', 'Chest', 'Stomach', 'Arms', 'Legs'],
datasets: [
{
data: [
hitGroupTotals.head,
hitGroupTotals.chest,
hitGroupTotals.stomach,
hitGroupTotals.left_arm + hitGroupTotals.right_arm,
hitGroupTotals.left_leg + hitGroupTotals.right_leg
],
backgroundColor: [
'rgba(239, 68, 68, 0.8)', // Red for head
'rgba(59, 130, 246, 0.8)', // Blue for chest
'rgba(249, 115, 22, 0.8)', // Orange for stomach
'rgba(34, 197, 94, 0.8)', // Green for arms
'rgba(168, 85, 247, 0.8)' // Purple for legs
]
}
]
};
</script>
<svelte:head>
<title>Match Weapons - CS2.WTF</title>
</svelte:head>
{#if !hasWeaponsData}
<Card padding="lg">
<div class="text-center">
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
<h2 class="mb-2 text-2xl font-bold text-base-content">No Weapons Data Available</h2>
<p class="mb-4 text-base-content/60">Weapon statistics are not available for this match.</p>
<Badge variant="warning" size="lg">Weapons data unavailable</Badge>
</div>
</Card>
{:else}
<div class="space-y-6">
<!-- Top Stats Summary -->
<div class="grid gap-6 md:grid-cols-3">
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Crosshair class="h-5 w-5 text-primary" />
<h3 class="font-semibold text-base-content">Total Kills</h3>
</div>
<div class="font-mono text-3xl font-bold text-primary">
{topWeapons.reduce((sum, w) => sum + w.kills, 0)}
</div>
<div class="mt-2 text-xs text-base-content/60">Across all weapons</div>
</Card>
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Target class="h-5 w-5 text-success" />
<h3 class="font-semibold text-base-content">Total Damage</h3>
</div>
<div class="font-mono text-3xl font-bold text-success">
{topWeapons.reduce((sum, w) => sum + w.damage, 0).toLocaleString()}
</div>
<div class="mt-2 text-xs text-base-content/60">Across all weapons</div>
</Card>
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<TrendingUp class="h-5 w-5 text-warning" />
<h3 class="font-semibold text-base-content">Total Hits</h3>
</div>
<div class="font-mono text-3xl font-bold text-warning">
{topWeapons.reduce((sum, w) => sum + w.hits, 0).toLocaleString()}
</div>
<div class="mt-2 text-xs text-base-content/60">Across all weapons</div>
</Card>
</div>
<!-- Top Weapons Chart -->
<Card padding="lg">
<div class="mb-4">
<h2 class="text-2xl font-bold text-base-content">Most Used Weapons</h2>
<p class="text-sm text-base-content/60">Weapons ranked by total kills</p>
</div>
<BarChart data={weaponUsageData} height={300} />
</Card>
<!-- Hit Group Distribution -->
<Card padding="lg">
<div class="mb-4">
<h2 class="text-2xl font-bold text-base-content">Hit Location Distribution</h2>
<p class="text-sm text-base-content/60">Where shots landed across all weapons</p>
</div>
<div class="grid gap-6 md:grid-cols-2">
<PieChart data={hitGroupData} height={300} />
<div class="flex flex-col justify-center">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-[rgba(239,68,68,0.8)]"></div>
<span>Head</span>
</span>
<span class="font-mono font-semibold">{hitGroupTotals.head}</span>
</div>
<div class="flex items-center justify-between">
<span class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-[rgba(59,130,246,0.8)]"></div>
<span>Chest</span>
</span>
<span class="font-mono font-semibold">{hitGroupTotals.chest}</span>
</div>
<div class="flex items-center justify-between">
<span class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-[rgba(249,115,22,0.8)]"></div>
<span>Stomach</span>
</span>
<span class="font-mono font-semibold">{hitGroupTotals.stomach}</span>
</div>
<div class="flex items-center justify-between">
<span class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-[rgba(34,197,94,0.8)]"></div>
<span>Arms</span>
</span>
<span class="font-mono font-semibold"
>{hitGroupTotals.left_arm + hitGroupTotals.right_arm}</span
>
</div>
<div class="flex items-center justify-between">
<span class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-[rgba(168,85,247,0.8)]"></div>
<span>Legs</span>
</span>
<span class="font-mono font-semibold"
>{hitGroupTotals.left_leg + hitGroupTotals.right_leg}</span
>
</div>
</div>
</div>
</div>
</Card>
<!-- Player Weapons Table -->
<Card padding="none">
<div class="p-6">
<h2 class="text-2xl font-bold text-base-content">Player Weapon Performance</h2>
<p class="mt-1 text-sm text-base-content/60">Individual player weapon statistics</p>
</div>
<DataTable data={sortedPlayerWeapons} columns={weaponColumns} striped hoverable />
</Card>
</div>
{/if}

View File

@@ -0,0 +1,23 @@
import type { PageLoad } from './$types';
import { matchesAPI } from '$lib/api/matches';
export const load: PageLoad = async ({ parent, params }) => {
const { match } = await parent();
try {
// Fetch weapons statistics for this match
const weapons = await matchesAPI.getMatchWeapons(params.id);
return {
match,
weapons
};
} catch (error) {
console.error('Failed to load weapons data:', error);
// Return match without weapons data on error
return {
match,
weapons: { match_id: parseInt(params.id), weapons: [] }
};
}
};