330 lines
20 KiB
HTML
330 lines
20 KiB
HTML
|
|
{% extends "base.html" %}
|
||
|
|
|
||
|
|
{% block content %}
|
||
|
|
<div class="space-y-6">
|
||
|
|
<!-- Global Stats Dashboard -->
|
||
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
|
|
<!-- Opponent ELO Distribution -->
|
||
|
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||
|
|
<h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-4">Opponent ELO Curve</h3>
|
||
|
|
<div class="relative h-48 w-full">
|
||
|
|
<canvas id="eloDistChart"></canvas>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Opponent Rating Distribution -->
|
||
|
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||
|
|
<h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-4">Opponent Rating Curve</h3>
|
||
|
|
<div class="relative h-48 w-full">
|
||
|
|
<canvas id="ratingDistChart"></canvas>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Map-specific Opponent Stats -->
|
||
|
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700">
|
||
|
|
<div class="p-6 border-b border-gray-100 dark:border-slate-700">
|
||
|
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">分地图对手统计</h3>
|
||
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">各地图下遇到对手的胜率、ELO、Rating、K/D</p>
|
||
|
|
</div>
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||
|
|
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||
|
|
<tr>
|
||
|
|
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Map</th>
|
||
|
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Matches</th>
|
||
|
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Win Rate</th>
|
||
|
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg Rating</th>
|
||
|
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg K/D</th>
|
||
|
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg Elo</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||
|
|
{% for m in map_stats %}
|
||
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||
|
|
<td class="px-6 py-3 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">{{ m.map_name }}</td>
|
||
|
|
<td class="px-6 py-3 whitespace-nowrap text-center">
|
||
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-slate-700 dark:text-gray-300">
|
||
|
|
{{ m.matches }}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-3 whitespace-nowrap text-center">
|
||
|
|
{% set wr = (m.win_rate or 0) * 100 %}
|
||
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-bold
|
||
|
|
{% if wr > 60 %}bg-red-100 text-red-800 border border-red-200
|
||
|
|
{% elif wr < 40 %}bg-green-100 text-green-800 border border-green-200
|
||
|
|
{% else %}bg-gray-100 text-gray-800 border border-gray-200{% endif %}">
|
||
|
|
{{ "%.1f"|format(wr) }}%
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-3 whitespace-nowrap text-center text-sm font-mono font-bold text-gray-700 dark:text-gray-300">
|
||
|
|
{{ "%.2f"|format(m.avg_rating or 0) }}
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-3 whitespace-nowrap text-center text-sm font-mono text-gray-600 dark:text-gray-400">
|
||
|
|
{{ "%.2f"|format(m.avg_kd or 0) }}
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-3 whitespace-nowrap text-center text-sm font-mono text-gray-500">
|
||
|
|
{% if m.avg_elo %}{{ "%.0f"|format(m.avg_elo) }}{% else %}—{% endif %}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% else %}
|
||
|
|
<tr>
|
||
|
|
<td colspan="6" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">暂无地图统计数据</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Map-specific Shark Encounters -->
|
||
|
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700">
|
||
|
|
<div class="p-6 border-b border-gray-100 dark:border-slate-700">
|
||
|
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">分地图炸鱼哥遭遇次数</h3>
|
||
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">统计各地图出现 rating > 1.5 对手的比赛次数</p>
|
||
|
|
</div>
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||
|
|
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||
|
|
<tr>
|
||
|
|
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Map</th>
|
||
|
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Encounters</th>
|
||
|
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Frequency</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||
|
|
{% for m in map_stats %}
|
||
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||
|
|
<td class="px-6 py-3 whitespace-nowrap text-sm font-bold text-gray-900 dark:text-white">{{ m.map_name }}</td>
|
||
|
|
<td class="px-6 py-3 whitespace-nowrap text-center">
|
||
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 border border-amber-200 dark:bg-slate-700 dark:text-amber-300 dark:border-slate-600">
|
||
|
|
{{ m.shark_matches or 0 }}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-3 whitespace-nowrap text-center">
|
||
|
|
{% set freq = ( (m.shark_matches or 0) / (m.matches or 1) ) * 100 %}
|
||
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-[10px] font-bold bg-gray-100 text-gray-800 border border-gray-200 dark:bg-slate-700 dark:text-gray-300 dark:border-slate-600">
|
||
|
|
{{ "%.1f"|format(freq) }}%
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% else %}
|
||
|
|
<tr>
|
||
|
|
<td colspan="3" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">暂无炸鱼哥统计数据</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700 p-6">
|
||
|
|
<div class="flex flex-col sm:flex-row justify-between items-center mb-6 gap-4">
|
||
|
|
<div>
|
||
|
|
<h2 class="text-2xl font-black text-gray-900 dark:text-white flex items-center gap-2">
|
||
|
|
<span>⚔️</span> 对手分析 (Opponent Analysis)
|
||
|
|
</h2>
|
||
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||
|
|
Analyze performance against specific players encountered in matches.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||
|
|
<!-- Sort Dropdown -->
|
||
|
|
<div class="relative">
|
||
|
|
<select onchange="location = this.value;" class="w-full sm:w-auto appearance-none pl-3 pr-10 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-yrtv-500 dark:text-white">
|
||
|
|
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='matches') }}" {% if sort_by == 'matches' %}selected{% endif %}>Sort by Matches</option>
|
||
|
|
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='rating') }}" {% if sort_by == 'rating' %}selected{% endif %}>Sort by Rating</option>
|
||
|
|
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='kd') }}" {% if sort_by == 'kd' %}selected{% endif %}>Sort by K/D</option>
|
||
|
|
<option value="{{ url_for('opponents.index', search=request.args.get('search', ''), sort='win_rate') }}" {% if sort_by == 'win_rate' %}selected{% endif %}>Sort by Win Rate (Nemesis)</option>
|
||
|
|
</select>
|
||
|
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-500">
|
||
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form action="{{ url_for('opponents.index') }}" method="get" class="flex gap-2">
|
||
|
|
<input type="hidden" name="sort" value="{{ sort_by }}">
|
||
|
|
<input type="text" name="search" placeholder="Search opponent..." class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-50 dark:bg-slate-700/50 focus:outline-none focus:ring-2 focus:ring-yrtv-500 dark:text-white transition" value="{{ request.args.get('search', '') }}">
|
||
|
|
<button type="submit" class="px-4 py-2 bg-yrtv-600 text-white font-bold rounded-lg hover:bg-yrtv-700 transition shadow-lg shadow-yrtv-500/30">
|
||
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="overflow-x-auto">
|
||
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||
|
|
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||
|
|
<tr>
|
||
|
|
<th scope="col" class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Opponent</th>
|
||
|
|
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Matches vs Us</th>
|
||
|
|
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their Win Rate</th>
|
||
|
|
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their Rating</th>
|
||
|
|
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Their K/D</th>
|
||
|
|
<th scope="col" class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Avg Match Elo</th>
|
||
|
|
<th scope="col" class="relative px-6 py-3"><span class="sr-only">View</span></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||
|
|
{% for op in opponents %}
|
||
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors group">
|
||
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
||
|
|
<div class="flex items-center">
|
||
|
|
<div class="flex-shrink-0 h-10 w-10">
|
||
|
|
{% if op.avatar_url %}
|
||
|
|
<img class="h-10 w-10 rounded-full object-cover border-2 border-white shadow-sm" src="{{ op.avatar_url }}" alt="">
|
||
|
|
{% else %}
|
||
|
|
<div class="h-10 w-10 rounded-full bg-gradient-to-br from-gray-100 to-gray-300 flex items-center justify-center text-gray-500 font-bold text-xs">
|
||
|
|
{{ op.username[:2]|upper if op.username else '??' }}
|
||
|
|
</div>
|
||
|
|
{% endif %}
|
||
|
|
</div>
|
||
|
|
<div class="ml-4">
|
||
|
|
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ op.username }}</div>
|
||
|
|
<div class="text-xs text-gray-500 font-mono">{{ op.steam_id_64 }}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-slate-700 dark:text-gray-300">
|
||
|
|
{{ op.matches }}
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||
|
|
{% set wr = op.win_rate * 100 %}
|
||
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-bold
|
||
|
|
{% if wr > 60 %}bg-red-100 text-red-800 border border-red-200
|
||
|
|
{% elif wr < 40 %}bg-green-100 text-green-800 border border-green-200
|
||
|
|
{% else %}bg-gray-100 text-gray-800 border border-gray-200{% endif %}">
|
||
|
|
{{ "%.1f"|format(wr) }}%
|
||
|
|
</span>
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono font-bold text-gray-700 dark:text-gray-300">
|
||
|
|
{{ "%.2f"|format(op.avg_rating or 0) }}
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-600 dark:text-gray-400">
|
||
|
|
{{ "%.2f"|format(op.avg_kd or 0) }}
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm font-mono text-gray-500">
|
||
|
|
{% if op.avg_match_elo %}
|
||
|
|
{{ "%.0f"|format(op.avg_match_elo) }}
|
||
|
|
{% else %}—{% endif %}
|
||
|
|
</td>
|
||
|
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||
|
|
<a href="{{ url_for('opponents.detail', steam_id=op.steam_id_64) }}" class="text-yrtv-600 hover:text-yrtv-900 font-bold hover:underline">Analyze →</a>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% else %}
|
||
|
|
<tr>
|
||
|
|
<td colspan="7" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||
|
|
No opponents found.
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Pagination -->
|
||
|
|
<div class="mt-6 flex justify-between items-center border-t border-gray-200 dark:border-slate-700 pt-4">
|
||
|
|
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||
|
|
Total <span class="font-bold">{{ total }}</span> opponents found
|
||
|
|
</div>
|
||
|
|
<div class="flex gap-2">
|
||
|
|
{% if page > 1 %}
|
||
|
|
<a href="{{ url_for('opponents.index', page=page-1, search=request.args.get('search', ''), sort=sort_by) }}" class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600 transition">Previous</a>
|
||
|
|
{% endif %}
|
||
|
|
{% if page < total_pages %}
|
||
|
|
<a href="{{ url_for('opponents.index', page=page+1, search=request.args.get('search', ''), sort=sort_by) }}" class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600 transition">Next</a>
|
||
|
|
{% endif %}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{% endblock %}
|
||
|
|
|
||
|
|
{% block scripts %}
|
||
|
|
<script>
|
||
|
|
document.addEventListener('DOMContentLoaded', function() {
|
||
|
|
// Data from Backend
|
||
|
|
const stats = {{ stats_summary | tojson }};
|
||
|
|
|
||
|
|
const createChart = (id, label, labels, data, color, type='line') => {
|
||
|
|
const ctx = document.getElementById(id).getContext('2d');
|
||
|
|
new Chart(ctx, {
|
||
|
|
type: type,
|
||
|
|
data: {
|
||
|
|
labels: labels,
|
||
|
|
datasets: [{
|
||
|
|
label: label,
|
||
|
|
data: data,
|
||
|
|
backgroundColor: 'rgba(124, 58, 237, 0.1)',
|
||
|
|
borderColor: color,
|
||
|
|
tension: 0.35,
|
||
|
|
fill: true,
|
||
|
|
borderRadius: 4,
|
||
|
|
barPercentage: 0.6
|
||
|
|
}]
|
||
|
|
},
|
||
|
|
options: {
|
||
|
|
responsive: true,
|
||
|
|
maintainAspectRatio: false,
|
||
|
|
plugins: {
|
||
|
|
legend: { display: false }
|
||
|
|
},
|
||
|
|
scales: {
|
||
|
|
y: {
|
||
|
|
beginAtZero: true,
|
||
|
|
grid: { color: 'rgba(156, 163, 175, 0.1)' },
|
||
|
|
ticks: { display: false } // Hide Y axis labels for cleaner look
|
||
|
|
},
|
||
|
|
x: {
|
||
|
|
grid: { display: false },
|
||
|
|
ticks: { font: { size: 10 } }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const buildBins = (values, step, roundFn) => {
|
||
|
|
if (!values || values.length === 0) return { labels: [], data: [] };
|
||
|
|
const min = Math.min(...values);
|
||
|
|
const max = Math.max(...values);
|
||
|
|
let start = Math.floor(min / step) * step;
|
||
|
|
let end = Math.ceil(max / step) * step;
|
||
|
|
const bins = [];
|
||
|
|
const labels = [];
|
||
|
|
for (let v = start; v <= end; v += step) {
|
||
|
|
bins.push(0);
|
||
|
|
labels.push(roundFn(v));
|
||
|
|
}
|
||
|
|
values.forEach(val => {
|
||
|
|
const idx = Math.floor((val - start) / step);
|
||
|
|
if (idx >= 0 && idx < bins.length) bins[idx] += 1;
|
||
|
|
});
|
||
|
|
return { labels, data: bins };
|
||
|
|
};
|
||
|
|
|
||
|
|
if (stats.elo_values && stats.elo_values.length) {
|
||
|
|
const eloStep = 100; // 可按需改为50
|
||
|
|
const { labels, data } = buildBins(stats.elo_values, eloStep, v => Math.round(v));
|
||
|
|
createChart('eloDistChart', 'Opponents', labels, data, 'rgba(124, 58, 237, 1)', 'line');
|
||
|
|
} else if (stats.elo_dist) {
|
||
|
|
createChart('eloDistChart', 'Opponents', Object.keys(stats.elo_dist), Object.values(stats.elo_dist), 'rgba(124, 58, 237, 1)', 'line');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (stats.rating_values && stats.rating_values.length) {
|
||
|
|
const rStep = 0.1; // 可按需改为0.2
|
||
|
|
const { labels, data } = buildBins(stats.rating_values, rStep, v => Number(v.toFixed(1)));
|
||
|
|
createChart('ratingDistChart', 'Opponents', labels, data, 'rgba(234, 179, 8, 1)', 'line');
|
||
|
|
} else if (stats.rating_dist) {
|
||
|
|
const order = ['<0.8','0.8-1.0','1.0-1.2','1.2-1.4','>1.4'];
|
||
|
|
const labels = order.filter(k => stats.rating_dist.hasOwnProperty(k));
|
||
|
|
const data = labels.map(k => stats.rating_dist[k]);
|
||
|
|
createChart('ratingDistChart', 'Opponents', labels, data, 'rgba(234, 179, 8, 1)', 'line');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
{% endblock %}
|