feat: Implement Phase 5 match detail tabs with charts and data visualization
This commit implements significant portions of Phase 5 (Feature Delivery) including: Chart Components (src/lib/components/charts/): - LineChart.svelte: Line charts with Chart.js integration - BarChart.svelte: Vertical/horizontal bar charts with stacking - PieChart.svelte: Pie/Doughnut charts with legend - All charts use Svelte 5 runes ($effect) for reactivity - Responsive design with customizable options Data Display Components (src/lib/components/data-display/): - DataTable.svelte: Generic sortable, filterable table component - TypeScript generics support for type safety - Custom formatters and renderers - Sort indicators and column alignment options Match Detail Pages: - Match layout with header, tabs, and score display - Economy tab: Equipment value charts, buy type classification, round-by-round table - Details tab: Multi-kill distribution charts, team performance, top performers - Chat tab: Chronological messages with filtering, search, and round grouping Additional Components: - SearchBar, ThemeToggle (layout components) - MatchCard, PlayerCard (domain components) - Modal, Skeleton, Tabs, Tooltip (UI components) - Player profile page with stats and recent matches Dependencies: - Installed chart.js for data visualization - Created Svelte 5 compatible chart wrappers Phase 4 marked as complete, Phase 5 at 50% completion. Flashes and Damage tabs deferred for future implementation. Note: Minor linting warnings to be addressed in follow-up commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
233
src/routes/match/[id]/details/+page.svelte
Normal file
233
src/routes/match/[id]/details/+page.svelte
Normal file
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { Trophy, Target, Flame, Zap } 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 type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { match, weaponsData } = data;
|
||||
|
||||
// Calculate additional stats for players
|
||||
const playersWithStats = (match.players || []).map((player) => {
|
||||
const kd = player.deaths > 0 ? (player.kills / player.deaths).toFixed(2) : player.kills.toFixed(2);
|
||||
const hsPercent = player.kills > 0 ? ((player.headshot / player.kills) * 100).toFixed(1) : '0.0';
|
||||
const adr = player.dmg_enemy ? (player.dmg_enemy / (match.max_rounds || 24)).toFixed(1) : '0.0';
|
||||
|
||||
return {
|
||||
...player,
|
||||
kd: parseFloat(kd),
|
||||
hsPercent: parseFloat(hsPercent),
|
||||
adr: parseFloat(adr),
|
||||
totalMultiKills: (player.mk_2 || 0) + (player.mk_3 || 0) + (player.mk_4 || 0) + (player.mk_5 || 0)
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by kills descending
|
||||
const sortedPlayers = playersWithStats.sort((a, b) => b.kills - a.kills);
|
||||
|
||||
// Prepare data table columns
|
||||
const detailsColumns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Player',
|
||||
sortable: true,
|
||||
render: (value: string, row: any) => {
|
||||
const teamClass = row.team_id === 2 ? 'text-terrorist' : 'text-ct';
|
||||
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${value}</a>`;
|
||||
}
|
||||
},
|
||||
{ key: 'kills', label: 'K', sortable: true, align: 'center' as const, class: 'font-mono font-semibold' },
|
||||
{ key: 'deaths', label: 'D', sortable: true, align: 'center' as const, class: 'font-mono' },
|
||||
{ key: 'assists', label: 'A', sortable: true, align: 'center' as const, class: 'font-mono' },
|
||||
{ key: 'kd', label: 'K/D', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => v.toFixed(2) },
|
||||
{ key: 'adr', label: 'ADR', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => v.toFixed(1) },
|
||||
{ key: 'hsPercent', label: 'HS%', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => `${v.toFixed(1)}%` },
|
||||
{ key: 'kast', label: 'KAST%', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => `${v.toFixed(1)}%` },
|
||||
{ key: 'mvp', label: 'MVP', sortable: true, align: 'center' as const, class: 'font-mono' },
|
||||
{
|
||||
key: 'mk_5',
|
||||
label: 'Aces',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
render: (value: number) => {
|
||||
if (value > 0) return `<span class="badge badge-warning badge-sm">${value}</span>`;
|
||||
return '<span class="text-base-content/40">-</span>';
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Multi-kill chart data
|
||||
const multiKillData = {
|
||||
labels: sortedPlayers.map((p) => p.name),
|
||||
datasets: [
|
||||
{
|
||||
label: '2K',
|
||||
data: sortedPlayers.map((p) => p.mk_2 || 0),
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.8)'
|
||||
},
|
||||
{
|
||||
label: '3K',
|
||||
data: sortedPlayers.map((p) => p.mk_3 || 0),
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.8)'
|
||||
},
|
||||
{
|
||||
label: '4K',
|
||||
data: sortedPlayers.map((p) => p.mk_4 || 0),
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.8)'
|
||||
},
|
||||
{
|
||||
label: '5K (Ace)',
|
||||
data: sortedPlayers.map((p) => p.mk_5 || 0),
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.8)'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Calculate team totals
|
||||
const teamAPlayers = playersWithStats.filter((p) => p.team_id === 2);
|
||||
const teamBPlayers = playersWithStats.filter((p) => p.team_id === 3);
|
||||
|
||||
const teamAStats = {
|
||||
totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
|
||||
totalUtilityDamage: teamAPlayers.reduce((sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0), 0),
|
||||
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
||||
avgKAST: (teamAPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamAPlayers.length).toFixed(1)
|
||||
};
|
||||
|
||||
const teamBStats = {
|
||||
totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
|
||||
totalUtilityDamage: teamBPlayers.reduce((sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0), 0),
|
||||
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
|
||||
avgKAST: (teamBPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamBPlayers.length).toFixed(1)
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.meta.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Team Performance Summary -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Terrorists Stats -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-4 text-xl font-bold text-terrorist">Terrorists Performance</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-base-content/60">Total Damage</div>
|
||||
<div class="text-2xl font-bold">{teamAStats.totalDamage.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Utility Damage</div>
|
||||
<div class="text-2xl font-bold">{teamAStats.totalUtilityDamage.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Flash Assists</div>
|
||||
<div class="text-2xl font-bold">{teamAStats.totalFlashAssists}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Avg KAST</div>
|
||||
<div class="text-2xl font-bold">{teamAStats.avgKAST}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Counter-Terrorists Stats -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-4 text-xl font-bold text-ct">Counter-Terrorists Performance</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-base-content/60">Total Damage</div>
|
||||
<div class="text-2xl font-bold">{teamBStats.totalDamage.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Utility Damage</div>
|
||||
<div class="text-2xl font-bold">{teamBStats.totalUtilityDamage.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Flash Assists</div>
|
||||
<div class="text-2xl font-bold">{teamBStats.totalFlashAssists}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base-content/60">Avg KAST</div>
|
||||
<div class="text-2xl font-bold">{teamBStats.avgKAST}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Multi-Kills Chart -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-base-content">Multi-Kill Distribution</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Double kills (2K), triple kills (3K), quad kills (4K), and aces (5K) per player
|
||||
</p>
|
||||
</div>
|
||||
<BarChart data={multiKillData} height={300} />
|
||||
</Card>
|
||||
|
||||
<!-- Detailed Player Statistics Table -->
|
||||
<Card padding="none">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Detailed Player Statistics</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
Complete performance breakdown for all players
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DataTable data={sortedPlayers} columns={detailsColumns} striped hoverable />
|
||||
</Card>
|
||||
|
||||
<!-- Top Performers -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
{#if sortedPlayers.length > 0}
|
||||
<!-- Most Kills -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Trophy class="h-5 w-5 text-warning" />
|
||||
<h3 class="font-semibold text-base-content">Most Kills</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div>
|
||||
<div class="mt-1 text-3xl font-mono font-bold text-primary">{sortedPlayers[0].kills}</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
{sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Best K/D -->
|
||||
{@const bestKD = [...sortedPlayers].sort((a, b) => b.kd - a.kd)[0]}
|
||||
<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">Best K/D Ratio</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{bestKD.name}</div>
|
||||
<div class="mt-1 text-3xl font-mono font-bold text-success">{bestKD.kd.toFixed(2)}</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
{bestKD.kills}K / {bestKD.deaths}D
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Most Utility Damage -->
|
||||
{@const bestUtility = [...sortedPlayers].sort(
|
||||
(a, b) => (b.ud_he || 0) + (b.ud_flames || 0) - ((a.ud_he || 0) + (a.ud_flames || 0))
|
||||
)[0]}
|
||||
<Card padding="lg">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Flame class="h-5 w-5 text-error" />
|
||||
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
|
||||
<div class="mt-1 text-3xl font-mono font-bold text-error">
|
||||
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-base-content/60">
|
||||
HE: {bestUtility.ud_he || 0} | Fire: {bestUtility.ud_flames || 0}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user