1.2.3-hotfix: Fixed data center not showing graphs.
This commit is contained in:
Binary file not shown.
@@ -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.stats_service import StatsService
|
||||||
from web.services.feature_service import FeatureService
|
from web.services.feature_service import FeatureService
|
||||||
from web.services.web_service import WebService
|
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 web.config import Config
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
@@ -233,16 +233,139 @@ def api_batch_stats():
|
|||||||
p = StatsService.get_player_info(sid)
|
p = StatsService.get_player_info(sid)
|
||||||
|
|
||||||
if f and p:
|
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({
|
stats.append({
|
||||||
'username': p['username'],
|
'username': p['username'],
|
||||||
'steam_id': sid,
|
'steam_id': sid,
|
||||||
'radar': {
|
'avatar_url': p['avatar_url'],
|
||||||
'STA': f['basic_avg_rating'] or 0,
|
'radar': radar,
|
||||||
'BAT': f['bat_avg_duel_win_rate'] or 0,
|
'basic': basic,
|
||||||
'HPS': f['hps_clutch_win_rate_1v1'] or 0,
|
'side': side,
|
||||||
'PTL': f['ptl_pistol_win_rate'] or 0,
|
'detailed': detailed
|
||||||
'SIDE': f['side_rating_ct'] or 0,
|
|
||||||
'UTIL': f['util_usage_rate'] or 0
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return jsonify(stats)
|
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)
|
||||||
|
|||||||
@@ -776,7 +776,8 @@ class FeatureService:
|
|||||||
# Survived = Rounds - Deaths
|
# Survived = Rounds - Deaths
|
||||||
if df_sides['kast'].mean() == 0:
|
if df_sides['kast'].mean() == 0:
|
||||||
df_sides['survived'] = df_sides['rounds'] - df_sides['deaths']
|
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['fk_rate'] = df_sides['fk'] / df_sides['rounds']
|
||||||
df_sides['fd_rate'] = df_sides['fd'] / df_sides['rounds']
|
df_sides['fd_rate'] = df_sides['fd'] / df_sides['rounds']
|
||||||
|
|||||||
@@ -1,22 +1,355 @@
|
|||||||
{% extends "tactics/layout.html" %}
|
<!-- Data Center Tab Content -->
|
||||||
|
<div x-show="activeTab === 'data'" class="space-y-6 h-full flex flex-col">
|
||||||
{% block title %}Data Center - Tactics{% endblock %}
|
<!-- Header / Controls -->
|
||||||
|
<div class="flex justify-between items-center bg-white dark:bg-slate-800 p-4 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700">
|
||||||
{% block tactics_content %}
|
<div>
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Data Center: Comparison</h2>
|
<span>📊</span> 数据对比中心 (Data Comparison)
|
||||||
|
</h3>
|
||||||
<div class="space-y-6">
|
<p class="text-xs text-gray-500 mt-1">拖拽左侧队员至下方区域,或点击搜索添加</p>
|
||||||
<!-- Controls -->
|
</div>
|
||||||
<div class="flex space-x-4">
|
<div class="flex gap-3">
|
||||||
<input type="text" placeholder="Search players to compare..." class="flex-1 rounded-md border-gray-300 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
<div class="relative">
|
||||||
<button class="px-4 py-2 bg-yrtv-600 text-white rounded-md">Add to Compare</button>
|
<input type="text" x-model="searchQuery" @keydown.enter="searchPlayer()" placeholder="Search Player..." class="pl-3 pr-8 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-gray-50 dark:bg-slate-900 dark:text-white focus:ring-2 focus:ring-yrtv-500">
|
||||||
|
<button @click="searchPlayer()" class="absolute right-2 top-2 text-gray-400 hover:text-yrtv-600">🔍</button>
|
||||||
|
</div>
|
||||||
|
<button @click="clearDataLineup()" class="px-4 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 text-sm font-bold transition">清空</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-6 min-h-0">
|
||||||
|
|
||||||
|
<!-- Left: Selected Players (Drop Zone) -->
|
||||||
|
<div class="lg:col-span-1 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700 flex flex-col overflow-hidden transition-colors duration-200"
|
||||||
|
:class="{'border-yrtv-400 bg-yrtv-50 dark:bg-slate-700 ring-2 ring-yrtv-200': isDraggingOverData}"
|
||||||
|
@dragover.prevent="isDraggingOverData = true"
|
||||||
|
@dragleave="isDraggingOverData = false"
|
||||||
|
@drop="dropData($event)">
|
||||||
|
|
||||||
|
<div class="p-4 border-b border-gray-100 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||||
|
<h4 class="font-bold text-gray-700 dark:text-gray-200 flex justify-between">
|
||||||
|
<span>对比列表</span>
|
||||||
|
<span class="text-xs bg-yrtv-100 text-yrtv-700 px-2 py-0.5 rounded-full" x-text="dataLineup.length + '/5'">0/5</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 p-4 space-y-3 overflow-y-auto custom-scroll min-h-[100px]">
|
||||||
|
|
||||||
|
<template x-for="(p, idx) in dataLineup" :key="p.steam_id_64">
|
||||||
|
<div class="flex items-center p-3 bg-white dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-xl shadow-sm group hover:border-yrtv-300 transition relative">
|
||||||
|
<!-- Color Indicator -->
|
||||||
|
<div class="w-1.5 h-full absolute left-0 top-0 rounded-l-xl" :style="'background-color: ' + getPlayerColor(idx)"></div>
|
||||||
|
|
||||||
|
<div class="ml-3 flex-shrink-0">
|
||||||
|
<template x-if="p.avatar_url">
|
||||||
|
<img :src="p.avatar_url" class="w-10 h-10 rounded-full object-cover border border-gray-200 dark:border-slate-500">
|
||||||
|
</template>
|
||||||
|
<template x-if="!p.avatar_url">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-500 font-bold text-xs">
|
||||||
|
<span x-text="(p.username || p.name).substring(0,2).toUpperCase()"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-bold text-gray-900 dark:text-white truncate" x-text="p.username || p.name"></div>
|
||||||
|
<div class="text-xs text-gray-500 font-mono truncate" x-text="p.steam_id_64"></div>
|
||||||
|
</div>
|
||||||
|
<button @click="removeFromDataLineup(idx)" class="text-gray-400 hover:text-red-500 p-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="dataLineup.length < 5">
|
||||||
|
<div class="h-24 border-2 border-dashed border-gray-200 dark:border-slate-600 rounded-xl flex flex-col items-center justify-center text-gray-400 text-sm hover:bg-gray-50 dark:hover:bg-slate-800 transition cursor-default"
|
||||||
|
:class="{'border-yrtv-400 text-yrtv-600 bg-white': isDraggingOverData}">
|
||||||
|
<span>+ 拖拽或搜索添加</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Visualization (Scrollable) -->
|
||||||
|
<div class="lg:col-span-3 space-y-6 overflow-y-auto custom-scroll pr-2">
|
||||||
|
|
||||||
|
<!-- 1. Radar & Key Stats -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Radar Chart -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700 min-h-[400px] flex flex-col">
|
||||||
|
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-4">能力模型对比 (Capability Radar)</h4>
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<canvas id="dataRadarChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Basic Stats Table -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700 flex flex-col">
|
||||||
|
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-4">基础数据 (Basic Stats)</h4>
|
||||||
|
<div class="flex-1 overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-500 border-b border-gray-100 dark:border-slate-700">
|
||||||
|
<th class="py-2 text-left">Player</th>
|
||||||
|
<th class="py-2 text-right">Rating</th>
|
||||||
|
<th class="py-2 text-right">K/D</th>
|
||||||
|
<th class="py-2 text-right">ADR</th>
|
||||||
|
<th class="py-2 text-right">KAST</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||||
|
<template x-for="(stat, idx) in dataResult" :key="stat.steam_id">
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50">
|
||||||
|
<td class="py-3 flex items-center gap-2">
|
||||||
|
<div class="w-3 h-3 rounded-full" :style="'background-color: ' + getPlayerColor(idx)"></div>
|
||||||
|
<span class="font-bold dark:text-white truncate max-w-[100px]" x-text="stat.username"></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 text-right font-mono font-bold" :class="getRatingColor(stat.basic.rating)" x-text="stat.basic.rating.toFixed(2)"></td>
|
||||||
|
<td class="py-3 text-right font-mono" x-text="stat.basic.kd.toFixed(2)"></td>
|
||||||
|
<td class="py-3 text-right font-mono" x-text="stat.basic.adr.toFixed(1)"></td>
|
||||||
|
<td class="py-3 text-right font-mono" x-text="(stat.basic.kast * 100).toFixed(1) + '%'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template x-if="!dataResult || dataResult.length === 0">
|
||||||
|
<tr><td colspan="5" class="py-8 text-center text-gray-400">请选择选手进行对比</td></tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Detailed Breakdown (New) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700">
|
||||||
|
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-6">详细数据对比 (Detailed Stats)</h4>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 dark:bg-slate-700/50 text-gray-500">
|
||||||
|
<th class="px-4 py-3 text-left rounded-l-lg">Metric</th>
|
||||||
|
<template x-for="(stat, idx) in dataResult" :key="'dh-'+stat.steam_id">
|
||||||
|
<th class="px-4 py-3 text-center" :class="{'rounded-r-lg': idx === dataResult.length-1}">
|
||||||
|
<span class="border-b-2 px-1 font-bold dark:text-gray-300" :style="'border-color: ' + getPlayerColor(idx)" x-text="stat.username"></span>
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||||
|
<!-- Row 1 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Rating (Rating/KD)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400 font-bold" x-text="stat.detailed.rating_t.toFixed(2)"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400 font-bold" x-text="stat.detailed.rating_ct.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">KD Ratio</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="stat.detailed.kd_t.toFixed(2)"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="stat.detailed.kd_ct.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 2 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Win Rate (胜率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.win_rate_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.win_rate_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">First Kill Rate (首杀率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.first_kill_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.first_kill_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 3 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">First Death Rate (首死率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.first_death_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.first_death_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">KAST (贡献率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.kast_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.kast_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 4 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">RWS (Round Win Share)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="stat.detailed.rws_t.toFixed(2)"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="stat.detailed.rws_ct.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Multi-Kill Rate (多杀率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.multikill_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.multikill_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Row 5 -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Headshot Rate (爆头率)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="(stat.detailed.hs_t * 100).toFixed(1) + '%'"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="(stat.detailed.hs_ct * 100).toFixed(1) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Obj (下包 vs 拆包)</td>
|
||||||
|
<template x-for="stat in dataResult">
|
||||||
|
<td class="px-4 py-2 text-center font-mono text-xs">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400" x-text="stat.detailed.obj_t.toFixed(2)"></span>
|
||||||
|
<span class="text-blue-600 dark:text-blue-400" x-text="stat.detailed.obj_ct.toFixed(2)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between w-full max-w-[120px] mx-auto text-[10px] text-gray-400">
|
||||||
|
<span>T-Side</span><span>CT-Side</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Map Performance -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 p-6 rounded-xl shadow-lg border border-gray-100 dark:border-slate-700">
|
||||||
|
<h4 class="font-bold text-gray-800 dark:text-gray-200 mb-6">地图表现 (Map Performance)</h4>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left rounded-l-lg">Map</th>
|
||||||
|
<template x-for="(stat, idx) in dataResult" :key="'h-'+stat.steam_id">
|
||||||
|
<th class="px-4 py-2 text-center" :class="{'rounded-r-lg': idx === dataResult.length-1}">
|
||||||
|
<span class="border-b-2 px-1" :style="'border-color: ' + getPlayerColor(idx)" x-text="stat.username"></span>
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||||
|
<!-- We need to iterate maps. Assuming mapMap is computed in JS -->
|
||||||
|
<template x-for="mapName in allMaps" :key="mapName">
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td class="px-4 py-3 font-bold text-gray-600 dark:text-gray-300" x-text="mapName"></td>
|
||||||
|
<template x-for="stat in dataResult" :key="'d-'+stat.steam_id+mapName">
|
||||||
|
<td class="px-4 py-3 text-center">
|
||||||
|
<template x-if="getMapStat(stat.steam_id, mapName)">
|
||||||
|
<div>
|
||||||
|
<div class="font-bold font-mono" :class="getRatingColor(getMapStat(stat.steam_id, mapName).rating)" x-text="getMapStat(stat.steam_id, mapName).rating.toFixed(2)"></div>
|
||||||
|
<div class="text-[10px] text-gray-400" x-text="(getMapStat(stat.steam_id, mapName).win_rate * 100).toFixed(0) + '% (' + getMapStat(stat.steam_id, mapName).matches + ')'"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!getMapStat(stat.steam_id, mapName)">
|
||||||
|
<span class="text-gray-300">-</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chart Placeholder -->
|
|
||||||
<div class="h-96 bg-gray-50 dark:bg-slate-700 rounded flex items-center justify-center">
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">Multi-player Radar Chart / Bar Chart Area</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
@@ -248,14 +248,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. Data Center (Placeholder) -->
|
<!-- 2. Data Center -->
|
||||||
<div x-show="activeTab === 'data'" class="flex items-center justify-center h-full">
|
{% include 'tactics/data.html' %}
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-4xl mb-4">📊</div>
|
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">数据对比中心 (Construction)</h3>
|
|
||||||
<p class="text-gray-500">此模块正在开发中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3. Strategy Board -->
|
<!-- 3. Strategy Board -->
|
||||||
<div x-show="activeTab === 'board'" class="h-full flex flex-col">
|
<div x-show="activeTab === 'board'" class="h-full flex flex-col">
|
||||||
@@ -344,6 +338,15 @@ function tacticsApp() {
|
|||||||
analysisResult: null,
|
analysisResult: null,
|
||||||
debounceTimer: 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
|
// Board State
|
||||||
currentMap: 'de_mirage',
|
currentMap: 'de_mirage',
|
||||||
map: null,
|
map: null,
|
||||||
@@ -372,6 +375,11 @@ function tacticsApp() {
|
|||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watch Data Lineup
|
||||||
|
this.$watch('dataLineup', () => {
|
||||||
|
this.comparePlayers();
|
||||||
|
});
|
||||||
|
|
||||||
// Init map on first board view, or delay
|
// Init map on first board view, or delay
|
||||||
this.$watch('activeTab', value => {
|
this.$watch('activeTab', value => {
|
||||||
if (value === 'board') {
|
if (value === 'board') {
|
||||||
@@ -397,10 +405,226 @@ function tacticsApp() {
|
|||||||
|
|
||||||
// --- Drag & Drop Generic ---
|
// --- Drag & Drop Generic ---
|
||||||
dragStart(event, player) {
|
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';
|
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 ---
|
// --- Analysis Logic ---
|
||||||
dropAnalysis(event) {
|
dropAnalysis(event) {
|
||||||
const data = event.dataTransfer.getData('text/plain');
|
const data = event.dataTransfer.getData('text/plain');
|
||||||
|
|||||||
Reference in New Issue
Block a user