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:
@@ -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` }
|
||||
|
||||
318
src/routes/match/[id]/weapons/+page.svelte
Normal file
318
src/routes/match/[id]/weapons/+page.svelte
Normal 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}
|
||||
23
src/routes/match/[id]/weapons/+page.ts
Normal file
23
src/routes/match/[id]/weapons/+page.ts
Normal 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: [] }
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user