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: 'Overview', href: `/match/${match.match_id}` },
|
||||||
{ label: 'Economy', href: `/match/${match.match_id}/economy` },
|
{ label: 'Economy', href: `/match/${match.match_id}/economy` },
|
||||||
{ label: 'Details', href: `/match/${match.match_id}/details` },
|
{ 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: 'Flashes', href: `/match/${match.match_id}/flashes` },
|
||||||
{ label: 'Damage', href: `/match/${match.match_id}/damage` },
|
{ label: 'Damage', href: `/match/${match.match_id}/damage` },
|
||||||
{ label: 'Chat', href: `/match/${match.match_id}/chat` }
|
{ 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