diff --git a/database/L3/L3_Features.sqlite b/database/L3/L3_Features.sqlite index 5018f4c..db91e56 100644 Binary files a/database/L3/L3_Features.sqlite and b/database/L3/L3_Features.sqlite differ diff --git a/web/routes/players.py b/web/routes/players.py index 7f67dd8..0ab9287 100644 --- a/web/routes/players.py +++ b/web/routes/players.py @@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, jsonify, redirect, url_fo from web.services.stats_service import StatsService from web.services.feature_service import FeatureService from web.services.web_service import WebService -from web.database import execute_db +from web.database import execute_db, query_db from web.config import Config from datetime import datetime import os @@ -233,16 +233,139 @@ def api_batch_stats(): p = StatsService.get_player_info(sid) if f and p: + # Convert sqlite3.Row to dict if necessary + if hasattr(f, 'keys'): # It's a Row object or similar + f = dict(f) + + # 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 = { + 'STA': float(f.get('score_sta') or 0.0), + 'BAT': float(f.get('score_bat') or 0.0), + 'HPS': float(f.get('score_hps') or 0.0), + 'PTL': float(f.get('score_ptl') or 0.0), + 'SIDE': float(f.get('score_tct') or 0.0), + 'UTIL': float(f.get('score_util') or 0.0) + } + + # 2. Basic Stats for Table + basic = { + '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) + } + + # 3. Side Stats + side = { + 'rating_t': float(f.get('side_rating_t') or 0), + 'rating_ct': float(f.get('side_rating_ct') or 0), + 'kd_t': float(f.get('side_kd_t') or 0), + 'kd_ct': float(f.get('side_kd_ct') or 0), + 'entry_t': float(f.get('side_entry_rate_t') or 0), + 'entry_ct': float(f.get('side_entry_rate_ct') or 0), + 'kast_t': float(f.get('side_kast_t') or 0), + 'kast_ct': float(f.get('side_kast_ct') or 0), + 'adr_t': float(f.get('side_adr_t') or 0), + 'adr_ct': float(f.get('side_adr_ct') or 0) + } + + # 4. Detailed Stats (Expanded for Data Center - Aligned with Profile) + detailed = { + # Row 1 + 'rating_t': float(f.get('side_rating_t') or 0), + 'rating_ct': float(f.get('side_rating_ct') or 0), + 'kd_t': float(f.get('side_kd_t') or 0), + 'kd_ct': float(f.get('side_kd_ct') or 0), + + # Row 2 + 'win_rate_t': float(f.get('side_win_rate_t') or 0), + 'win_rate_ct': float(f.get('side_win_rate_ct') or 0), + 'first_kill_t': float(f.get('side_first_kill_rate_t') or 0), + '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), + '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), + + # 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) + } + stats.append({ 'username': p['username'], 'steam_id': sid, - 'radar': { - 'STA': f['basic_avg_rating'] or 0, - 'BAT': f['bat_avg_duel_win_rate'] or 0, - 'HPS': f['hps_clutch_win_rate_1v1'] or 0, - 'PTL': f['ptl_pistol_win_rate'] or 0, - 'SIDE': f['side_rating_ct'] or 0, - 'UTIL': f['util_usage_rate'] or 0 - } + 'avatar_url': p['avatar_url'], + 'radar': radar, + 'basic': basic, + 'side': side, + 'detailed': detailed }) return jsonify(stats) + +@bp.route('/api/batch_map_stats') +def api_batch_map_stats(): + steam_ids = request.args.get('ids', '').split(',') + steam_ids = [sid for sid in steam_ids if sid] + + if not steam_ids: + return jsonify({}) + + # Query L2 for Map Stats grouped by Player and Map + # We need to construct a query that can be executed via execute_db or query_db + # Since StatsService usually handles this, we can write raw SQL here or delegate. + # Raw SQL is easier for this specific aggregation. + + placeholders = ','.join('?' for _ in steam_ids) + sql = f""" + SELECT + mp.steam_id_64, + m.map_name, + COUNT(*) as matches, + SUM(CASE WHEN mp.is_win THEN 1 ELSE 0 END) as wins, + AVG(mp.rating) as avg_rating, + AVG(mp.kd_ratio) as avg_kd, + AVG(mp.adr) as avg_adr + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + WHERE mp.steam_id_64 IN ({placeholders}) + GROUP BY mp.steam_id_64, m.map_name + ORDER BY matches DESC + """ + + # We need to import query_db if not available in current scope (it is imported at top) + from web.database import query_db + rows = query_db('l2', sql, steam_ids) + + # Structure: {steam_id: [ {map: 'de_mirage', stats...}, ... ]} + result = {} + for r in rows: + sid = r['steam_id_64'] + if sid not in result: + result[sid] = [] + + result[sid].append({ + 'map_name': r['map_name'], + 'matches': r['matches'], + 'win_rate': (r['wins'] / r['matches']) if r['matches'] else 0, + 'rating': r['avg_rating'], + 'kd': r['avg_kd'], + 'adr': r['avg_adr'] + }) + + return jsonify(result) diff --git a/web/services/feature_service.py b/web/services/feature_service.py index b391f67..b1cf451 100644 --- a/web/services/feature_service.py +++ b/web/services/feature_service.py @@ -776,7 +776,8 @@ class FeatureService: # Survived = Rounds - Deaths if df_sides['kast'].mean() == 0: df_sides['survived'] = df_sides['rounds'] - df_sides['deaths'] - df_sides['kast'] = (df_sides['kills'] + df_sides['assists'] + df_sides['survived']) / df_sides['rounds'] * 100 + df_sides['kast'] = (df_sides['kills'] + df_sides['assists'] + df_sides['survived']) / df_sides['rounds'] + df_sides['fk_rate'] = df_sides['fk'] / df_sides['rounds'] df_sides['fd_rate'] = df_sides['fd'] / df_sides['rounds'] diff --git a/web/templates/tactics/data.html b/web/templates/tactics/data.html index 46880b4..7b68f2b 100644 --- a/web/templates/tactics/data.html +++ b/web/templates/tactics/data.html @@ -1,22 +1,355 @@ -{% extends "tactics/layout.html" %} - -{% block title %}Data Center - Tactics{% endblock %} - -{% block tactics_content %} -
-

Data Center: Comparison

- -
- -
- - + +
+ +
+
+

+ 📊 数据对比中心 (Data Comparison) +

+

拖拽左侧队员至下方区域,或点击搜索添加

- - -
-

Multi-player Radar Chart / Bar Chart Area

+
+
+ + +
+
-
-{% endblock %} \ No newline at end of file + + +
+ + +
+ +
+

+ 对比列表 + 0/5 +

+
+ +
+ + + + +
+
+ + +
+ + +
+ +
+

能力模型对比 (Capability Radar)

+
+ +
+
+ + +
+

基础数据 (Basic Stats)

+
+ + + + + + + + + + + + + + +
PlayerRatingK/DADRKAST
+
+
+
+ + +
+

详细数据对比 (Detailed Stats)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Metric
Rating (Rating/KD)
KD Ratio
Win Rate (胜率)
First Kill Rate (首杀率)
First Death Rate (首死率)
KAST (贡献率)
RWS (Round Win Share)
Multi-Kill Rate (多杀率)
Headshot Rate (爆头率)
Obj (下包 vs 拆包)
+
+
+ + +
+

地图表现 (Map Performance)

+ +
+ + + + + + + + + + + +
Map
+
+
+ +
+
+
\ No newline at end of file diff --git a/web/templates/tactics/index.html b/web/templates/tactics/index.html index 3f06454..24f172a 100644 --- a/web/templates/tactics/index.html +++ b/web/templates/tactics/index.html @@ -248,14 +248,8 @@
- -
-
-
📊
-

数据对比中心 (Construction)

-

此模块正在开发中...

-
-
+ + {% include 'tactics/data.html' %}
@@ -344,6 +338,15 @@ function tacticsApp() { analysisResult: null, debounceTimer: null, + // Data Center State + dataLineup: [], + dataResult: [], + searchQuery: '', + radarChart: null, + allMaps: ['de_mirage', 'de_inferno', 'de_dust2', 'de_nuke', 'de_ancient', 'de_anubis', 'de_vertigo'], + mapStatsCache: {}, + isDraggingOverData: false, + // Board State currentMap: 'de_mirage', map: null, @@ -372,6 +375,11 @@ function tacticsApp() { }, 300); }); + // Watch Data Lineup + this.$watch('dataLineup', () => { + this.comparePlayers(); + }); + // Init map on first board view, or delay this.$watch('activeTab', value => { if (value === 'board') { @@ -397,10 +405,226 @@ function tacticsApp() { // --- Drag & Drop Generic --- dragStart(event, player) { - event.dataTransfer.setData('text/plain', JSON.stringify(player)); + // Only send essential data to avoid circular references with Alpine proxies + const payload = { + steam_id_64: player.steam_id_64, + username: player.username || player.name, + name: player.name || player.username, + avatar_url: player.avatar_url + }; + event.dataTransfer.setData('text/plain', JSON.stringify(payload)); event.dataTransfer.effectAllowed = 'copy'; }, + // --- Data Center Logic --- + searchPlayer() { + if (!this.searchQuery) return; + const q = this.searchQuery.toLowerCase(); + const found = this.roster.find(p => + (p.username && p.username.toLowerCase().includes(q)) || + (p.steam_id_64 && p.steam_id_64.includes(q)) + ); + if (found) { + this.addToDataLineup(found); + this.searchQuery = ''; + } else { + alert('未找到玩家 (Locally)'); + } + }, + + addToDataLineup(player) { + if (this.dataLineup.some(p => p.steam_id_64 === player.steam_id_64)) { + alert('该选手已在对比列表中'); + return; + } + if (this.dataLineup.length >= 5) { + alert('对比列表已满 (最多5人)'); + return; + } + this.dataLineup.push(player); + }, + + removeFromDataLineup(index) { + this.dataLineup.splice(index, 1); + }, + + clearDataLineup() { + this.dataLineup = []; + }, + + dropData(event) { + this.isDraggingOverData = false; + const data = event.dataTransfer.getData('text/plain'); + if (!data) return; + try { + const player = JSON.parse(data); + this.addToDataLineup(player); + } catch (e) { + console.error("Drop Error:", e); + alert("无法解析拖拽数据"); + } + }, + + comparePlayers() { + if (this.dataLineup.length === 0) { + this.dataResult = []; + if (this.radarChart) { + this.radarChart.data.datasets = []; + this.radarChart.update(); + } + return; + } + + const ids = this.dataLineup.map(p => p.steam_id_64).join(','); + + // 1. Fetch Basic & Radar Stats + fetch('/players/api/batch_stats?ids=' + ids) + .then(res => res.json()) + .then(data => { + this.dataResult = data; + // Use $nextTick to ensure DOM update if needed, but for Chart.js usually direct call is fine. + // However, dataResult is reactive. Let's call update explicitly. + this.$nextTick(() => { + this.updateRadarChart(); + }); + }); + + // 2. Fetch Map Stats + fetch('/players/api/batch_map_stats?ids=' + ids) + .then(res => res.json()) + .then(mapData => { + this.mapStatsCache = mapData; + }); + }, + + getMapStat(sid, mapName) { + if (!this.mapStatsCache[sid]) return null; + return this.mapStatsCache[sid].find(m => m.map_name === mapName); + }, + + getPlayerColor(idx) { + const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6']; + return colors[idx % colors.length]; + }, + + getRatingColor(rating) { + if (rating >= 1.2) return 'text-red-500'; + if (rating >= 1.05) return 'text-green-600'; + return 'text-gray-500'; + }, + + updateRadarChart() { + // Force destroy to avoid state issues (fullSize error) + if (this.radarChart) { + this.radarChart.destroy(); + this.radarChart = null; + } + + const canvas = document.getElementById('dataRadarChart'); + if (!canvas) return; // Tab might not be visible yet + + // Unwrap proxy if needed + const rawData = JSON.parse(JSON.stringify(this.dataResult)); + + const datasets = rawData.map((p, idx) => { + const color = this.getPlayerColor(idx); + const d = [ + p.radar.BAT || 0, p.radar.PTL || 0, p.radar.HPS || 0, + p.radar.SIDE || 0, p.radar.UTIL || 0, p.radar.STA || 0 + ]; + + return { + label: p.username, + data: d, + borderColor: color, + backgroundColor: color + '20', + borderWidth: 2, + pointRadius: 3 + }; + }); + + // Recreate Chart with Profile-aligned config + const ctx = canvas.getContext('2d'); + this.radarChart = new Chart(ctx, { + type: 'radar', + data: { + labels: ['BAT (火力)', 'PTL (手枪)', 'HPS (抗压)', 'SIDE (阵营)', 'UTIL (道具)', 'STA (稳定)'], + datasets: datasets + }, + options: { + maintainAspectRatio: false, + scales: { + r: { + min: 0, + max: 100, + ticks: { + display: false, // Cleaner look like profile + stepSize: 20 + }, + pointLabels: { + font: { size: 12, 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)' + }, + angleLines: { + color: (ctx) => document.documentElement.classList.contains('dark') ? 'rgba(51, 65, 85, 0.5)' : 'rgba(229, 231, 235, 0.8)' + } + } + }, + plugins: { + legend: { + position: 'bottom', + labels: { + color: (ctx) => document.documentElement.classList.contains('dark') ? '#fff' : '#000', + usePointStyle: true, + padding: 20 + } + } + } + } + }); + }, + + initRadarChart() { + const canvas = document.getElementById('dataRadarChart'); + if (!canvas) return; // Tab might not be visible yet + + const ctx = canvas.getContext('2d'); + this.radarChart = new Chart(ctx, { + type: 'radar', + data: { + labels: ['BAT (火力)', 'PTL (手枪)', 'HPS (抗压)', 'SIDE (阵营)', 'UTIL (道具)', 'STA (稳定)'], + datasets: [] + }, + options: { + scales: { + r: { + min: 0, + max: 100, + ticks: { display: false, stepSize: 20 }, + pointLabels: { + font: { size: 12, weight: 'bold' }, + color: (ctx) => document.documentElement.classList.contains('dark') ? '#cbd5e1' : '#374151' + }, + grid: { + color: (ctx) => document.documentElement.classList.contains('dark') ? '#334155' : '#e5e7eb' + } + } + }, + plugins: { + legend: { + labels: { + color: (ctx) => document.documentElement.classList.contains('dark') ? '#fff' : '#000' + } + } + }, + maintainAspectRatio: false + } + }); + }, + // --- Analysis Logic --- dropAnalysis(event) { const data = event.dataTransfer.getData('text/plain');