upd
This commit is contained in:
109
src/components/scoreboard/ScoreBreakdown.tsx
Normal file
109
src/components/scoreboard/ScoreBreakdown.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ScoreBreakdown as ScoreBreakdownType } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatNumber } from '../../utils/formatters';
|
||||
|
||||
interface ScoreBreakdownProps {
|
||||
breakdown: ScoreBreakdownType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreBreakdown({ breakdown, className }: ScoreBreakdownProps) {
|
||||
const total = breakdown.totalAttack + breakdown.totalDefense + breakdown.totalSLA;
|
||||
|
||||
const attackPercent = total > 0 ? (breakdown.totalAttack / total) * 100 : 0;
|
||||
const defensePercent = total > 0 ? (breakdown.totalDefense / total) * 100 : 0;
|
||||
const slaPercent = total > 0 ? (breakdown.totalSLA / total) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Breakdown bars */}
|
||||
<div className="h-4 overflow-hidden rounded-full bg-muted">
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className="bg-red-500 transition-all"
|
||||
style={{ width: `${attackPercent}%` }}
|
||||
title={`Атака: ${formatNumber(breakdown.totalAttack)}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-blue-500 transition-all"
|
||||
style={{ width: `${defensePercent}%` }}
|
||||
title={`Защита: ${formatNumber(breakdown.totalDefense)}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-green-500 transition-all"
|
||||
style={{ width: `${slaPercent}%` }}
|
||||
title={`SLA: ${formatNumber(breakdown.totalSLA)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Атака</div>
|
||||
<div className="text-lg font-bold text-red-400">
|
||||
{formatNumber(breakdown.totalAttack)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{attackPercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Защита</div>
|
||||
<div className="text-lg font-bold text-blue-400">
|
||||
{formatNumber(breakdown.totalDefense)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{defensePercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">SLA</div>
|
||||
<div className="text-lg font-bold text-green-400">
|
||||
{formatNumber(breakdown.totalSLA)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{slaPercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Penalties and bonuses */}
|
||||
{(breakdown.penalties !== 0 || breakdown.bonuses !== 0) && (
|
||||
<div className="flex justify-between border-t border-border pt-4">
|
||||
{breakdown.penalties !== 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Штрафы: </span>
|
||||
<span className="text-destructive">-{formatNumber(breakdown.penalties)}</span>
|
||||
</div>
|
||||
)}
|
||||
{breakdown.bonuses !== 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Бонусы: </span>
|
||||
<span className="text-green-400">+{formatNumber(breakdown.bonuses)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service breakdown */}
|
||||
{breakdown.services.length > 0 && (
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<div className="text-sm font-medium">По сервисам:</div>
|
||||
{breakdown.services.map((service) => (
|
||||
<div key={service.serviceId} className="flex items-center justify-between text-sm">
|
||||
<span>{service.serviceName}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-red-400">+{service.attackScore}</span>
|
||||
<span className="text-xs text-blue-400">+{service.defenseScore}</span>
|
||||
<span className="text-xs text-green-400">+{service.slaScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScoreBreakdown;
|
||||
150
src/components/scoreboard/ScoreChart.tsx
Normal file
150
src/components/scoreboard/ScoreChart.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ScoreHistory } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { getTeamColor } from '../../utils/colors';
|
||||
|
||||
interface ScoreChartProps {
|
||||
history: ScoreHistory[];
|
||||
height?: number;
|
||||
showLegend?: boolean;
|
||||
showGrid?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreChart({
|
||||
history,
|
||||
height = 300,
|
||||
showLegend = true,
|
||||
showGrid = true,
|
||||
className,
|
||||
}: ScoreChartProps) {
|
||||
// Get all rounds
|
||||
const allRounds = useMemo(() => {
|
||||
const rounds = new Set<number>();
|
||||
history.forEach((h) => {
|
||||
h.history.forEach((point) => rounds.add(point.round));
|
||||
});
|
||||
return Array.from(rounds).sort((a, b) => a - b);
|
||||
}, [history]);
|
||||
|
||||
// Get max score for scaling
|
||||
const maxScore = useMemo(() => {
|
||||
let max = 0;
|
||||
history.forEach((h) => {
|
||||
h.history.forEach((point) => {
|
||||
if (point.score > max) max = point.score;
|
||||
});
|
||||
});
|
||||
return max || 100;
|
||||
}, [history]);
|
||||
|
||||
// Generate path for a team
|
||||
const generatePath = (teamHistory: ScoreHistory) => {
|
||||
if (allRounds.length === 0 || teamHistory.history.length === 0) return '';
|
||||
|
||||
const points = allRounds.map((round) => {
|
||||
const point = teamHistory.history.find((p) => p.round === round);
|
||||
const score = point?.score || 0;
|
||||
const x = (allRounds.indexOf(round) / (allRounds.length - 1 || 1)) * 100;
|
||||
const y = 100 - (score / maxScore) * 100;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
return points.map((p, i) => (i === 0 ? `M ${p}` : `L ${p}`)).join(' ');
|
||||
};
|
||||
|
||||
if (history.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)} style={{ height }}>
|
||||
<p className="text-muted-foreground">Нет данных для отображения</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Chart */}
|
||||
<div className="relative overflow-hidden rounded-lg border border-border bg-card">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full"
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Grid */}
|
||||
{showGrid && (
|
||||
<>
|
||||
{[0, 25, 50, 75, 100].map((y) => (
|
||||
<line
|
||||
key={y}
|
||||
x1="0"
|
||||
y1={y}
|
||||
x2="100"
|
||||
y2={y}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="0.2"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lines */}
|
||||
{history.map((teamHistory, index) => (
|
||||
<g key={teamHistory.teamId}>
|
||||
<path
|
||||
d={generatePath(teamHistory)}
|
||||
fill="none"
|
||||
stroke={getTeamColor(index)}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Points */}
|
||||
{teamHistory.history.map((point, i) => {
|
||||
const x = (allRounds.indexOf(point.round) / (allRounds.length - 1 || 1)) * 100;
|
||||
const y = 100 - (point.score / maxScore) * 100;
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="1"
|
||||
fill={getTeamColor(index)}
|
||||
className="opacity-0 hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[10px] text-muted-foreground">
|
||||
{allRounds.slice(0, 10).map((round) => (
|
||||
<span key={round}>{round}</span>
|
||||
))}
|
||||
{allRounds.length > 10 && <span>...</span>}
|
||||
{allRounds.length > 1 && <span>{allRounds[allRounds.length - 1]}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{history.map((teamHistory, index) => (
|
||||
<div key={teamHistory.teamId} className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: getTeamColor(index) }}
|
||||
/>
|
||||
<span className="text-sm">{teamHistory.teamName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScoreChart;
|
||||
169
src/components/scoreboard/ScoreboardTable.tsx
Normal file
169
src/components/scoreboard/ScoreboardTable.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Trophy, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { ScoreboardEntry } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatNumber, formatPositionChange, formatSLA } from '../../utils/formatters';
|
||||
import { getTeamColor } from '../../utils/colors';
|
||||
|
||||
interface ScoreboardTableProps {
|
||||
entries: ScoreboardEntry[];
|
||||
highlightTeamId?: string;
|
||||
compact?: boolean;
|
||||
showBreakdown?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreboardTable({
|
||||
entries,
|
||||
highlightTeamId,
|
||||
compact = false,
|
||||
showBreakdown = false,
|
||||
className,
|
||||
}: ScoreboardTableProps) {
|
||||
// Sort by position
|
||||
const sortedEntries = useMemo(() => {
|
||||
return [...entries].sort((a, b) => a.position - b.position);
|
||||
}, [entries]);
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-lg border border-border', className)}>
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className={cn('px-4 py-3 text-left text-sm font-medium', compact ? 'w-12' : 'w-16')}>
|
||||
#
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Команда</th>
|
||||
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-24' : 'w-32')}>
|
||||
Очки
|
||||
</th>
|
||||
{showBreakdown && (
|
||||
<>
|
||||
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
|
||||
Атака
|
||||
</th>
|
||||
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
|
||||
Защита
|
||||
</th>
|
||||
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
|
||||
SLA
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-20' : 'w-24')}>
|
||||
Флаги
|
||||
</th>
|
||||
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-16' : 'w-20')}>
|
||||
SLA
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedEntries.map((entry, index) => {
|
||||
const isHighlighted = entry.teamId === highlightTeamId;
|
||||
const isTop3 = entry.position <= 3;
|
||||
const positionChange = entry.positionChange || 0;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={entry.teamId}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors',
|
||||
isHighlighted && 'bg-primary/5',
|
||||
!isHighlighted && 'hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{/* Position */}
|
||||
<td className={cn('px-4 py-3', compact ? 'text-sm' : 'text-base')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isTop3 ? (
|
||||
<Trophy
|
||||
className={cn(
|
||||
'h-5 w-5',
|
||||
entry.position === 1 && 'text-yellow-400',
|
||||
entry.position === 2 && 'text-gray-300',
|
||||
entry.position === 3 && 'text-orange-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono font-medium">{entry.position}</span>
|
||||
)}
|
||||
{positionChange !== 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center text-xs',
|
||||
positionChange > 0 && 'text-green-400',
|
||||
positionChange < 0 && 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{positionChange > 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Team */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||
style={{ backgroundColor: getTeamColor(index) }}
|
||||
>
|
||||
{entry.teamTag?.slice(0, 2).toUpperCase() || entry.teamName.slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn('font-medium', compact ? 'text-sm' : 'text-base')}>
|
||||
{entry.teamName}
|
||||
</p>
|
||||
{showBreakdown && entry.teamTag && (
|
||||
<p className="text-xs text-muted-foreground">{entry.teamTag}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Score */}
|
||||
<td className={cn('px-4 py-3 text-right font-bold', compact ? 'text-lg' : 'text-xl')}>
|
||||
{formatNumber(entry.score)}
|
||||
</td>
|
||||
|
||||
{/* Breakdown */}
|
||||
{showBreakdown && (
|
||||
<>
|
||||
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
|
||||
<span className="text-red-400">+{formatNumber(entry.attackScore)}</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
|
||||
<span className="text-blue-400">+{formatNumber(entry.defenseScore)}</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
|
||||
<span className="text-green-400">+{formatNumber(entry.slaScore)}</span>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Flags */}
|
||||
<td className={cn('px-4 py-3 text-right', compact ? 'text-sm' : 'text-base')}>
|
||||
<span className="text-green-400">{entry.flagsCaptured}</span>
|
||||
<span className="mx-1 text-muted-foreground">/</span>
|
||||
<span className="text-red-400">{entry.flagsLost}</span>
|
||||
</td>
|
||||
|
||||
{/* SLA */}
|
||||
<td className={cn('px-4 py-3 text-right font-medium', compact ? 'text-sm' : 'text-base')}>
|
||||
{formatSLA(entry.avgSLA)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScoreboardTable;
|
||||
124
src/components/scoreboard/TeamScoreCard.tsx
Normal file
124
src/components/scoreboard/TeamScoreCard.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Trophy, Target, Shield, Activity } from 'lucide-react';
|
||||
import { ScoreboardEntry } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatNumber, formatSLA } from '../../utils/formatters';
|
||||
import { getTeamColor } from '../../utils/colors';
|
||||
|
||||
interface TeamScoreCardProps {
|
||||
entry: ScoreboardEntry;
|
||||
rank?: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function TeamScoreCard({
|
||||
entry,
|
||||
rank,
|
||||
compact = false,
|
||||
className,
|
||||
onClick,
|
||||
}: TeamScoreCardProps) {
|
||||
const isTop3 = (rank || entry.position) <= 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50 hover:shadow-lg',
|
||||
onClick && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold text-white"
|
||||
style={{ backgroundColor: getTeamColor(entry.position - 1) }}
|
||||
>
|
||||
{entry.teamTag?.slice(0, 2).toUpperCase() || entry.teamName.slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{entry.teamName}</h3>
|
||||
{entry.teamTag && (
|
||||
<p className="text-sm text-muted-foreground">{entry.teamTag}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isTop3 && <Trophy className="h-6 w-6 text-yellow-400" />}
|
||||
<span className="text-2xl font-bold">#{entry.position}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main score */}
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-muted-foreground">Общий счёт</div>
|
||||
<div className="text-4xl font-bold text-primary">{formatNumber(entry.score)}</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Attack */}
|
||||
<div className="rounded-lg bg-red-500/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-red-400">
|
||||
<Target className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Атака</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-red-400">
|
||||
+{formatNumber(entry.attackScore)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{entry.flagsCaptured} флагов
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defense */}
|
||||
<div className="rounded-lg bg-blue-500/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-blue-400">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Защита</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-blue-400">
|
||||
+{formatNumber(entry.defenseScore)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{entry.flagsLost} потеряно
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLA */}
|
||||
<div className="rounded-lg bg-green-500/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-green-400">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">SLA</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-green-400">
|
||||
+{formatNumber(entry.slaScore)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSLA(entry.avgSLA)} uptime
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend */}
|
||||
<div className="rounded-lg bg-purple-500/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-purple-400">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Тренд</span>
|
||||
</div>
|
||||
<div className={cn('text-xl font-bold', entry.trend === 'up' && 'text-green-400', entry.trend === 'down' && 'text-red-400')}>
|
||||
{entry.positionChange && entry.positionChange > 0 ? '+' : ''}
|
||||
{entry.positionChange || 0}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
позиций
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamScoreCard;
|
||||
5
src/components/scoreboard/index.ts
Normal file
5
src/components/scoreboard/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Export all scoreboard components
|
||||
export { ScoreboardTable } from './ScoreboardTable';
|
||||
export { ScoreChart } from './ScoreChart';
|
||||
export { TeamScoreCard } from './TeamScoreCard';
|
||||
export { ScoreBreakdown } from './ScoreBreakdown';
|
||||
Reference in New Issue
Block a user