Added graph for event log
This commit is contained in:
244
Log.tsx
244
Log.tsx
@@ -1,10 +1,25 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Medal, CheckCircle2, History } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Medal, CheckCircle2, History, LineChart as LineChartIcon, List } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { useCTF } from './CTFContext';
|
||||
import { calculateChallengeValue, getFirstBloodBonusFactor, calculateTeamTotalScore } from './services/scoring';
|
||||
|
||||
const COLORS = [
|
||||
'#ff0000', // Red
|
||||
'#00ff00', // Green
|
||||
'#0000ff', // Blue
|
||||
'#ffff00', // Yellow
|
||||
'#ff00ff', // Magenta
|
||||
'#00ffff', // Cyan
|
||||
'#ffaa00', // Orange
|
||||
'#bf00ff', // Purple
|
||||
'#ff0080', // Pink
|
||||
'#aaff00' // Lime
|
||||
];
|
||||
|
||||
export const Log: React.FC = () => {
|
||||
const { state } = useCTF();
|
||||
const [view, setView] = useState<'log' | 'graph'>('log');
|
||||
|
||||
const sortedSolves = useMemo(() => {
|
||||
return [...state.solves].sort((a, b) => b.timestamp - a.timestamp);
|
||||
@@ -18,13 +33,89 @@ export const Log: React.FC = () => {
|
||||
.slice(0, 3);
|
||||
}, [state]);
|
||||
|
||||
const top10Teams = useMemo(() => {
|
||||
return state.teams
|
||||
.filter(t => !t.isAdmin && !t.isDisabled)
|
||||
.map(team => ({ ...team, score: calculateTeamTotalScore(team.id, state.challenges, state.solves) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 10);
|
||||
}, [state]);
|
||||
|
||||
const graphData = useMemo(() => {
|
||||
if (state.solves.length === 0) return [];
|
||||
|
||||
const sortedSolvesAsc = [...state.solves].sort((a, b) => a.timestamp - b.timestamp);
|
||||
const dataPoints: any[] = [];
|
||||
|
||||
const startTime = state.startTime || (sortedSolvesAsc.length > 0 ? sortedSolvesAsc[0].timestamp - 1000 : 0);
|
||||
const initialPoint: any = { time: startTime, displayTime: new Date(startTime).toLocaleTimeString() };
|
||||
top10Teams.forEach(t => initialPoint[t.name] = 0);
|
||||
dataPoints.push(initialPoint);
|
||||
|
||||
sortedSolvesAsc.forEach((solve, idx) => {
|
||||
const solvesUpToNow = sortedSolvesAsc.slice(0, idx + 1);
|
||||
const point: any = { time: solve.timestamp, displayTime: new Date(solve.timestamp).toLocaleTimeString() };
|
||||
|
||||
top10Teams.forEach(team => {
|
||||
let total = 0;
|
||||
const teamSolves = solvesUpToNow.filter(s => s.teamId === team.id);
|
||||
|
||||
teamSolves.forEach(ts => {
|
||||
const challenge = state.challenges.find(c => c.id === ts.challengeId);
|
||||
if (!challenge) return;
|
||||
|
||||
const challengeSolvesUpToNow = solvesUpToNow.filter(s => s.challengeId === ts.challengeId);
|
||||
|
||||
const baseValue = calculateChallengeValue(
|
||||
challenge.initialPoints,
|
||||
challenge.minimumPoints || 0,
|
||||
challenge.decaySolves || 1,
|
||||
challengeSolvesUpToNow.length
|
||||
);
|
||||
|
||||
const rank = challengeSolvesUpToNow.findIndex(s => s.teamId === team.id);
|
||||
const bonus = Math.floor(challenge.initialPoints * getFirstBloodBonusFactor(rank));
|
||||
|
||||
total += (baseValue + bonus);
|
||||
});
|
||||
|
||||
point[team.name] = total;
|
||||
});
|
||||
|
||||
dataPoints.push(point);
|
||||
});
|
||||
|
||||
return dataPoints;
|
||||
}, [state, top10Teams]);
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 py-12 max-w-5xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-8 border-b-4 border-[#333] pb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<History size={32} className="text-[#bf00ff]" />
|
||||
<h2 className="text-4xl font-black italic text-white uppercase tracking-tighter">Event Log</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<History size={32} className="text-[#bf00ff]" />
|
||||
<h2 className="text-4xl font-black italic text-white uppercase tracking-tighter">Event Log</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setView('log')}
|
||||
className={`flex items-center gap-2 px-4 py-2 font-bold uppercase tracking-wider text-sm transition-colors ${
|
||||
view === 'log' ? 'bg-[#bf00ff] text-white' : 'bg-[#333] text-slate-300 hover:bg-[#444]'
|
||||
}`}
|
||||
>
|
||||
<List size={18} /> List View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('graph')}
|
||||
className={`flex items-center gap-2 px-4 py-2 font-bold uppercase tracking-wider text-sm transition-colors ${
|
||||
view === 'graph' ? 'bg-[#bf00ff] text-white' : 'bg-[#333] text-slate-300 hover:bg-[#444]'
|
||||
}`}
|
||||
>
|
||||
<LineChartIcon size={18} /> Graph View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topTeams.length > 0 && (
|
||||
<div className="flex items-center gap-6 text-sm font-black italic uppercase">
|
||||
{topTeams.map((team, idx) => (
|
||||
@@ -39,61 +130,104 @@ export const Log: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedSolves.length === 0 ? (
|
||||
<div className="p-8 hxp-border border-[#333] text-center text-slate-500 font-bold uppercase tracking-widest bg-white/5">
|
||||
No activities recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
sortedSolves.map((solve, idx) => {
|
||||
const team = state.teams.find(t => t.id === solve.teamId);
|
||||
const challenge = state.challenges.find(c => c.id === solve.challengeId);
|
||||
|
||||
if (!team || !challenge) return null;
|
||||
|
||||
const challengeSolves = state.solves
|
||||
.filter(s => s.challengeId === solve.challengeId)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const rank = challengeSolves.findIndex(s => s.teamId === solve.teamId);
|
||||
const bonusFactor = getFirstBloodBonusFactor(rank);
|
||||
const basePoints = calculateChallengeValue(
|
||||
challenge.initialPoints,
|
||||
challenge.minimumPoints || 0,
|
||||
challenge.decaySolves || 1,
|
||||
challengeSolves.length
|
||||
);
|
||||
const bonus = Math.floor(challenge.initialPoints * bonusFactor);
|
||||
const pointsGained = basePoints + bonus;
|
||||
{view === 'log' ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sortedSolves.length === 0 ? (
|
||||
<div className="p-8 hxp-border border-[#333] text-center text-slate-500 font-bold uppercase tracking-widest bg-white/5">
|
||||
No activities recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
sortedSolves.map((solve, idx) => {
|
||||
const team = state.teams.find(t => t.id === solve.teamId);
|
||||
const challenge = state.challenges.find(c => c.id === solve.challengeId);
|
||||
|
||||
if (!team || !challenge) return null;
|
||||
|
||||
const getRankIcon = (rank: number) => {
|
||||
if (rank === 0) return <Medal size={18} className="text-[#ffaa00]" />; // Gold
|
||||
if (rank === 1) return <Medal size={18} className="text-[#c0c0c0]" />; // Silver
|
||||
if (rank === 2) return <Medal size={18} className="text-[#cd7f32]" />; // Bronze
|
||||
return <CheckCircle2 size={18} className="text-[#00ff00]" />; // Checkmark
|
||||
};
|
||||
const challengeSolves = state.solves
|
||||
.filter(s => s.challengeId === solve.challengeId)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const rank = challengeSolves.findIndex(s => s.teamId === solve.teamId);
|
||||
const bonusFactor = getFirstBloodBonusFactor(rank);
|
||||
const basePoints = calculateChallengeValue(
|
||||
challenge.initialPoints,
|
||||
challenge.minimumPoints || 0,
|
||||
challenge.decaySolves || 1,
|
||||
challengeSolves.length
|
||||
);
|
||||
const bonus = Math.floor(challenge.initialPoints * bonusFactor);
|
||||
const pointsGained = basePoints + bonus;
|
||||
|
||||
const difficultyColor =
|
||||
challenge.difficulty === 'Low' ? 'text-[#00ff00]' :
|
||||
challenge.difficulty === 'Medium' ? 'text-[#ffaa00]' :
|
||||
'text-[#ff0000]';
|
||||
const getRankIcon = (rank: number) => {
|
||||
if (rank === 0) return <Medal size={18} className="text-[#ffaa00]" />; // Gold
|
||||
if (rank === 1) return <Medal size={18} className="text-[#c0c0c0]" />; // Silver
|
||||
if (rank === 2) return <Medal size={18} className="text-[#cd7f32]" />; // Bronze
|
||||
return <CheckCircle2 size={18} className="text-[#00ff00]" />; // Checkmark
|
||||
};
|
||||
|
||||
const dateStr = new Date(solve.timestamp).toLocaleString();
|
||||
const difficultyColor =
|
||||
challenge.difficulty === 'Low' ? 'text-[#00ff00]' :
|
||||
challenge.difficulty === 'Medium' ? 'text-[#ffaa00]' :
|
||||
'text-[#ff0000]';
|
||||
|
||||
return (
|
||||
<div key={`${solve.teamId}-${solve.challengeId}-${idx}`} className="p-4 hxp-border border-[#333] bg-white/5 flex items-center gap-4 hover:border-[#bf00ff] transition-colors">
|
||||
<div className="flex-shrink-0">
|
||||
{getRankIcon(rank)}
|
||||
const dateStr = new Date(solve.timestamp).toLocaleString();
|
||||
|
||||
return (
|
||||
<div key={`${solve.teamId}-${solve.challengeId}-${idx}`} className="p-4 hxp-border border-[#333] bg-white/5 flex items-center gap-4 hover:border-[#bf00ff] transition-colors">
|
||||
<div className="flex-shrink-0">
|
||||
{getRankIcon(rank)}
|
||||
</div>
|
||||
<div className="text-sm font-mono text-slate-300">
|
||||
<span className="text-slate-500 mr-2">[{dateStr}]</span>
|
||||
<span className="font-bold text-white uppercase italic">{team.name}</span> solved <span className="text-[#bf00ff] uppercase">{challenge.category}</span> - <span className={`font-bold ${difficultyColor}`}>{challenge.title}</span> and gained <span className="text-white font-bold hxp-border-purple px-1 py-0.5 bg-[#bf00ff]/10">{pointsGained} points</span>.
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-mono text-slate-300">
|
||||
<span className="text-slate-500 mr-2">[{dateStr}]</span>
|
||||
<span className="font-bold text-white uppercase italic">{team.name}</span> solved <span className="text-[#bf00ff] uppercase">{challenge.category}</span> - <span className={`font-bold ${difficultyColor}`}>{challenge.title}</span> and gained <span className="text-white font-bold hxp-border-purple px-1 py-0.5 bg-[#bf00ff]/10">{pointsGained} points</span>.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-[600px] p-4 hxp-border border-[#333] bg-white/5 flex flex-col">
|
||||
<h3 className="text-xl font-bold italic text-white mb-4 uppercase">Top 10 Teams - Score Progression</h3>
|
||||
{graphData.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 font-bold uppercase tracking-widest">
|
||||
No data to display.
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={graphData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis
|
||||
dataKey="displayTime"
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888' }}
|
||||
label={{ value: 'Points', angle: -90, position: 'insideLeft', fill: '#888' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#111', borderColor: '#333', color: '#fff' }}
|
||||
/>
|
||||
<Legend />
|
||||
{top10Teams.map((team, idx) => (
|
||||
<Line
|
||||
key={team.id}
|
||||
type="stepAfter"
|
||||
dataKey={team.name}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user