Compare commits

...

2 Commits

Author SHA1 Message Date
ba5bf14ee2 3.0.3: Cant fix team avg. removed. 2026-01-29 23:44:02 +08:00
3bb3d61c2e 3.0.2- rollback 2026-01-29 12:18:05 +08:00
12 changed files with 251 additions and 206 deletions

Binary file not shown.

View File

@@ -239,7 +239,7 @@ def main(force_all: bool = False, workers: int = 1):
result["last_match_date"],
)
success_count += 1
if processed_count % 4 == 0:
if processed_count % 2 == 0:
conn_l3.commit()
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")
else:
@@ -267,7 +267,7 @@ def main(force_all: bool = False, workers: int = 1):
continue
processed_count = idx
if processed_count % 4 == 0:
if processed_count % 2 == 0:
conn_l3.commit()
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")

View File

@@ -6,6 +6,7 @@ from web.database import execute_db, query_db
from web.config import Config
from datetime import datetime
import os
import json
from werkzeug.utils import secure_filename
bp = Blueprint('players', __name__, url_prefix='/players')
@@ -231,6 +232,41 @@ def charts_data(steam_id):
radar_data = {}
radar_dist = FeatureService.get_roster_features_distribution(steam_id)
# Task 1: Strict Team Average Calculation
team_avg_radar = None
lineups = WebService.get_lineups()
if lineups:
target_lineup = None
try:
p_ids = [str(i) for i in json.loads(lineups[0].get("player_ids_json") or "[]")]
if str(steam_id) in p_ids:
target_lineup = p_ids
except:
target_lineup = None
if target_lineup:
# Calculate strict average for this lineup
team_sums = {
'score_aim': 0.0, 'score_defense': 0.0, 'score_utility': 0.0,
'score_clutch': 0.0, 'score_economy': 0.0, 'score_pace': 0.0,
'score_pistol': 0.0, 'score_stability': 0.0
}
member_count = 0
for member_id in target_lineup:
mf = FeatureService.get_player_features(member_id)
if mf:
member_count += 1
for k in team_sums:
team_sums[k] += float(mf.get(k) or 0.0)
if member_count > 0:
team_avg_radar = {k: v / member_count for k, v in team_sums.items()}
# Fallback: if calculated avg is all zeros (e.g. teammates have no stats),
# treat as None to trigger global fallback in frontend
if sum(team_avg_radar.values()) == 0:
team_avg_radar = None
if features:
# Dimensions: AIM, DEFENSE, UTILITY, CLUTCH, ECONOMY, PACE (6 Dimensions)
# Use calculated scores (0-100 scale)
@@ -266,7 +302,8 @@ def charts_data(steam_id):
return jsonify({
'trend': {'labels': trend_labels, 'values': trend_values},
'radar': radar_data,
'radar_dist': radar_dist
'radar_dist': radar_dist,
'team_avg_radar': team_avg_radar
})
# --- API for Comparison ---
@@ -297,7 +334,6 @@ def api_batch_stats():
# 1. Radar Scores (Normalized 0-100)
# Use safe conversion with default 0 if None
# Force 0.0 if value is 0 or None to ensure JSON compatibility
radar = {
'AIM': float(f.get('score_aim') or 0.0),
'DEFENSE': float(f.get('score_defense') or 0.0),
@@ -310,16 +346,11 @@ def api_batch_stats():
}
# 2. Basic Stats for Table
rating_val = f.get('core_avg_rating2')
if rating_val is None:
rating_val = f.get('core_avg_rating')
if rating_val is None:
rating_val = f.get('basic_avg_rating')
basic = {
'rating': float(rating_val or 0),
'kd': float(f.get('core_avg_kd') or f.get('basic_avg_kd') or 0),
'adr': float(f.get('core_avg_adr') or f.get('basic_avg_adr') or 0),
'kast': float(f.get('core_avg_kast') or f.get('basic_avg_kast') or 0),
'rating': float(f.get('basic_avg_rating') or 0),
'kd': float(f.get('basic_avg_kd') or 0),
'adr': float(f.get('basic_avg_adr') or 0),
'kast': float(f.get('basic_avg_kast') or 0),
'hs_rate': float(f.get('basic_headshot_rate') or 0),
'fk_rate': float(f.get('basic_first_kill_rate') or 0),
'matches': int(f.get('matches_played') or 0)
@@ -354,22 +385,22 @@ def api_batch_stats():
'first_kill_ct': float(f.get('side_first_kill_rate_ct') or 0),
# Row 3
'first_death_t': float(f.get('side_first_death_rate_t') or 0),
'first_death_ct': float(f.get('side_first_death_rate_ct') or 0),
'first_death_t': float(f.get('tac_fd_rate') or 0),
'first_death_ct': float(f.get('tac_fd_rate') or 0),
'kast_t': float(f.get('side_kast_t') or 0),
'kast_ct': float(f.get('side_kast_ct') or 0),
# Row 4
'rws_t': float(f.get('side_rws_t') or 0),
'rws_ct': float(f.get('side_rws_ct') or 0),
'multikill_t': float(f.get('side_multikill_rate_t') or 0),
'multikill_ct': float(f.get('side_multikill_rate_ct') or 0),
'rws_t': float(f.get('core_avg_rws') or 0),
'rws_ct': float(f.get('core_avg_rws') or 0),
'multikill_t': float(f.get('tac_multikill_rate') or 0),
'multikill_ct': float(f.get('tac_multikill_rate') or 0),
# Row 5
'hs_t': float(f.get('side_headshot_rate_t') or 0),
'hs_ct': float(f.get('side_headshot_rate_ct') or 0),
'obj_t': float(f.get('side_obj_t') or 0),
'obj_ct': float(f.get('side_obj_ct') or 0)
'hs_t': float(f.get('core_hs_rate') or 0),
'hs_ct': float(f.get('core_hs_rate') or 0),
'obj_t': float(f.get('core_avg_plants') or 0),
'obj_ct': float(f.get('core_avg_defuses') or 0)
}
stats.append({

View File

@@ -27,7 +27,6 @@ def api_analyze():
total_kd = 0
total_adr = 0
count = 0
radar_vectors = []
for p in players:
p_dict = dict(p)
@@ -38,25 +37,10 @@ def api_analyze():
player_data.append(p_dict)
if stats:
rating_val = stats.get('core_avg_rating2')
if rating_val is None:
rating_val = stats.get('core_avg_rating')
if rating_val is None:
rating_val = stats.get('basic_avg_rating')
total_rating += rating_val or 0
total_kd += stats.get('core_avg_kd', stats.get('basic_avg_kd', 0)) or 0
total_adr += stats.get('core_avg_adr', stats.get('basic_avg_adr', 0)) or 0
total_rating += stats.get('basic_avg_rating', 0) or 0
total_kd += stats.get('basic_avg_kd', 0) or 0
total_adr += stats.get('basic_avg_adr', 0) or 0
count += 1
radar_vectors.append([
float(stats.get('score_aim') or 0),
float(stats.get('score_defense') or 0),
float(stats.get('score_utility') or 0),
float(stats.get('score_clutch') or 0),
float(stats.get('score_economy') or 0),
float(stats.get('score_pace') or 0),
float(stats.get('score_pistol') or 0),
float(stats.get('score_stability') or 0)
])
# 2. Shared Matches
shared_matches = StatsService.get_shared_matches(steam_ids)
@@ -68,23 +52,41 @@ def api_analyze():
'kd': total_kd / count if count else 0,
'adr': total_adr / count if count else 0
}
# Calculate 8-Dimension Averages
radar_keys = {
'score_aim': 'AIM', 'score_defense': 'DEFENSE', 'score_utility': 'UTILITY',
'score_clutch': 'CLUTCH', 'score_economy': 'ECONOMY', 'score_pace': 'PACE',
'score_pistol': 'PISTOL', 'score_stability': 'STABILITY'
}
radar_stats = {v: 0.0 for v in radar_keys.values()}
if count > 0:
for p in player_data:
stats = p.get('stats', {})
for k, v in radar_keys.items():
radar_stats[v] += float(stats.get(k) or 0.0)
for k in radar_stats:
radar_stats[k] /= count
chemistry = 0
if len(radar_vectors) >= 2:
def cosine_sim(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(y * y for y in b) ** 0.5
if na == 0 or nb == 0:
return 0
return dot / (na * nb)
sims = []
for i in range(len(radar_vectors)):
for j in range(i + 1, len(radar_vectors)):
sims.append(cosine_sim(radar_vectors[i], radar_vectors[j]))
if sims:
chemistry = sum(sims) / len(sims) * 100
# Calculate Chemistry
# Formula: Base on shared matches and win rate
# Max Score = 100
# 50% weight on match count (Cap at 50 matches = 50 pts)
# 50% weight on win rate (100% WR = 50 pts)
avg_shared_count = 0
avg_shared_winrate = 0
if shared_matches:
avg_shared_count = len(shared_matches)
wins = sum(1 for m in shared_matches if m['is_win'])
avg_shared_winrate = wins / len(shared_matches)
chem_match_score = min(50, avg_shared_count) # 1 point per match, max 50
chem_win_score = avg_shared_winrate * 50
chemistry_score = chem_match_score + chem_win_score
# 4. Map Stats Calculation
map_stats = {} # {map_name: {'count': 0, 'wins': 0}}
@@ -117,9 +119,10 @@ def api_analyze():
'players': player_data,
'shared_matches': [dict(m) for m in shared_matches],
'avg_stats': avg_stats,
'radar_stats': radar_stats,
'chemistry_score': chemistry_score,
'map_stats': map_stats_list,
'total_shared_matches': total_shared_matches,
'chemistry': chemistry
'total_shared_matches': total_shared_matches
})
# API: Save Board

View File

@@ -78,12 +78,8 @@ class FeatureService:
}
for legacy_key, l3_key in alias_map.items():
legacy_val = f.get(legacy_key)
l3_val = f.get(l3_key)
if legacy_val is None and l3_val is not None:
f[legacy_key] = l3_val
elif l3_val is None and legacy_val is not None:
f[l3_key] = legacy_val
if legacy_key not in f or f.get(legacy_key) is None:
f[legacy_key] = f.get(l3_key)
if f.get("matches_played") is None:
f["matches_played"] = f.get("total_matches", 0) or 0
@@ -170,33 +166,13 @@ class FeatureService:
lineups = WebService.get_lineups()
roster_ids: list[str] = []
# Try to find a lineup containing this player
if lineups:
for lineup in lineups:
try:
p_ids = [str(i) for i in json.loads(lineup.get("player_ids_json") or "[]")]
if str(target_steam_id) in p_ids:
roster_ids = p_ids
break
except Exception:
continue
# If not found in any lineup, use the most recent lineup as a fallback context
if not roster_ids and lineups:
try:
roster_ids = [str(i) for i in json.loads(lineups[0].get("player_ids_json") or "[]")]
except Exception:
roster_ids = []
# If still no roster (e.g. no lineups at all), fallback to a "Global Context" (Top 50 active players)
# This ensures we always have a distribution to compare against
if not roster_ids:
rows = query_db("l3", "SELECT steam_id_64 FROM dm_player_features ORDER BY last_match_date DESC LIMIT 50")
roster_ids = [str(r['steam_id_64']) for r in rows] if rows else []
# Ensure target player is in the list
if str(target_steam_id) not in roster_ids:
roster_ids.append(str(target_steam_id))
try:
p_ids = [str(i) for i in json.loads(lineups[0].get("player_ids_json") or "[]")]
if str(target_steam_id) in p_ids:
roster_ids = p_ids
except Exception:
roster_ids = []
if not roster_ids:
return None

View File

@@ -733,19 +733,16 @@ class StatsService:
from web.services.feature_service import FeatureService
import json
# 1. Get Active Roster IDs
lineups = WebService.get_lineups()
active_roster_ids = []
target_steam_id = str(target_steam_id)
if lineups:
for lineup in lineups:
try:
raw_ids = json.loads(lineup.get('player_ids_json') or '[]')
roster_ids = [str(uid) for uid in raw_ids]
if target_steam_id in roster_ids:
active_roster_ids = roster_ids
break
except Exception:
continue
try:
raw_ids = json.loads(lineups[0]['player_ids_json'])
active_roster_ids = [str(uid) for uid in raw_ids]
except:
pass
if not active_roster_ids:
return None
@@ -755,8 +752,11 @@ class StatsService:
return None
stats_map = {str(row["steam_id_64"]): FeatureService._normalize_features(dict(row)) for row in rows}
target_steam_id = str(target_steam_id)
# If target not in map (e.g. no L3 data), try to add empty default
if target_steam_id not in stats_map:
return None
stats_map[target_steam_id] = {}
metrics = [
# TIER 1: CORE

View File

@@ -40,15 +40,15 @@
<!-- Mini Stats -->
<div class="grid grid-cols-3 gap-x-4 gap-y-2 text-xs text-gray-600 dark:text-gray-300 mb-4 w-full text-center">
<div>
<span class="block font-bold">{{ "%.2f"|format(player.core_avg_rating2 or player.core_avg_rating or 0) }}</span>
<span class="block font-bold">{{ "%.2f"|format(player.core_avg_rating2|default(player.basic_avg_rating)|default(0)) }}</span>
<span class="text-gray-400">Rating</span>
</div>
<div>
<span class="block font-bold">{{ "%.2f"|format(player.core_avg_kd or 0) }}</span>
<span class="block font-bold">{{ "%.2f"|format(player.basic_avg_kd|default(0)) }}</span>
<span class="text-gray-400">K/D</span>
</div>
<div>
<span class="block font-bold">{{ "%.1f"|format((player.core_avg_kast or 0) * 100) }}%</span>
<span class="block font-bold">{{ "%.1f"|format((player.basic_avg_kast|default(0)) * 100) }}%</span>
<span class="text-gray-400">KAST</span>
</div>
</div>

View File

@@ -869,6 +869,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Prepare Distribution Data
const dist = data.radar_dist || {};
const hasDist = Object.keys(dist).length > 0;
const getDist = (key) => dist[key] || { rank: '?', avg: 0 };
// Map friendly names to keys
@@ -877,41 +878,49 @@ document.addEventListener('DOMContentLoaded', function() {
const rawLabels = ['枪法 (Aim)', '生存 (Defense)', '道具 (Utility)', '残局 (Clutch)', '经济 (Economy)', '节奏 (Pace)', '手枪 (Pistol)', '稳定 (Stability)'];
const labels = rawLabels.map((l, i) => {
if (!hasDist) return l;
const k = keys[i];
const d = getDist(k);
return `${l} #${d.rank}`;
});
const teamAvgs = keys.map(k => getDist(k).avg);
let teamAvgs;
if (data.team_avg_radar) {
teamAvgs = keys.map(k => data.team_avg_radar[k] || 0);
}
const datasets = [{
label: 'Player',
data: [
data.radar.AIM, data.radar.DEFENSE, data.radar.UTILITY,
data.radar.CLUTCH, data.radar.ECONOMY, data.radar.PACE,
data.radar.PISTOL, data.radar.STABILITY
],
backgroundColor: 'rgba(124, 58, 237, 0.2)',
borderColor: '#7c3aed',
borderWidth: 2,
pointBackgroundColor: '#7c3aed',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: '#7c3aed'
}];
if (teamAvgs) {
datasets.push({
label: 'Team Avg',
data: teamAvgs,
backgroundColor: 'rgba(148, 163, 184, 0.2)',
borderColor: '#94a3b8',
borderWidth: 2,
pointRadius: 0,
borderDash: [5, 5]
});
}
new Chart(ctxRadar, {
type: 'radar',
data: {
labels: labels,
datasets: [{
label: 'Player',
data: [
data.radar.AIM, data.radar.DEFENSE, data.radar.UTILITY,
data.radar.CLUTCH, data.radar.ECONOMY, data.radar.PACE,
data.radar.PISTOL, data.radar.STABILITY
],
backgroundColor: 'rgba(124, 58, 237, 0.2)',
borderColor: '#7c3aed',
borderWidth: 2,
pointBackgroundColor: '#7c3aed',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: '#7c3aed'
},
{
label: 'Team Avg',
data: teamAvgs,
backgroundColor: 'rgba(148, 163, 184, 0.2)', // Slate-400
borderColor: '#94a3b8',
borderWidth: 2,
pointRadius: 0,
borderDash: [5, 5]
}]
datasets: datasets
},
options: {
plugins: {

View File

@@ -338,10 +338,10 @@ function tacticsBoard() {
this.radarChart = new Chart(ctx, {
type: 'radar',
data: {
labels: ['枪法', '生存', '道具', '残局', '经济', '节奏', '手枪', '稳定'],
labels: ['RTG', 'K/D', 'KST', 'ADR', 'IMP', 'UTL'],
datasets: [{
label: 'Avg',
data: [0, 0, 0, 0, 0, 0, 0, 0],
data: [0, 0, 0, 0, 0, 0],
backgroundColor: 'rgba(139, 92, 246, 0.2)',
borderColor: 'rgba(139, 92, 246, 1)',
pointBackgroundColor: 'rgba(139, 92, 246, 1)',
@@ -354,7 +354,7 @@ function tacticsBoard() {
scales: {
r: {
beginAtZero: true,
max: 100,
max: 1.5,
grid: { color: 'rgba(156, 163, 175, 0.1)' },
angleLines: { color: 'rgba(156, 163, 175, 0.1)' },
pointLabels: { font: { size: 9 } },
@@ -368,22 +368,20 @@ function tacticsBoard() {
updateRadar() {
if (this.activePlayers.length === 0) {
this.radarChart.data.datasets[0].data = [0, 0, 0, 0, 0, 0, 0, 0];
this.radarChart.data.datasets[0].data = [0, 0, 0, 0, 0, 0];
this.radarChart.update();
return;
}
let totals = [0, 0, 0, 0, 0, 0, 0, 0];
let totals = [0, 0, 0, 0, 0, 0];
this.activePlayers.forEach(p => {
const s = p.stats || {};
totals[0] += s.score_aim || 0;
totals[1] += s.score_defense || 0;
totals[2] += s.score_utility || 0;
totals[3] += s.score_clutch || 0;
totals[4] += s.score_economy || 0;
totals[5] += s.score_pace || 0;
totals[6] += s.score_pistol || 0;
totals[7] += s.score_stability || 0;
totals[0] += s.basic_avg_rating || 0;
totals[1] += s.basic_avg_kd || 0;
totals[2] += s.basic_avg_kast || 0;
totals[3] += (s.basic_avg_adr || 0) / 100;
totals[4] += s.bat_avg_impact || 1.0;
totals[5] += s.util_usage_rate || 0.5;
});
const count = this.activePlayers.length;
@@ -395,4 +393,4 @@ function tacticsBoard() {
}
}
</script>
{% endblock %}
{% endblock %}

View File

@@ -120,7 +120,7 @@
<span class="text-sm font-bold truncate w-full text-center dark:text-white mb-1" x-text="p.username || p.name"></span>
<div class="px-2.5 py-1 bg-white dark:bg-slate-900 rounded-full text-xs text-gray-500 dark:text-gray-400 shadow-inner border border-gray-100 dark:border-slate-700">
Rating: <span class="font-bold text-yrtv-600" x-text="(p.stats?.core_avg_rating2 || p.stats?.core_avg_rating || p.stats?.basic_avg_rating || 0).toFixed(2)"></span>
Rating: <span class="font-bold text-yrtv-600" x-text="((p.stats?.core_avg_rating2 || p.stats?.basic_avg_rating) || 0).toFixed(2)"></span>
</div>
</div>
</template>
@@ -149,16 +149,19 @@
<h4 class="font-bold text-xl text-gray-900 dark:text-white flex items-center gap-2">
<span>📈</span> 综合评分
</h4>
<div class="flex items-baseline gap-6">
<div class="flex items-baseline gap-2">
<span class="text-sm text-gray-500">Team Rating</span>
<span class="text-4xl font-black text-yrtv-600 tracking-tight" x-text="analysisResult.avg_stats.rating.toFixed(2)"></span>
</div>
<div class="flex items-baseline gap-2">
<span class="text-sm text-gray-500">Chemistry</span>
<span class="text-3xl font-black text-yrtv-600 tracking-tight" x-text="analysisResult.chemistry.toFixed(1)"></span>
</div>
<div class="flex items-baseline gap-2">
<span class="text-sm text-gray-500">Team Rating</span>
<span class="text-4xl font-black text-yrtv-600 tracking-tight" x-text="analysisResult.avg_stats.rating.toFixed(2)"></span>
</div>
<div class="flex items-baseline gap-2">
<span class="text-sm text-gray-500">Chemistry Score</span>
<span class="text-4xl font-black text-blue-600 tracking-tight" x-text="(analysisResult.chemistry_score || 0).toFixed(0)"></span>
</div>
</div>
<!-- Analysis Radar Chart -->
<div class="bg-gray-50 dark:bg-slate-700 p-4 rounded-xl border border-gray-100 dark:border-slate-600 h-[300px]">
<canvas id="analysisRadarChart"></canvas>
</div>
<div class="grid grid-cols-3 gap-6 text-center">
@@ -342,6 +345,7 @@ function tacticsApp() {
// Analysis State
analysisLineup: [],
analysisResult: null,
analysisChart: null,
debounceTimer: null,
// Data Center State
@@ -416,7 +420,8 @@ function tacticsApp() {
steam_id_64: player.steam_id_64,
username: player.username || player.name,
name: player.name || player.username,
avatar_url: player.avatar_url
avatar_url: player.avatar_url,
stats: player.stats || { basic_avg_rating: 0.0 } // Include stats for drag preview
};
event.dataTransfer.setData('text/plain', JSON.stringify(payload));
event.dataTransfer.effectAllowed = 'copy';
@@ -532,10 +537,14 @@ function tacticsApp() {
// Unwrap proxy if needed
const rawData = JSON.parse(JSON.stringify(this.dataResult));
const radarKeys = ['AIM', 'DEFENSE', 'UTILITY', 'CLUTCH', 'ECONOMY', 'PACE', 'PISTOL', 'STABILITY'];
const datasets = rawData.map((p, idx) => {
const color = this.getPlayerColor(idx);
const d = radarKeys.map(k => (p.radar?.[k] || 0));
const d = [
p.radar.AIM || 0, p.radar.DEFENSE || 0, p.radar.UTILITY || 0,
p.radar.CLUTCH || 0, p.radar.ECONOMY || 0, p.radar.PACE || 0,
p.radar.PISTOL || 0, p.radar.STABILITY || 0
];
return {
label: p.username,
data: d,
@@ -546,49 +555,12 @@ function tacticsApp() {
};
});
const valuesByDim = radarKeys.map(() => []);
rawData.forEach(p => {
radarKeys.forEach((k, i) => {
valuesByDim[i].push(Number(p.radar?.[k] || 0));
});
});
const avgVals = valuesByDim.map(arr => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0);
const minVals = valuesByDim.map(arr => arr.length ? Math.min(...arr) : 0);
const maxVals = valuesByDim.map(arr => arr.length ? Math.max(...arr) : 0);
datasets.push({
label: 'Avg',
data: avgVals,
borderColor: '#64748b',
backgroundColor: 'rgba(100, 116, 139, 0.08)',
borderWidth: 2,
pointRadius: 0
});
datasets.push({
label: 'Max',
data: maxVals,
borderColor: '#16a34a',
backgroundColor: 'rgba(22, 163, 74, 0.05)',
borderWidth: 1,
borderDash: [4, 3],
pointRadius: 0
});
datasets.push({
label: 'Min',
data: minVals,
borderColor: '#dc2626',
backgroundColor: 'rgba(220, 38, 38, 0.05)',
borderWidth: 1,
borderDash: [4, 3],
pointRadius: 0
});
// Recreate Chart with Profile-aligned config
const ctx = canvas.getContext('2d');
this.radarChart = new Chart(ctx, {
type: 'radar',
data: {
labels: ['枪法 (Aim)', '生存 (Defense)', '道具 (Utility)', '残局 (Clutch)', '经济 (Economy)', '节奏 (Pace)', '手枪 (Pistol)', '稳定 (Stability)'],
labels: ['AIM (枪法)', 'DEF (生存)', 'UTIL (道具)', 'CLUTCH (残局)', 'ECO (经济)', 'PACE (节奏)', 'PISTOL (手枪)', 'STA (稳定)'],
datasets: datasets
},
options: {
@@ -635,7 +607,7 @@ function tacticsApp() {
this.radarChart = new Chart(ctx, {
type: 'radar',
data: {
labels: ['枪法 (Aim)', '生存 (Defense)', '道具 (Utility)', '残局 (Clutch)', '经济 (Economy)', '节奏 (Pace)', '手枪 (Pistol)', '稳定 (Stability)'],
labels: ['AIM (枪法)', 'DEF (生存)', 'UTIL (道具)', 'CLUTCH (残局)', 'ECO (经济)', 'PACE (节奏)', 'PISTOL (手枪)', 'STA (稳定)'],
datasets: []
},
options: {
@@ -699,6 +671,59 @@ function tacticsApp() {
.then(res => res.json())
.then(data => {
this.analysisResult = data;
this.$nextTick(() => {
this.updateAnalysisChart();
});
});
},
updateAnalysisChart() {
if (this.analysisChart) {
this.analysisChart.destroy();
this.analysisChart = null;
}
const canvas = document.getElementById('analysisRadarChart');
if (!canvas || !this.analysisResult || !this.analysisResult.radar_stats) return;
const stats = this.analysisResult.radar_stats;
const data = [
stats.AIM || 0, stats.DEFENSE || 0, stats.UTILITY || 0,
stats.CLUTCH || 0, stats.ECONOMY || 0, stats.PACE || 0,
stats.PISTOL || 0, stats.STABILITY || 0
];
const ctx = canvas.getContext('2d');
this.analysisChart = new Chart(ctx, {
type: 'radar',
data: {
labels: ['AIM (枪法)', 'DEF (生存)', 'UTIL (道具)', 'CLUTCH (残局)', 'ECO (经济)', 'PACE (节奏)', 'PISTOL (手枪)', 'STA (稳定)'],
datasets: [{
label: 'Team Average',
data: data,
backgroundColor: 'rgba(59, 130, 246, 0.2)',
borderColor: '#3b82f6',
borderWidth: 2,
pointRadius: 3
}]
},
options: {
maintainAspectRatio: false,
scales: {
r: {
min: 0, max: 100,
ticks: { display: false, stepSize: 20 },
pointLabels: {
font: { size: 11, weight: 'bold' },
color: (ctx) => document.documentElement.classList.contains('dark') ? '#cbd5e1' : '#374151'
},
grid: {
color: (ctx) => document.documentElement.classList.contains('dark') ? 'rgba(51, 65, 85, 0.5)' : 'rgba(229, 231, 235, 0.8)'
}
}
},
plugins: { legend: { display: false } }
}
});
},
@@ -817,4 +842,4 @@ function tacticsApp() {
}
}
</script>
{% endblock %}
{% endblock %}

View File

@@ -69,18 +69,18 @@
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-3 gap-2 w-full text-center mb-auto">
<div class="grid grid-cols-3 gap-1 w-full text-center mb-auto">
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
<div class="text-xs text-gray-400">Rating</div>
<div class="font-bold text-yrtv-600 dark:text-yrtv-400" x-text="(player.stats?.core_avg_rating || 0).toFixed(2)"></div>
<div class="text-[10px] text-gray-400">Rating</div>
<div class="font-bold text-yrtv-600 dark:text-yrtv-400 text-sm" x-text="(player.stats?.core_avg_rating2 || player.stats?.core_avg_rating || 0).toFixed(2)"></div>
</div>
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
<div class="text-xs text-gray-400">K/D</div>
<div class="font-bold" x-text="(player.stats?.core_avg_kd || 0).toFixed(2)"></div>
<div class="text-[10px] text-gray-400">K/D</div>
<div class="font-bold text-sm" x-text="(player.stats?.core_avg_kd || 0).toFixed(2)"></div>
</div>
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
<div class="text-xs text-gray-400">总评</div>
<div class="font-bold" x-text="(player.stats?.score_overall || 0).toFixed(1)"></div>
<div class="text-[10px] text-gray-400">OVR</div>
<div class="font-black text-sm text-yrtv-700 dark:text-yrtv-300" x-text="(player.stats?.score_overall || 0).toFixed(0)"></div>
</div>
</div>

View File

@@ -16,7 +16,10 @@
<a href="{{ url_for('players.detail', steam_id=p.steam_id_64) }}" class="text-sm font-medium text-gray-900 dark:text-white hover:text-yrtv-600 truncate w-full text-center">
{{ p.username }}
</a>
<span class="text-xs text-gray-500">Rating: {{ "%.2f"|format(p.rating if p.rating else 0) }}</span>
<div class="flex gap-2 text-xs text-gray-500 mt-1">
<span>R: <span class="font-bold {{ 'text-green-600' if p.rating >= 1.1 else '' }}">{{ "%.2f"|format(p.rating if p.rating else 0) }}</span></span>
<span class="border-l border-gray-300 pl-2">OVR: <span class="font-bold text-yrtv-600">{{ p.stats.get('score_overall', 0)|int }}</span></span>
</div>
</div>
{% endfor %}
</div>