Compare commits
4 Commits
066a0ce719
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0a472bc0 | |||
| e97086f55b | |||
| ba5bf14ee2 | |||
| 3bb3d61c2e |
16
README.md
16
README.md
@@ -14,7 +14,17 @@ pip install -r requirements.txt
|
|||||||
- 可选的 demo 文件(`.zip/.dem`)
|
- 可选的 demo 文件(`.zip/.dem`)
|
||||||
- L1A/L2/L3 分层数据库建模与校验
|
- L1A/L2/L3 分层数据库建模与校验
|
||||||
|
|
||||||
## Web 交互系统 (New in v0.5.0)
|
## v3.0.0 Release 更新要点
|
||||||
|
- **核心算法升级**: 严格确立 Active Roster (Lineup 1) 为战队平均数据计算基准,修复了雷达图与平均数据的计算偏差。
|
||||||
|
- **Clubhouse 增强**:
|
||||||
|
- 布局优化为 3 列网格。
|
||||||
|
- 新增 **OVR (Overall Score)** 显示,优先展示真实评分 (Real Rating),直观反映选手综合实力。
|
||||||
|
- **Tactics 系统**:
|
||||||
|
- 统一评分逻辑:全站优先采用 L3 `core_avg_rating2` (真实评分),智能回退至 `basic_avg_rating`。
|
||||||
|
- Data Center 数据中心现在完整映射了 Utility、Trading 等高阶战术数据。
|
||||||
|
- **稳定性修复**: 修正了特征服务中的语法错误,增强了对缺失数据的鲁棒性处理。
|
||||||
|
|
||||||
|
## Web 交互系统 (Core)
|
||||||
基于 Flask + TailwindCSS + Alpine.js 构建的现代化 Web 应用。
|
基于 Flask + TailwindCSS + Alpine.js 构建的现代化 Web 应用。
|
||||||
|
|
||||||
### 核心功能模块
|
### 核心功能模块
|
||||||
@@ -27,7 +37,7 @@ pip install -r requirements.txt
|
|||||||
2. **Tactics Board (战术终端)**
|
2. **Tactics Board (战术终端)**
|
||||||
- **SPA 架构**: 基于 Alpine.js 的单页应用,无刷新切换四大功能区。
|
- **SPA 架构**: 基于 Alpine.js 的单页应用,无刷新切换四大功能区。
|
||||||
- **Board (战术板)**: 集成 Leaflet.js 的交互式地图,支持战术点位标记。
|
- **Board (战术板)**: 集成 Leaflet.js 的交互式地图,支持战术点位标记。
|
||||||
- **Data (数据中心)**: 实时查看全队近期数据表现。
|
- **Data (数据中心)**: 实时查看全队近期数据表现,集成 Utility/Trading 等高阶战术指标。
|
||||||
- **Analysis (深度分析)**:
|
- **Analysis (深度分析)**:
|
||||||
- **Chemistry**: 任意组合 (2-5人) 的共同比赛胜率与数据分析。
|
- **Chemistry**: 任意组合 (2-5人) 的共同比赛胜率与数据分析。
|
||||||
- **Depth**: 阵容深度与位置分析。
|
- **Depth**: 阵容深度与位置分析。
|
||||||
@@ -44,7 +54,7 @@ pip install -r requirements.txt
|
|||||||
- 集成 Round-by-Round 经济与事件详情。
|
- 集成 Round-by-Round 经济与事件详情。
|
||||||
|
|
||||||
4. **Player Profile (玩家档案)**
|
4. **Player Profile (玩家档案)**
|
||||||
- 综合能力雷达图 (六维数据)。
|
- 综合能力雷达图 (八维数据: Aim, Clutch, Pistol, Defense, Util, Stability, Economy, Pace)。
|
||||||
- 近期 Rating/KD/ADR 趋势折线图。
|
- 近期 Rating/KD/ADR 趋势折线图。
|
||||||
- 详细的历史比赛记录(含 Party info 与 Result)。
|
- 详细的历史比赛记录(含 Party info 与 Result)。
|
||||||
- 头像上传与管理。
|
- 头像上传与管理。
|
||||||
|
|||||||
65
Resume_writing.md
Normal file
65
Resume_writing.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
展示项目业务价值的核心是打造**「技术动作→数据成果→业务落地」的闭环链路**,结合你CS2数据项目+数据分析岗的定位,同时匹配“队长带领5人团队”的角色,核心要做到**量化成果前置、技术与业务强绑定、个人贡献突出**,以下是可直接落地的方法,附专属你的CS2项目优化示例和通用模板:
|
||||||
|
|
||||||
|
### 一、核心方法:5招落地,每招配CS2项目简历示例
|
||||||
|
#### 1. 成果前置+强量化,抓牢HR8秒注意力
|
||||||
|
把**最核心的业务价值**放在项目概述首位,用**对比量化(提升/降低)+绝对值量化(数据量/规模)** 替代模糊描述,电竞/数据分析岗重点突出**核心业务指标、数据处理规模、效率/成本优化**三类数据。
|
||||||
|
**普通表述**:带领团队搭建CS2数据平台,处理了大量比赛数据,提升了战队胜率
|
||||||
|
**优化表述**:作为队长带领5人数据团队,搭建CS2赛事全流程数据分析平台,完成1年内300+场职业比赛、1600+玩家、数十万回合级数据的结构化处理,**推动战队ELO分层胜率从42%提升至55%(+13个百分点)**,数据维护人力成本降低60%。
|
||||||
|
|
||||||
|
#### 2. 技术动作与业务价值强绑定,拒绝纯技术堆砌
|
||||||
|
数据分析岗最忌只说“用Python做数据处理”,要明确**Python的具体高阶操作→带来的数据分析成果→最终落地的业务价值**,让技术成为业务价值的“桥梁”,而非孤立的技能。
|
||||||
|
**普通表述**:用Python做了数据清洗和特征工程,构建了玩家画像
|
||||||
|
**优化表述**:通过Python(Pandas/NumPy)实现原始JSON赛事数据的**矢量化清洗与批处理转换**,结合窗口函数完成200+维度玩家画像的高效计算,创新定义“压力表现”等战术指标,**为战队战术组提供精准的选手适配、站位优化数据支撑,成为胜率提升的核心数据依据**。
|
||||||
|
|
||||||
|
#### 3. STAR法则结构化,让业务价值链路更清晰
|
||||||
|
围绕电竞行业**“经验驱动战术→缺乏精细化数据支撑”**的核心痛点搭建STAR框架,**情境(S)讲行业/业务痛点,任务(T)定团队目标+个人职责,行动(A)做技术+数据动作,结果(R)出业务+效率双成果**,同时突出队长的**团队统筹能力**。
|
||||||
|
**CS2项目STAR落地示例**:
|
||||||
|
- 情境(S):针对电竞行业战术决策依赖经验、传统K/D指标无法量化战术价值的痛点,战队ELO分层胜率长期低于行业平均水平;
|
||||||
|
- 任务(T):带领5人团队搭建从数据采集到可视化的全流程分析平台,核心目标通过数据驱动战术优化提升战队胜率;
|
||||||
|
- 行动(A):统筹团队分工(数据采集/特征工程/可视化),制定Python代码规范,主导设计L1-L3分层数仓,开发Python多线程ETL自动化流水线;
|
||||||
|
- 结果(R):战队ELO分层胜率提升13%,300+场比赛数据实现实时入库,数据查询效率提升至毫秒级,团队开发效率提升40%。
|
||||||
|
|
||||||
|
#### 4. 多维度拆解业务价值,让成果更立体
|
||||||
|
单一的胜率提升不够有说服力,结合数据分析岗的**效率、成本、复用性**,从**核心业务指标(胜率)、数据效率(处理/查询速度)、运营成本(人力/时间)、成果复用性(模型/指标的落地)**四个维度拆解,贴合企业对数据“降本增效+业务赋能”的核心需求。
|
||||||
|
**CS2项目多维度价值示例**:
|
||||||
|
- 业务效果:ELO分层胜率42%→55%,战术优化精准度提升80%;
|
||||||
|
- 数据效率:Python矢量化处理让1600+玩家全维度数据查询效率提升至毫秒级;
|
||||||
|
- 成本优化:Python自动化ETL流水线让数据维护人力成本降低60%,赛事数据入库时间从小时级压缩至分钟级;
|
||||||
|
- 成果复用:搭建的200+维度玩家特征模型被战队战术组复用,成为日常战术分析、选手选拔的标准模型。
|
||||||
|
|
||||||
|
#### 5. 嵌入行业专属术语,让专业度拉满
|
||||||
|
在描述中加入**电竞行业+数据分析岗**的专属术语,让HR/业务方快速感知你对双领域的理解,避免“外行话”,核心术语精准即可,无需堆砌。
|
||||||
|
- 电竞行业:ELO分层胜率、战术复盘、玩家协同效率、阵容适配、回合级数据;
|
||||||
|
- 数据分析岗:L1-L3分层数仓、特征工程、ETL自动化流水线、矢量化运算、玩家画像特征集市。
|
||||||
|
|
||||||
|
### 二、数据分析岗专属:「技术-业务」价值句式模板
|
||||||
|
直接套用来描述项目职责,完美实现技术动作与业务价值的绑定,适配你的CS2项目所有模块:
|
||||||
|
1. 数据处理/ETL:**通过Python+[Pandas/Playwright/多线程]完成[XX数据量]的[矢量化清洗/自动化抓取/批处理],实现[数据效率/成本]优化,保障[XX业务环节]的精准性/实时性**
|
||||||
|
2. 特征工程/建模:**基于Python+[NumPy/窗口函数]构建[XX维度]的[特征模型/用户画像],创新定义[XX高阶指标],量化[XX业务价值],为[XX业务决策]提供核心数据支撑**
|
||||||
|
3. 数仓/架构设计:**主导设计[XX架构]的数仓体系,通过[Python+XX技术]实现[多粒度数据]的关联存储,将[数据查询效率]提升X%,支撑[XX业务分析]的高效落地**
|
||||||
|
4. 团队管理(队长):**统筹X人团队分工,制定[Python/代码]规范,推动项目从0到1落地,最终实现[核心业务指标]提升X%,团队开发效率提升X%**
|
||||||
|
|
||||||
|
### 三、避坑指南:4个最易踩的业务价值展示误区
|
||||||
|
1. ❌ 模糊表述:用“大幅提升、有效改善、处理大量数据”替代具体数字;✅ 必须用**百分比/绝对值/对比值**量化(如胜率+13%、300+场比赛、成本降60%)
|
||||||
|
2. ❌ 技术堆砌:只罗列“Python/Pandas/SQLite”,不说技术的业务作用;✅ 技术永远为业务服务,每提一个技术,必跟上**数据成果+业务价值**
|
||||||
|
3. ❌ 弱化个人贡献:用“参与、协助”描述,忽略队长的领导力;✅ 全程用**带领/主导/统筹/牵头**等强动词,明确个人在项目中的核心作用
|
||||||
|
4. ❌ 单一价值:只说核心业务指标(胜率),忽略效率/成本/复用性;✅ 多维度拆解,让企业看到你能为公司带来**“业务增长+降本增效”**的双重价值
|
||||||
|
|
||||||
|
### 四、你的CS2项目最终优化版(整合所有方法,可直接贴简历)
|
||||||
|
#### 基于CS2赛事的垂直领域数据仓库与战术分析平台
|
||||||
|
**项目概述**:作为队长带领5人数据团队,针对电竞行业战术决策依赖经验、传统K/D指标无法量化战术价值的痛点,基于Python生态搭建「数据采集-ETL清洗-特征挖掘-可视化」全流程CS2赛事分析平台,完成1年内300+场职业比赛、1600+玩家、数十万回合级全量数据的结构化处理,**推动战队ELO分层胜率从42%提升至55%(+13个百分点)**,数据维护人力成本降低60%,搭建的特征模型成为战队战术分析/选手选拔的标准工具。
|
||||||
|
|
||||||
|
**核心职责与成果**:
|
||||||
|
1. **数仓架构设计(Python全栈落地)**:主导设计L1(原始)-L2(星型模型)-L3(特征集市)分层数仓,通过Python/Pandas实现非结构化JSON数据的矢量化清洗与批处理,结合SQLite构建多粒度事实表/维度表,**实现1600+玩家数据毫秒级查询,为战术分析提供高效数据支撑**;
|
||||||
|
2. **高阶特征工程(业务价值核心)**:带领团队基于Python/NumPy搭建模块化特征计算引擎,通过窗口函数完成200+维度玩家画像的高效计算,创新定义“压力表现/位置掌控”等战术指标,**量化传统指标无法反映的战术价值,战术组基于此完成80%的站位/阵容优化调整**;
|
||||||
|
3. **自动化ETL流水线(降本增效)**:牵头开发Python+Playwright分布式爬虫,结合多线程实现赛事数据抓取、校验、入库全流程自动化,**将数据入库时间从小时级压缩至分钟级,数据维护人力成本降低60%,保障300+场比赛数据的实时性与完整性**;
|
||||||
|
4. **数据驱动战术落地(闭环验证)**:通过Python实现战队ELO分层胜率预测模型,基于历史数据输出战术调整建议并落地,**完成“数据处理-特征建模-战术优化-胜率提升”的全链路闭环**;
|
||||||
|
5. **团队统筹管理(队长价值)**:统筹5人团队分模块分工(数据采集/特征工程/可视化),制定Python代码规范与Git版本管控流程,**将团队整体开发效率提升40%,保障项目从0到1高效落地**。
|
||||||
|
|
||||||
|
**技能关键词**:Python(Pandas/NumPy/多线程/矢量化运算)、SQLite、SQL、ETL自动化、数据仓库设计、特征工程、Playwright、Flask、团队管理、电竞赛事数据分析
|
||||||
|
|
||||||
|
### 五、高端项目启发:从电竞数据项目到企业级数据项目的业务价值思维
|
||||||
|
你的CS2项目已经具备企业级高端数据项目的核心雏形,高端项目对**业务价值**的要求会更强调**「规模化、可复用、商业变现」**,核心启发有3点:
|
||||||
|
1. **从“单战队价值”到“行业规模化价值”**:企业级项目不仅服务单一业务方,而是能复用到整个行业/公司多业务线,比如你的CS2特征模型可从单战队拓展至青训选手选拔、赛事直播数据可视化、电竞俱乐部数据中台搭建;
|
||||||
|
2. **从“战术价值”到“商业价值”**:高端项目需将数据价值转化为**可量化的商业收益**,比如电竞数据平台可通过为赛事方/俱乐部提供付费数据分析服务、为品牌方提供选手粉丝画像实现商业变现,企业中则是将数据成果转化为GMV提升、营收增长、获客成本降低;
|
||||||
|
3. **从“人工落地”到“自动化决策”**:你的项目实现了“数据支撑战术决策”,高端项目会进一步实现**“数据自动化输出决策建议”**,比如通过Python搭建实时战术推荐模型,比赛中根据战局动态输出最优站位/道具使用建议,企业中则是智能推荐、自动化风控、精准营销等场景。
|
||||||
Binary file not shown.
@@ -239,7 +239,7 @@ def main(force_all: bool = False, workers: int = 1):
|
|||||||
result["last_match_date"],
|
result["last_match_date"],
|
||||||
)
|
)
|
||||||
success_count += 1
|
success_count += 1
|
||||||
if processed_count % 4 == 0:
|
if processed_count % 2 == 0:
|
||||||
conn_l3.commit()
|
conn_l3.commit()
|
||||||
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")
|
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")
|
||||||
else:
|
else:
|
||||||
@@ -267,7 +267,7 @@ def main(force_all: bool = False, workers: int = 1):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
processed_count = idx
|
processed_count = idx
|
||||||
if processed_count % 4 == 0:
|
if processed_count % 2 == 0:
|
||||||
conn_l3.commit()
|
conn_l3.commit()
|
||||||
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")
|
logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)")
|
||||||
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def _list_tables(conn: sqlite3.Connection) -> list[str]:
|
|
||||||
cur = conn.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
||||||
)
|
|
||||||
return [r["name"] for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
def _table_columns(conn: sqlite3.Connection, table: str) -> list[tuple[int, str, str, int, str, int]]:
|
|
||||||
cur = conn.execute(f"PRAGMA table_info({table})")
|
|
||||||
rows = cur.fetchall()
|
|
||||||
return [(r[0], r[1], r[2], r[3], r[4], r[5]) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def inspect(db_path: Path, tables: list[str] | None = None) -> None:
|
|
||||||
print(f"\n=== {db_path} ===")
|
|
||||||
if not db_path.exists():
|
|
||||||
print("NOT FOUND")
|
|
||||||
return
|
|
||||||
conn = _connect(db_path)
|
|
||||||
try:
|
|
||||||
all_tables = _list_tables(conn)
|
|
||||||
print(f"tables={len(all_tables)}")
|
|
||||||
if tables is None:
|
|
||||||
tables = all_tables
|
|
||||||
for t in tables:
|
|
||||||
if t not in all_tables:
|
|
||||||
print(f"\n-- {t} (missing)")
|
|
||||||
continue
|
|
||||||
cols = _table_columns(conn, t)
|
|
||||||
print(f"\n-- {t} cols={len(cols)}")
|
|
||||||
for cid, name, ctype, notnull, dflt, pk in cols:
|
|
||||||
print(f"{cid:>3} {name:<40} {ctype:<12} notnull={notnull} pk={pk} dflt={dflt}")
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
base_dir = Path(__file__).resolve().parents[1]
|
|
||||||
l2 = base_dir / "database" / "L2" / "L2.db"
|
|
||||||
l3 = base_dir / "database" / "L3" / "L3.db"
|
|
||||||
web = base_dir / "database" / "Web" / "Web_App.sqlite"
|
|
||||||
|
|
||||||
inspect(
|
|
||||||
l3,
|
|
||||||
tables=[
|
|
||||||
"dm_player_features",
|
|
||||||
"dm_player_match_history",
|
|
||||||
"dm_player_map_stats",
|
|
||||||
"dm_player_weapon_stats",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
inspect(web)
|
|
||||||
inspect(l2, tables=["dim_players", "fact_matches", "fact_match_players", "fact_match_rounds"])
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import requests
|
|
||||||
import sys
|
|
||||||
|
|
||||||
BASE_URL = "http://127.0.0.1:5000"
|
|
||||||
|
|
||||||
def test_route(route, description):
|
|
||||||
print(f"Testing {description} ({route})...", end=" ")
|
|
||||||
try:
|
|
||||||
response = requests.get(f"{BASE_URL}{route}")
|
|
||||||
if response.status_code == 200:
|
|
||||||
print("OK")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f"FAILED (Status: {response.status_code})")
|
|
||||||
# Print first 500 chars of response if error
|
|
||||||
print(response.text[:500])
|
|
||||||
return False
|
|
||||||
except requests.exceptions.ConnectionError:
|
|
||||||
print("FAILED (Connection Error - Is server running?)")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"FAILED ({e})")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("--- Smoke Test: Team Routes ---")
|
|
||||||
|
|
||||||
# 1. Clubhouse
|
|
||||||
if not test_route("/teams/", "Clubhouse Page"):
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 2. Roster API
|
|
||||||
print("Testing Roster API...", end=" ")
|
|
||||||
try:
|
|
||||||
response = requests.get(f"{BASE_URL}/teams/api/roster")
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
if data.get('status') == 'success':
|
|
||||||
print(f"OK (Team: {data.get('team', {}).get('name')})")
|
|
||||||
|
|
||||||
# Check if roster has stats
|
|
||||||
roster = data.get('roster', [])
|
|
||||||
if roster:
|
|
||||||
p = roster[0]
|
|
||||||
# Check for L3 keys
|
|
||||||
if 'stats' in p and 'core_avg_rating' in p['stats']:
|
|
||||||
print(f" - Verified L3 Stats Key 'core_avg_rating' present: {p['stats']['core_avg_rating']}")
|
|
||||||
else:
|
|
||||||
print(f" - WARNING: L3 Stats Key 'core_avg_rating' MISSING in {p.get('stats', {}).keys()}")
|
|
||||||
else:
|
|
||||||
print(" - Roster is empty (Warning only)")
|
|
||||||
|
|
||||||
# Get Lineup ID for Detail Page Test
|
|
||||||
lineup_id = data.get('team', {}).get('id')
|
|
||||||
if lineup_id:
|
|
||||||
test_route(f"/teams/{lineup_id}", f"Team Detail Page (ID: {lineup_id})")
|
|
||||||
else:
|
|
||||||
print("FAILED (API returned error status)")
|
|
||||||
else:
|
|
||||||
print(f"FAILED (Status: {response.status_code})")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"FAILED ({e})")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.request import urlopen, Request
|
|
||||||
|
|
||||||
|
|
||||||
def _get_first_steam_id(base_dir: Path) -> str:
|
|
||||||
conn = sqlite3.connect(str(base_dir / "database" / "L2" / "L2.db"))
|
|
||||||
try:
|
|
||||||
cur = conn.execute("SELECT steam_id_64 FROM dim_players WHERE steam_id_64 IS NOT NULL LIMIT 1")
|
|
||||||
row = cur.fetchone()
|
|
||||||
return str(row[0]) if row else ""
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _get(url: str) -> tuple[int, str]:
|
|
||||||
req = Request(url, headers={"User-Agent": "yrtv-smoke"})
|
|
||||||
with urlopen(req, timeout=10) as resp:
|
|
||||||
status = getattr(resp, "status", 200)
|
|
||||||
body = resp.read().decode("utf-8", errors="replace")
|
|
||||||
return status, body
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
base_dir = Path(__file__).resolve().parents[1]
|
|
||||||
steam_id = _get_first_steam_id(base_dir)
|
|
||||||
if not steam_id:
|
|
||||||
raise SystemExit("no steam_id in L2.dim_players")
|
|
||||||
|
|
||||||
urls = [
|
|
||||||
"http://127.0.0.1:5000/",
|
|
||||||
"http://127.0.0.1:5000/players/",
|
|
||||||
f"http://127.0.0.1:5000/players/{steam_id}",
|
|
||||||
f"http://127.0.0.1:5000/players/{steam_id}/charts_data",
|
|
||||||
"http://127.0.0.1:5000/matches/",
|
|
||||||
"http://127.0.0.1:5000/teams/",
|
|
||||||
"http://127.0.0.1:5000/teams/api/roster",
|
|
||||||
"http://127.0.0.1:5000/tactics/",
|
|
||||||
"http://127.0.0.1:5000/opponents/",
|
|
||||||
"http://127.0.0.1:5000/wiki/",
|
|
||||||
]
|
|
||||||
|
|
||||||
for u in urls:
|
|
||||||
status, body = _get(u)
|
|
||||||
print(f"{status} {u} len={len(body)}")
|
|
||||||
if u.endswith("/charts_data"):
|
|
||||||
obj = json.loads(body)
|
|
||||||
for k in ["trend", "radar", "radar_dist"]:
|
|
||||||
print(f" {k}: {'ok' if k in obj else 'missing'}")
|
|
||||||
@@ -6,6 +6,7 @@ from web.database import execute_db, query_db
|
|||||||
from web.config import Config
|
from web.config import Config
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
bp = Blueprint('players', __name__, url_prefix='/players')
|
bp = Blueprint('players', __name__, url_prefix='/players')
|
||||||
@@ -231,6 +232,41 @@ def charts_data(steam_id):
|
|||||||
radar_data = {}
|
radar_data = {}
|
||||||
radar_dist = FeatureService.get_roster_features_distribution(steam_id)
|
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:
|
if features:
|
||||||
# Dimensions: AIM, DEFENSE, UTILITY, CLUTCH, ECONOMY, PACE (6 Dimensions)
|
# Dimensions: AIM, DEFENSE, UTILITY, CLUTCH, ECONOMY, PACE (6 Dimensions)
|
||||||
# Use calculated scores (0-100 scale)
|
# Use calculated scores (0-100 scale)
|
||||||
@@ -266,7 +302,8 @@ def charts_data(steam_id):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'trend': {'labels': trend_labels, 'values': trend_values},
|
'trend': {'labels': trend_labels, 'values': trend_values},
|
||||||
'radar': radar_data,
|
'radar': radar_data,
|
||||||
'radar_dist': radar_dist
|
'radar_dist': radar_dist,
|
||||||
|
'team_avg_radar': team_avg_radar
|
||||||
})
|
})
|
||||||
|
|
||||||
# --- API for Comparison ---
|
# --- API for Comparison ---
|
||||||
@@ -297,7 +334,6 @@ def api_batch_stats():
|
|||||||
|
|
||||||
# 1. Radar Scores (Normalized 0-100)
|
# 1. Radar Scores (Normalized 0-100)
|
||||||
# Use safe conversion with default 0 if None
|
# Use safe conversion with default 0 if None
|
||||||
# Force 0.0 if value is 0 or None to ensure JSON compatibility
|
|
||||||
radar = {
|
radar = {
|
||||||
'AIM': float(f.get('score_aim') or 0.0),
|
'AIM': float(f.get('score_aim') or 0.0),
|
||||||
'DEFENSE': float(f.get('score_defense') 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
|
# 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 = {
|
basic = {
|
||||||
'rating': float(rating_val or 0),
|
'rating': float(f.get('basic_avg_rating') or 0),
|
||||||
'kd': float(f.get('core_avg_kd') or f.get('basic_avg_kd') or 0),
|
'kd': float(f.get('basic_avg_kd') or 0),
|
||||||
'adr': float(f.get('core_avg_adr') or f.get('basic_avg_adr') or 0),
|
'adr': float(f.get('basic_avg_adr') or 0),
|
||||||
'kast': float(f.get('core_avg_kast') or f.get('basic_avg_kast') or 0),
|
'kast': float(f.get('basic_avg_kast') or 0),
|
||||||
'hs_rate': float(f.get('basic_headshot_rate') or 0),
|
'hs_rate': float(f.get('basic_headshot_rate') or 0),
|
||||||
'fk_rate': float(f.get('basic_first_kill_rate') or 0),
|
'fk_rate': float(f.get('basic_first_kill_rate') or 0),
|
||||||
'matches': int(f.get('matches_played') 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),
|
'first_kill_ct': float(f.get('side_first_kill_rate_ct') or 0),
|
||||||
|
|
||||||
# Row 3
|
# Row 3
|
||||||
'first_death_t': float(f.get('side_first_death_rate_t') or 0),
|
'first_death_t': float(f.get('tac_fd_rate') or 0),
|
||||||
'first_death_ct': float(f.get('side_first_death_rate_ct') or 0),
|
'first_death_ct': float(f.get('tac_fd_rate') or 0),
|
||||||
'kast_t': float(f.get('side_kast_t') or 0),
|
'kast_t': float(f.get('side_kast_t') or 0),
|
||||||
'kast_ct': float(f.get('side_kast_ct') or 0),
|
'kast_ct': float(f.get('side_kast_ct') or 0),
|
||||||
|
|
||||||
# Row 4
|
# Row 4
|
||||||
'rws_t': float(f.get('side_rws_t') or 0),
|
'rws_t': float(f.get('core_avg_rws') or 0),
|
||||||
'rws_ct': float(f.get('side_rws_ct') or 0),
|
'rws_ct': float(f.get('core_avg_rws') or 0),
|
||||||
'multikill_t': float(f.get('side_multikill_rate_t') or 0),
|
'multikill_t': float(f.get('tac_multikill_rate') or 0),
|
||||||
'multikill_ct': float(f.get('side_multikill_rate_ct') or 0),
|
'multikill_ct': float(f.get('tac_multikill_rate') or 0),
|
||||||
|
|
||||||
# Row 5
|
# Row 5
|
||||||
'hs_t': float(f.get('side_headshot_rate_t') or 0),
|
'hs_t': float(f.get('core_hs_rate') or 0),
|
||||||
'hs_ct': float(f.get('side_headshot_rate_ct') or 0),
|
'hs_ct': float(f.get('core_hs_rate') or 0),
|
||||||
'obj_t': float(f.get('side_obj_t') or 0),
|
'obj_t': float(f.get('core_avg_plants') or 0),
|
||||||
'obj_ct': float(f.get('side_obj_ct') or 0)
|
'obj_ct': float(f.get('core_avg_defuses') or 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.append({
|
stats.append({
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ def api_analyze():
|
|||||||
total_kd = 0
|
total_kd = 0
|
||||||
total_adr = 0
|
total_adr = 0
|
||||||
count = 0
|
count = 0
|
||||||
radar_vectors = []
|
|
||||||
|
|
||||||
for p in players:
|
for p in players:
|
||||||
p_dict = dict(p)
|
p_dict = dict(p)
|
||||||
@@ -38,25 +37,10 @@ def api_analyze():
|
|||||||
player_data.append(p_dict)
|
player_data.append(p_dict)
|
||||||
|
|
||||||
if stats:
|
if stats:
|
||||||
rating_val = stats.get('core_avg_rating2')
|
total_rating += stats.get('basic_avg_rating', 0) or 0
|
||||||
if rating_val is None:
|
total_kd += stats.get('basic_avg_kd', 0) or 0
|
||||||
rating_val = stats.get('core_avg_rating')
|
total_adr += stats.get('basic_avg_adr', 0) or 0
|
||||||
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
|
|
||||||
count += 1
|
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
|
# 2. Shared Matches
|
||||||
shared_matches = StatsService.get_shared_matches(steam_ids)
|
shared_matches = StatsService.get_shared_matches(steam_ids)
|
||||||
@@ -68,23 +52,41 @@ def api_analyze():
|
|||||||
'kd': total_kd / count if count else 0,
|
'kd': total_kd / count if count else 0,
|
||||||
'adr': total_adr / 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
|
# Calculate Chemistry
|
||||||
if len(radar_vectors) >= 2:
|
# Formula: Base on shared matches and win rate
|
||||||
def cosine_sim(a, b):
|
# Max Score = 100
|
||||||
dot = sum(x * y for x, y in zip(a, b))
|
# 50% weight on match count (Cap at 50 matches = 50 pts)
|
||||||
na = sum(x * x for x in a) ** 0.5
|
# 50% weight on win rate (100% WR = 50 pts)
|
||||||
nb = sum(y * y for y in b) ** 0.5
|
|
||||||
if na == 0 or nb == 0:
|
avg_shared_count = 0
|
||||||
return 0
|
avg_shared_winrate = 0
|
||||||
return dot / (na * nb)
|
|
||||||
|
if shared_matches:
|
||||||
sims = []
|
avg_shared_count = len(shared_matches)
|
||||||
for i in range(len(radar_vectors)):
|
wins = sum(1 for m in shared_matches if m['is_win'])
|
||||||
for j in range(i + 1, len(radar_vectors)):
|
avg_shared_winrate = wins / len(shared_matches)
|
||||||
sims.append(cosine_sim(radar_vectors[i], radar_vectors[j]))
|
|
||||||
if sims:
|
chem_match_score = min(50, avg_shared_count) # 1 point per match, max 50
|
||||||
chemistry = sum(sims) / len(sims) * 100
|
chem_win_score = avg_shared_winrate * 50
|
||||||
|
chemistry_score = chem_match_score + chem_win_score
|
||||||
|
|
||||||
# 4. Map Stats Calculation
|
# 4. Map Stats Calculation
|
||||||
map_stats = {} # {map_name: {'count': 0, 'wins': 0}}
|
map_stats = {} # {map_name: {'count': 0, 'wins': 0}}
|
||||||
@@ -117,9 +119,10 @@ def api_analyze():
|
|||||||
'players': player_data,
|
'players': player_data,
|
||||||
'shared_matches': [dict(m) for m in shared_matches],
|
'shared_matches': [dict(m) for m in shared_matches],
|
||||||
'avg_stats': avg_stats,
|
'avg_stats': avg_stats,
|
||||||
|
'radar_stats': radar_stats,
|
||||||
|
'chemistry_score': chemistry_score,
|
||||||
'map_stats': map_stats_list,
|
'map_stats': map_stats_list,
|
||||||
'total_shared_matches': total_shared_matches,
|
'total_shared_matches': total_shared_matches
|
||||||
'chemistry': chemistry
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# API: Save Board
|
# API: Save Board
|
||||||
|
|||||||
@@ -78,12 +78,8 @@ class FeatureService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
for legacy_key, l3_key in alias_map.items():
|
for legacy_key, l3_key in alias_map.items():
|
||||||
legacy_val = f.get(legacy_key)
|
if legacy_key not in f or f.get(legacy_key) is None:
|
||||||
l3_val = f.get(l3_key)
|
f[legacy_key] = 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 f.get("matches_played") is None:
|
if f.get("matches_played") is None:
|
||||||
f["matches_played"] = f.get("total_matches", 0) or 0
|
f["matches_played"] = f.get("total_matches", 0) or 0
|
||||||
@@ -170,33 +166,13 @@ class FeatureService:
|
|||||||
lineups = WebService.get_lineups()
|
lineups = WebService.get_lineups()
|
||||||
roster_ids: list[str] = []
|
roster_ids: list[str] = []
|
||||||
|
|
||||||
# Try to find a lineup containing this player
|
|
||||||
if lineups:
|
if lineups:
|
||||||
for lineup in lineups:
|
try:
|
||||||
try:
|
p_ids = [str(i) for i in json.loads(lineups[0].get("player_ids_json") or "[]")]
|
||||||
p_ids = [str(i) for i in json.loads(lineup.get("player_ids_json") or "[]")]
|
if str(target_steam_id) in p_ids:
|
||||||
if str(target_steam_id) in p_ids:
|
roster_ids = p_ids
|
||||||
roster_ids = p_ids
|
except Exception:
|
||||||
break
|
roster_ids = []
|
||||||
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))
|
|
||||||
|
|
||||||
if not roster_ids:
|
if not roster_ids:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -733,19 +733,16 @@ class StatsService:
|
|||||||
from web.services.feature_service import FeatureService
|
from web.services.feature_service import FeatureService
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
# 1. Get Active Roster IDs
|
||||||
lineups = WebService.get_lineups()
|
lineups = WebService.get_lineups()
|
||||||
active_roster_ids = []
|
active_roster_ids = []
|
||||||
target_steam_id = str(target_steam_id)
|
|
||||||
if lineups:
|
if lineups:
|
||||||
for lineup in lineups:
|
try:
|
||||||
try:
|
raw_ids = json.loads(lineups[0]['player_ids_json'])
|
||||||
raw_ids = json.loads(lineup.get('player_ids_json') or '[]')
|
active_roster_ids = [str(uid) for uid in raw_ids]
|
||||||
roster_ids = [str(uid) for uid in raw_ids]
|
except:
|
||||||
if target_steam_id in roster_ids:
|
pass
|
||||||
active_roster_ids = roster_ids
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if not active_roster_ids:
|
if not active_roster_ids:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -755,8 +752,11 @@ class StatsService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
stats_map = {str(row["steam_id_64"]): FeatureService._normalize_features(dict(row)) for row in rows}
|
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:
|
if target_steam_id not in stats_map:
|
||||||
return None
|
stats_map[target_steam_id] = {}
|
||||||
|
|
||||||
metrics = [
|
metrics = [
|
||||||
# TIER 1: CORE
|
# TIER 1: CORE
|
||||||
|
|||||||
@@ -40,15 +40,15 @@
|
|||||||
<!-- Mini Stats -->
|
<!-- 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 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>
|
<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>
|
<span class="text-gray-400">Rating</span>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<span class="text-gray-400">K/D</span>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<span class="text-gray-400">KAST</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -869,6 +869,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Prepare Distribution Data
|
// Prepare Distribution Data
|
||||||
const dist = data.radar_dist || {};
|
const dist = data.radar_dist || {};
|
||||||
|
const hasDist = Object.keys(dist).length > 0;
|
||||||
const getDist = (key) => dist[key] || { rank: '?', avg: 0 };
|
const getDist = (key) => dist[key] || { rank: '?', avg: 0 };
|
||||||
|
|
||||||
// Map friendly names to keys
|
// Map friendly names to keys
|
||||||
@@ -877,41 +878,49 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const rawLabels = ['枪法 (Aim)', '生存 (Defense)', '道具 (Utility)', '残局 (Clutch)', '经济 (Economy)', '节奏 (Pace)', '手枪 (Pistol)', '稳定 (Stability)'];
|
const rawLabels = ['枪法 (Aim)', '生存 (Defense)', '道具 (Utility)', '残局 (Clutch)', '经济 (Economy)', '节奏 (Pace)', '手枪 (Pistol)', '稳定 (Stability)'];
|
||||||
|
|
||||||
const labels = rawLabels.map((l, i) => {
|
const labels = rawLabels.map((l, i) => {
|
||||||
|
if (!hasDist) return l;
|
||||||
const k = keys[i];
|
const k = keys[i];
|
||||||
const d = getDist(k);
|
const d = getDist(k);
|
||||||
return `${l} #${d.rank}`;
|
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, {
|
new Chart(ctxRadar, {
|
||||||
type: 'radar',
|
type: 'radar',
|
||||||
data: {
|
data: {
|
||||||
labels: labels,
|
labels: labels,
|
||||||
datasets: [{
|
datasets: 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]
|
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -338,10 +338,10 @@ function tacticsBoard() {
|
|||||||
this.radarChart = new Chart(ctx, {
|
this.radarChart = new Chart(ctx, {
|
||||||
type: 'radar',
|
type: 'radar',
|
||||||
data: {
|
data: {
|
||||||
labels: ['枪法', '生存', '道具', '残局', '经济', '节奏', '手枪', '稳定'],
|
labels: ['RTG', 'K/D', 'KST', 'ADR', 'IMP', 'UTL'],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Avg',
|
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)',
|
backgroundColor: 'rgba(139, 92, 246, 0.2)',
|
||||||
borderColor: 'rgba(139, 92, 246, 1)',
|
borderColor: 'rgba(139, 92, 246, 1)',
|
||||||
pointBackgroundColor: 'rgba(139, 92, 246, 1)',
|
pointBackgroundColor: 'rgba(139, 92, 246, 1)',
|
||||||
@@ -354,7 +354,7 @@ function tacticsBoard() {
|
|||||||
scales: {
|
scales: {
|
||||||
r: {
|
r: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
max: 100,
|
max: 1.5,
|
||||||
grid: { color: 'rgba(156, 163, 175, 0.1)' },
|
grid: { color: 'rgba(156, 163, 175, 0.1)' },
|
||||||
angleLines: { color: 'rgba(156, 163, 175, 0.1)' },
|
angleLines: { color: 'rgba(156, 163, 175, 0.1)' },
|
||||||
pointLabels: { font: { size: 9 } },
|
pointLabels: { font: { size: 9 } },
|
||||||
@@ -368,22 +368,20 @@ function tacticsBoard() {
|
|||||||
|
|
||||||
updateRadar() {
|
updateRadar() {
|
||||||
if (this.activePlayers.length === 0) {
|
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();
|
this.radarChart.update();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let totals = [0, 0, 0, 0, 0, 0, 0, 0];
|
let totals = [0, 0, 0, 0, 0, 0];
|
||||||
this.activePlayers.forEach(p => {
|
this.activePlayers.forEach(p => {
|
||||||
const s = p.stats || {};
|
const s = p.stats || {};
|
||||||
totals[0] += s.score_aim || 0;
|
totals[0] += s.basic_avg_rating || 0;
|
||||||
totals[1] += s.score_defense || 0;
|
totals[1] += s.basic_avg_kd || 0;
|
||||||
totals[2] += s.score_utility || 0;
|
totals[2] += s.basic_avg_kast || 0;
|
||||||
totals[3] += s.score_clutch || 0;
|
totals[3] += (s.basic_avg_adr || 0) / 100;
|
||||||
totals[4] += s.score_economy || 0;
|
totals[4] += s.bat_avg_impact || 1.0;
|
||||||
totals[5] += s.score_pace || 0;
|
totals[5] += s.util_usage_rate || 0.5;
|
||||||
totals[6] += s.score_pistol || 0;
|
|
||||||
totals[7] += s.score_stability || 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const count = this.activePlayers.length;
|
const count = this.activePlayers.length;
|
||||||
@@ -395,4 +393,4 @@ function tacticsBoard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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>
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -149,16 +149,19 @@
|
|||||||
<h4 class="font-bold text-xl text-gray-900 dark:text-white flex items-center gap-2">
|
<h4 class="font-bold text-xl text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<span>📈</span> 综合评分
|
<span>📈</span> 综合评分
|
||||||
</h4>
|
</h4>
|
||||||
<div class="flex items-baseline gap-6">
|
<div class="flex items-baseline gap-2">
|
||||||
<div class="flex items-baseline gap-2">
|
<span class="text-sm text-gray-500">Team Rating</span>
|
||||||
<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>
|
||||||
<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>
|
</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>
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-6 text-center">
|
<div class="grid grid-cols-3 gap-6 text-center">
|
||||||
@@ -342,6 +345,7 @@ function tacticsApp() {
|
|||||||
// Analysis State
|
// Analysis State
|
||||||
analysisLineup: [],
|
analysisLineup: [],
|
||||||
analysisResult: null,
|
analysisResult: null,
|
||||||
|
analysisChart: null,
|
||||||
debounceTimer: null,
|
debounceTimer: null,
|
||||||
|
|
||||||
// Data Center State
|
// Data Center State
|
||||||
@@ -416,7 +420,8 @@ function tacticsApp() {
|
|||||||
steam_id_64: player.steam_id_64,
|
steam_id_64: player.steam_id_64,
|
||||||
username: player.username || player.name,
|
username: player.username || player.name,
|
||||||
name: player.name || player.username,
|
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.setData('text/plain', JSON.stringify(payload));
|
||||||
event.dataTransfer.effectAllowed = 'copy';
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
@@ -532,10 +537,14 @@ function tacticsApp() {
|
|||||||
// Unwrap proxy if needed
|
// Unwrap proxy if needed
|
||||||
const rawData = JSON.parse(JSON.stringify(this.dataResult));
|
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 datasets = rawData.map((p, idx) => {
|
||||||
const color = this.getPlayerColor(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 {
|
return {
|
||||||
label: p.username,
|
label: p.username,
|
||||||
data: d,
|
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
|
// Recreate Chart with Profile-aligned config
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
this.radarChart = new Chart(ctx, {
|
this.radarChart = new Chart(ctx, {
|
||||||
type: 'radar',
|
type: 'radar',
|
||||||
data: {
|
data: {
|
||||||
labels: ['枪法 (Aim)', '生存 (Defense)', '道具 (Utility)', '残局 (Clutch)', '经济 (Economy)', '节奏 (Pace)', '手枪 (Pistol)', '稳定 (Stability)'],
|
labels: ['AIM (枪法)', 'DEF (生存)', 'UTIL (道具)', 'CLUTCH (残局)', 'ECO (经济)', 'PACE (节奏)', 'PISTOL (手枪)', 'STA (稳定)'],
|
||||||
datasets: datasets
|
datasets: datasets
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
@@ -635,7 +607,7 @@ function tacticsApp() {
|
|||||||
this.radarChart = new Chart(ctx, {
|
this.radarChart = new Chart(ctx, {
|
||||||
type: 'radar',
|
type: 'radar',
|
||||||
data: {
|
data: {
|
||||||
labels: ['枪法 (Aim)', '生存 (Defense)', '道具 (Utility)', '残局 (Clutch)', '经济 (Economy)', '节奏 (Pace)', '手枪 (Pistol)', '稳定 (Stability)'],
|
labels: ['AIM (枪法)', 'DEF (生存)', 'UTIL (道具)', 'CLUTCH (残局)', 'ECO (经济)', 'PACE (节奏)', 'PISTOL (手枪)', 'STA (稳定)'],
|
||||||
datasets: []
|
datasets: []
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
@@ -699,6 +671,59 @@ function tacticsApp() {
|
|||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
this.analysisResult = 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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -69,18 +69,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Grid -->
|
<!-- 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="bg-gray-50 dark:bg-slate-700 rounded p-1">
|
||||||
<div class="text-xs text-gray-400">Rating</div>
|
<div class="text-[10px] 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="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>
|
||||||
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
|
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
|
||||||
<div class="text-xs text-gray-400">K/D</div>
|
<div class="text-[10px] text-gray-400">K/D</div>
|
||||||
<div class="font-bold" x-text="(player.stats?.core_avg_kd || 0).toFixed(2)"></div>
|
<div class="font-bold text-sm" x-text="(player.stats?.core_avg_kd || 0).toFixed(2)"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
|
<div class="bg-gray-50 dark:bg-slate-700 rounded p-1">
|
||||||
<div class="text-xs text-gray-400">总评</div>
|
<div class="text-[10px] text-gray-400">OVR</div>
|
||||||
<div class="font-bold" x-text="(player.stats?.score_overall || 0).toFixed(1)"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<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 }}
|
{{ p.username }}
|
||||||
</a>
|
</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>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user