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:
247
src/routes/match/[id]/economy/+page.svelte
Normal file
247
src/routes/match/[id]/economy/+page.svelte
Normal file
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { DollarSign, TrendingUp, ShoppingCart } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import LineChart from '$lib/components/charts/LineChart.svelte';
|
||||
import DataTable from '$lib/components/data-display/DataTable.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { match, roundsData } = data;
|
||||
|
||||
// Aggregate team economy per round
|
||||
interface TeamEconomy {
|
||||
round: number;
|
||||
teamA_bank: number;
|
||||
teamB_bank: number;
|
||||
teamA_equipment: number;
|
||||
teamB_equipment: number;
|
||||
teamA_spent: number;
|
||||
teamB_spent: number;
|
||||
winner: number;
|
||||
teamA_buyType: string;
|
||||
teamB_buyType: string;
|
||||
}
|
||||
|
||||
const teamEconomy: TeamEconomy[] = [];
|
||||
|
||||
// Process rounds data to calculate team totals
|
||||
for (const roundData of roundsData.rounds) {
|
||||
const teamAPlayers = roundData.players.filter((p) => {
|
||||
// Find player's team from match data
|
||||
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
||||
return matchPlayer?.team_id === 2;
|
||||
});
|
||||
|
||||
const teamBPlayers = roundData.players.filter((p) => {
|
||||
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
||||
return matchPlayer?.team_id === 3;
|
||||
});
|
||||
|
||||
const teamA_bank = teamAPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
|
||||
const teamB_bank = teamBPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
|
||||
const teamA_equipment = teamAPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
|
||||
const teamB_equipment = teamBPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
|
||||
const teamA_spent = teamAPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||
const teamB_spent = teamBPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||
|
||||
// Classify buy type based on average equipment value
|
||||
const avgTeamA_equipment = teamAPlayers.length > 0 ? teamA_equipment / teamAPlayers.length : 0;
|
||||
const avgTeamB_equipment = teamBPlayers.length > 0 ? teamB_equipment / teamBPlayers.length : 0;
|
||||
|
||||
const classifyBuyType = (avgEquipment: number): string => {
|
||||
if (avgEquipment < 1500) return 'Eco';
|
||||
if (avgEquipment < 2500) return 'Semi-Eco';
|
||||
if (avgEquipment < 3500) return 'Force';
|
||||
return 'Full Buy';
|
||||
};
|
||||
|
||||
teamEconomy.push({
|
||||
round: roundData.round,
|
||||
teamA_bank,
|
||||
teamB_bank,
|
||||
teamA_equipment,
|
||||
teamB_equipment,
|
||||
teamA_spent,
|
||||
teamB_spent,
|
||||
winner: roundData.winner || 0,
|
||||
teamA_buyType: classifyBuyType(avgTeamA_equipment),
|
||||
teamB_buyType: classifyBuyType(avgTeamB_equipment)
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare chart data
|
||||
const equipmentChartData = {
|
||||
labels: teamEconomy.map((r) => `R${r.round}`),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Terrorists Equipment',
|
||||
data: teamEconomy.map((r) => r.teamA_equipment),
|
||||
borderColor: 'rgb(249, 115, 22)',
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Counter-Terrorists Equipment',
|
||||
data: teamEconomy.map((r) => r.teamB_equipment),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Calculate summary stats
|
||||
const totalRounds = teamEconomy.length;
|
||||
const teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
|
||||
const teamB_fullBuys = teamEconomy.filter((r) => r.teamB_buyType === 'Full Buy').length;
|
||||
const teamA_ecos = teamEconomy.filter((r) => r.teamA_buyType === 'Eco').length;
|
||||
const teamB_ecos = teamEconomy.filter((r) => r.teamB_buyType === 'Eco').length;
|
||||
|
||||
// Prepare table data
|
||||
const tableColumns = [
|
||||
{ key: 'round', label: 'Round', sortable: true, align: 'center' as const },
|
||||
{
|
||||
key: 'teamA_buyType',
|
||||
label: 'T Buy',
|
||||
sortable: true,
|
||||
render: (value: string) => {
|
||||
const variant =
|
||||
value === 'Full Buy'
|
||||
? 'success'
|
||||
: value === 'Eco'
|
||||
? 'error'
|
||||
: value === 'Force'
|
||||
? 'warning'
|
||||
: 'default';
|
||||
return `<span class="badge badge-${variant} badge-sm">${value}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'teamA_equipment',
|
||||
label: 'T Equipment',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
format: (value: number) => `$${value.toLocaleString()}`
|
||||
},
|
||||
{
|
||||
key: 'teamB_buyType',
|
||||
label: 'CT Buy',
|
||||
sortable: true,
|
||||
render: (value: string) => {
|
||||
const variant =
|
||||
value === 'Full Buy'
|
||||
? 'success'
|
||||
: value === 'Eco'
|
||||
? 'error'
|
||||
: value === 'Force'
|
||||
? 'warning'
|
||||
: 'default';
|
||||
return `<span class="badge badge-${variant} badge-sm">${value}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'teamB_equipment',
|
||||
label: 'CT Equipment',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
format: (value: number) => `$${value.toLocaleString()}`
|
||||
},
|
||||
{
|
||||
key: 'winner',
|
||||
label: 'Winner',
|
||||
align: 'center' as const,
|
||||
render: (value: number) => {
|
||||
if (value === 2) return '<span class="badge badge-sm" style="background-color: rgb(249, 115, 22); color: white;">T</span>';
|
||||
if (value === 3) return '<span class="badge badge-sm" style="background-color: rgb(59, 130, 246); color: white;">CT</span>';
|
||||
return '<span class="text-base-content/40">-</span>';
|
||||
}
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.meta.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<Card padding="lg">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<ShoppingCart class="h-5 w-5 text-primary" />
|
||||
<span class="text-sm font-medium text-base-content/70">Total Rounds</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content">{totalRounds}</div>
|
||||
<div class="mt-1 text-xs text-base-content/60">
|
||||
{match.score_team_a} - {match.score_team_b}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<TrendingUp class="h-5 w-5 text-terrorist" />
|
||||
<span class="text-sm font-medium text-base-content/70">Terrorists Buy Rounds</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content">{teamA_fullBuys}</div>
|
||||
<div class="mt-1 text-xs text-base-content/60">{teamA_ecos} eco rounds</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<TrendingUp class="h-5 w-5 text-ct" />
|
||||
<span class="text-sm font-medium text-base-content/70">CT Buy Rounds</span>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-base-content">{teamB_fullBuys}</div>
|
||||
<div class="mt-1 text-xs text-base-content/60">{teamB_ecos} eco rounds</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Equipment Value Chart -->
|
||||
<Card padding="lg">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl font-bold text-base-content">Equipment Value Over Time</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Total equipment value for each team across all rounds
|
||||
</p>
|
||||
</div>
|
||||
<LineChart data={equipmentChartData} height={350} />
|
||||
</Card>
|
||||
|
||||
<!-- Round-by-Round Table -->
|
||||
<Card padding="none">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-base-content">Round-by-Round Economy</h2>
|
||||
<p class="mt-1 text-sm text-base-content/60">
|
||||
Detailed breakdown of buy types and equipment values
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DataTable data={teamEconomy} columns={tableColumns} striped hoverable />
|
||||
</Card>
|
||||
|
||||
<!-- Buy Type Legend -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-3 text-lg font-semibold text-base-content">Buy Type Classification</h3>
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="error" size="sm">Eco</Badge>
|
||||
<span class="text-base-content/60">< $1,500 avg equipment</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="default" size="sm">Semi-Eco</Badge>
|
||||
<span class="text-base-content/60">$1,500 - $2,500 avg equipment</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="warning" size="sm">Force</Badge>
|
||||
<span class="text-base-content/60">$2,500 - $3,500 avg equipment</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="success" size="sm">Full Buy</Badge>
|
||||
<span class="text-base-content/60">> $3,500 avg equipment</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
27
src/routes/match/[id]/economy/+page.ts
Normal file
27
src/routes/match/[id]/economy/+page.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { matchesAPI } from '$lib/api';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params, parent }) => {
|
||||
try {
|
||||
// Get match data from parent layout
|
||||
const { match } = await parent();
|
||||
|
||||
// Fetch round-by-round economy data
|
||||
const roundsData = await matchesAPI.getMatchRounds(params.id);
|
||||
|
||||
return {
|
||||
match,
|
||||
roundsData,
|
||||
meta: {
|
||||
title: `${match.map} Economy - Match ${match.match_id} - CS2.WTF`,
|
||||
description: `Round-by-round economy analysis for ${match.map} match`
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load economy data:', err);
|
||||
throw error(500, {
|
||||
message: 'Failed to load economy data'
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user