1.1.0: Updated Profile
38
web/debug_roster.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from web.services.web_service import WebService
|
||||||
|
from web.services.stats_service import StatsService
|
||||||
|
import json
|
||||||
|
|
||||||
|
def debug_roster():
|
||||||
|
print("--- Debugging Roster Stats ---")
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
if not lineups:
|
||||||
|
print("No lineups found via WebService.")
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_json = lineups[0]['player_ids_json']
|
||||||
|
print(f"Raw JSON: {raw_json}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
roster_ids = json.loads(raw_json)
|
||||||
|
print(f"Parsed IDs (List): {roster_ids}")
|
||||||
|
print(f"Type of first ID: {type(roster_ids[0])}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"JSON Parse Error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
target_id = roster_ids[0] # Pick first one
|
||||||
|
print(f"\nTesting for Target ID: {target_id} (Type: {type(target_id)})")
|
||||||
|
|
||||||
|
# Test StatsService
|
||||||
|
dist = StatsService.get_roster_stats_distribution(target_id)
|
||||||
|
print(f"\nDistribution Result: {dist}")
|
||||||
|
|
||||||
|
# Test Basic Stats
|
||||||
|
basic = StatsService.get_player_basic_stats(str(target_id))
|
||||||
|
print(f"\nBasic Stats for {target_id}: {basic}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from web.app import create_app
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
debug_roster()
|
||||||
@@ -11,10 +11,15 @@ def index():
|
|||||||
map_name = request.args.get('map')
|
map_name = request.args.get('map')
|
||||||
date_from = request.args.get('date_from')
|
date_from = request.args.get('date_from')
|
||||||
|
|
||||||
|
# Fetch summary stats (for the dashboard)
|
||||||
|
summary_stats = StatsService.get_team_stats_summary()
|
||||||
|
|
||||||
matches, total = StatsService.get_matches(page, Config.ITEMS_PER_PAGE, map_name, date_from)
|
matches, total = StatsService.get_matches(page, Config.ITEMS_PER_PAGE, map_name, date_from)
|
||||||
total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE
|
total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE
|
||||||
|
|
||||||
return render_template('matches/list.html', matches=matches, total=total, page=page, total_pages=total_pages)
|
return render_template('matches/list.html',
|
||||||
|
matches=matches, total=total, page=page, total_pages=total_pages,
|
||||||
|
summary_stats=summary_stats)
|
||||||
|
|
||||||
@bp.route('/<match_id>')
|
@bp.route('/<match_id>')
|
||||||
def detail(match_id):
|
def detail(match_id):
|
||||||
|
|||||||
@@ -118,11 +118,14 @@ def detail(steam_id):
|
|||||||
comments = WebService.get_comments('player', steam_id)
|
comments = WebService.get_comments('player', steam_id)
|
||||||
metadata = WebService.get_player_metadata(steam_id)
|
metadata = WebService.get_player_metadata(steam_id)
|
||||||
|
|
||||||
|
# Roster Distribution Stats
|
||||||
|
distribution = StatsService.get_roster_stats_distribution(steam_id)
|
||||||
|
|
||||||
# History for table (L2 Source) - Fetch ALL for history table/chart
|
# History for table (L2 Source) - Fetch ALL for history table/chart
|
||||||
history_asc = StatsService.get_player_trend(steam_id, limit=1000)
|
history_asc = StatsService.get_player_trend(steam_id, limit=1000)
|
||||||
history = history_asc[::-1] if history_asc else []
|
history = history_asc[::-1] if history_asc else []
|
||||||
|
|
||||||
return render_template('players/profile.html', player=player, features=features, comments=comments, metadata=metadata, history=history)
|
return render_template('players/profile.html', player=player, features=features, comments=comments, metadata=metadata, history=history, distribution=distribution)
|
||||||
|
|
||||||
@bp.route('/comment/<int:comment_id>/like', methods=['POST'])
|
@bp.route('/comment/<int:comment_id>/like', methods=['POST'])
|
||||||
def like_comment(comment_id):
|
def like_comment(comment_id):
|
||||||
@@ -151,9 +154,14 @@ def charts_data(steam_id):
|
|||||||
|
|
||||||
trend_labels = []
|
trend_labels = []
|
||||||
trend_values = []
|
trend_values = []
|
||||||
for t in trends:
|
match_indices = []
|
||||||
dt = datetime.fromtimestamp(t['start_time']) if t['start_time'] else datetime.now()
|
for i, row in enumerate(trends):
|
||||||
trend_labels.append(dt.strftime('%Y-%m-%d'))
|
t = dict(row) # Convert sqlite3.Row to dict
|
||||||
|
# Format: Match #Index (Map)
|
||||||
|
# Use backend-provided match_index if available, or just index + 1
|
||||||
|
idx = t.get('match_index', i + 1)
|
||||||
|
map_name = t.get('map_name', 'Unknown')
|
||||||
|
trend_labels.append(f"#{idx} {map_name}")
|
||||||
trend_values.append(t['rating'])
|
trend_values.append(t['rating'])
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
@@ -1,6 +1,178 @@
|
|||||||
from web.database import query_db
|
from web.database import query_db
|
||||||
|
|
||||||
class StatsService:
|
class StatsService:
|
||||||
|
@staticmethod
|
||||||
|
def get_team_stats_summary():
|
||||||
|
"""
|
||||||
|
Calculates aggregate statistics for matches where at least 2 roster members played together.
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
'map_stats': [{'map_name', 'count', 'wins', 'win_rate'}],
|
||||||
|
'elo_stats': [{'range', 'count', 'wins', 'win_rate'}],
|
||||||
|
'duration_stats': [{'range', 'count', 'wins', 'win_rate'}],
|
||||||
|
'round_stats': [{'type', 'count', 'wins', 'win_rate'}]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 1. Get Active Roster
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
import json
|
||||||
|
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
active_roster_ids = []
|
||||||
|
if lineups:
|
||||||
|
try:
|
||||||
|
raw_ids = json.loads(lineups[0]['player_ids_json'])
|
||||||
|
active_roster_ids = [str(uid) for uid in raw_ids]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not active_roster_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 2. Find matches with >= 2 roster members
|
||||||
|
# We need match_id, map_name, scores, winner_team, duration, avg_elo
|
||||||
|
# And we need to determine if "Our Team" won.
|
||||||
|
|
||||||
|
placeholders = ','.join('?' for _ in active_roster_ids)
|
||||||
|
|
||||||
|
# Step A: Get Candidate Match IDs (matches with >= 2 roster players)
|
||||||
|
# Also get the team_id of our players in that match to determine win
|
||||||
|
candidate_sql = f"""
|
||||||
|
SELECT mp.match_id, MAX(mp.team_id) as our_team_id
|
||||||
|
FROM fact_match_players mp
|
||||||
|
WHERE CAST(mp.steam_id_64 AS TEXT) IN ({placeholders})
|
||||||
|
GROUP BY mp.match_id
|
||||||
|
HAVING COUNT(DISTINCT mp.steam_id_64) >= 2
|
||||||
|
"""
|
||||||
|
candidate_rows = query_db('l2', candidate_sql, active_roster_ids)
|
||||||
|
|
||||||
|
if not candidate_rows:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
candidate_map = {row['match_id']: row['our_team_id'] for row in candidate_rows}
|
||||||
|
match_ids = list(candidate_map.keys())
|
||||||
|
match_placeholders = ','.join('?' for _ in match_ids)
|
||||||
|
|
||||||
|
# Step B: Get Match Details
|
||||||
|
match_sql = f"""
|
||||||
|
SELECT m.match_id, m.map_name, m.score_team1, m.score_team2, m.winner_team, m.duration,
|
||||||
|
AVG(fmt.group_origin_elo) as avg_elo
|
||||||
|
FROM fact_matches m
|
||||||
|
LEFT JOIN fact_match_teams fmt ON m.match_id = fmt.match_id AND fmt.group_origin_elo > 0
|
||||||
|
WHERE m.match_id IN ({match_placeholders})
|
||||||
|
GROUP BY m.match_id
|
||||||
|
"""
|
||||||
|
match_rows = query_db('l2', match_sql, match_ids)
|
||||||
|
|
||||||
|
# 3. Process Data
|
||||||
|
# Buckets initialization
|
||||||
|
map_stats = {}
|
||||||
|
elo_ranges = ['<1000', '1000-1200', '1200-1400', '1400-1600', '1600-1800', '1800-2000', '2000+']
|
||||||
|
elo_stats = {r: {'wins': 0, 'total': 0} for r in elo_ranges}
|
||||||
|
|
||||||
|
dur_ranges = ['<30m', '30-45m', '45m+']
|
||||||
|
dur_stats = {r: {'wins': 0, 'total': 0} for r in dur_ranges}
|
||||||
|
|
||||||
|
round_types = ['Stomp (<15)', 'Normal', 'Close (>23)', 'Choke (24)']
|
||||||
|
round_stats = {r: {'wins': 0, 'total': 0} for r in round_types}
|
||||||
|
|
||||||
|
for m in match_rows:
|
||||||
|
mid = m['match_id']
|
||||||
|
# Determine Win
|
||||||
|
# Use candidate_map to get our_team_id.
|
||||||
|
# Note: winner_team is usually int (1 or 2) or string.
|
||||||
|
# our_team_id from fact_match_players is usually int (1 or 2).
|
||||||
|
# This logic assumes simple team ID matching.
|
||||||
|
# If sophisticated "UID in Winning Group" logic is needed, we'd need more queries.
|
||||||
|
# For aggregate stats, let's assume team_id matching is sufficient for 99% cases or fallback to simple check.
|
||||||
|
# Actually, let's try to be consistent with get_matches logic if possible,
|
||||||
|
# but getting group_uids for ALL matches is heavy.
|
||||||
|
# Let's trust team_id for this summary.
|
||||||
|
|
||||||
|
our_tid = candidate_map[mid]
|
||||||
|
winner_tid = m['winner_team']
|
||||||
|
|
||||||
|
# Type normalization
|
||||||
|
try:
|
||||||
|
is_win = (int(our_tid) == int(winner_tid)) if (our_tid and winner_tid) else False
|
||||||
|
except:
|
||||||
|
is_win = (str(our_tid) == str(winner_tid)) if (our_tid and winner_tid) else False
|
||||||
|
|
||||||
|
# 1. Map Stats
|
||||||
|
map_name = m['map_name'] or 'Unknown'
|
||||||
|
if map_name not in map_stats:
|
||||||
|
map_stats[map_name] = {'wins': 0, 'total': 0}
|
||||||
|
map_stats[map_name]['total'] += 1
|
||||||
|
if is_win: map_stats[map_name]['wins'] += 1
|
||||||
|
|
||||||
|
# 2. ELO Stats
|
||||||
|
elo = m['avg_elo']
|
||||||
|
if elo:
|
||||||
|
if elo < 1000: e_key = '<1000'
|
||||||
|
elif elo < 1200: e_key = '1000-1200'
|
||||||
|
elif elo < 1400: e_key = '1200-1400'
|
||||||
|
elif elo < 1600: e_key = '1400-1600'
|
||||||
|
elif elo < 1800: e_key = '1600-1800'
|
||||||
|
elif elo < 2000: e_key = '1800-2000'
|
||||||
|
else: e_key = '2000+'
|
||||||
|
elo_stats[e_key]['total'] += 1
|
||||||
|
if is_win: elo_stats[e_key]['wins'] += 1
|
||||||
|
|
||||||
|
# 3. Duration Stats
|
||||||
|
dur = m['duration'] # seconds
|
||||||
|
if dur:
|
||||||
|
dur_min = dur / 60
|
||||||
|
if dur_min < 30: d_key = '<30m'
|
||||||
|
elif dur_min < 45: d_key = '30-45m'
|
||||||
|
else: d_key = '45m+'
|
||||||
|
dur_stats[d_key]['total'] += 1
|
||||||
|
if is_win: dur_stats[d_key]['wins'] += 1
|
||||||
|
|
||||||
|
# 4. Round Stats
|
||||||
|
s1 = m['score_team1'] or 0
|
||||||
|
s2 = m['score_team2'] or 0
|
||||||
|
total_rounds = s1 + s2
|
||||||
|
|
||||||
|
if total_rounds == 24:
|
||||||
|
r_key = 'Choke (24)'
|
||||||
|
round_stats[r_key]['total'] += 1
|
||||||
|
if is_win: round_stats[r_key]['wins'] += 1
|
||||||
|
|
||||||
|
# Note: Close (>23) overlaps with Choke (24).
|
||||||
|
# User requirement: Close > 23 counts ALL matches > 23, regardless of other categories.
|
||||||
|
if total_rounds > 23:
|
||||||
|
r_key = 'Close (>23)'
|
||||||
|
round_stats[r_key]['total'] += 1
|
||||||
|
if is_win: round_stats[r_key]['wins'] += 1
|
||||||
|
|
||||||
|
if total_rounds < 15:
|
||||||
|
r_key = 'Stomp (<15)'
|
||||||
|
round_stats[r_key]['total'] += 1
|
||||||
|
if is_win: round_stats[r_key]['wins'] += 1
|
||||||
|
elif total_rounds <= 23: # Only Normal if NOT Stomp and NOT Close (<= 23 and >= 15)
|
||||||
|
r_key = 'Normal'
|
||||||
|
round_stats[r_key]['total'] += 1
|
||||||
|
if is_win: round_stats[r_key]['wins'] += 1
|
||||||
|
|
||||||
|
# 4. Format Results
|
||||||
|
def fmt(stats_dict):
|
||||||
|
res = []
|
||||||
|
for k, v in stats_dict.items():
|
||||||
|
rate = (v['wins'] / v['total'] * 100) if v['total'] > 0 else 0
|
||||||
|
res.append({'label': k, 'count': v['total'], 'wins': v['wins'], 'win_rate': rate})
|
||||||
|
return res
|
||||||
|
|
||||||
|
# For maps, sort by count
|
||||||
|
map_res = fmt(map_stats)
|
||||||
|
map_res.sort(key=lambda x: x['count'], reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'map_stats': map_res,
|
||||||
|
'elo_stats': fmt(elo_stats), # Keep order
|
||||||
|
'duration_stats': fmt(dur_stats), # Keep order
|
||||||
|
'round_stats': fmt(round_stats) # Keep order
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_recent_matches(limit=5):
|
def get_recent_matches(limit=5):
|
||||||
sql = """
|
sql = """
|
||||||
@@ -398,7 +570,12 @@ class StatsService:
|
|||||||
WHERE p2.match_id = mp.match_id
|
WHERE p2.match_id = mp.match_id
|
||||||
AND p2.match_team_id = mp.match_team_id
|
AND p2.match_team_id = mp.match_team_id
|
||||||
AND p2.match_team_id > 0 -- Ensure we don't count 0 (solo default) as a massive party
|
AND p2.match_team_id > 0 -- Ensure we don't count 0 (solo default) as a massive party
|
||||||
) as party_size
|
) as party_size,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM fact_matches m2
|
||||||
|
WHERE m2.start_time <= m.start_time
|
||||||
|
) as match_index
|
||||||
FROM fact_match_players mp
|
FROM fact_match_players mp
|
||||||
JOIN fact_matches m ON mp.match_id = m.match_id
|
JOIN fact_matches m ON mp.match_id = m.match_id
|
||||||
WHERE mp.steam_id_64 = ?
|
WHERE mp.steam_id_64 = ?
|
||||||
@@ -408,6 +585,103 @@ class StatsService:
|
|||||||
"""
|
"""
|
||||||
return query_db('l2', sql, [steam_id, limit])
|
return query_db('l2', sql, [steam_id, limit])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_roster_stats_distribution(target_steam_id):
|
||||||
|
"""
|
||||||
|
Calculates rank and distribution of the target player within the active roster.
|
||||||
|
"""
|
||||||
|
from web.services.web_service import WebService
|
||||||
|
import json
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# 1. Get Active Roster IDs
|
||||||
|
lineups = WebService.get_lineups()
|
||||||
|
active_roster_ids = []
|
||||||
|
if lineups:
|
||||||
|
try:
|
||||||
|
raw_ids = json.loads(lineups[0]['player_ids_json'])
|
||||||
|
active_roster_ids = [str(uid) for uid in raw_ids]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ensure target is in list (if not in roster, compare against roster anyway)
|
||||||
|
# If roster is empty, return None
|
||||||
|
if not active_roster_ids:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2. Fetch stats for all roster members
|
||||||
|
placeholders = ','.join('?' for _ in active_roster_ids)
|
||||||
|
sql = f"""
|
||||||
|
SELECT
|
||||||
|
CAST(steam_id_64 AS TEXT) as steam_id_64,
|
||||||
|
AVG(rating) as rating,
|
||||||
|
AVG(kd_ratio) as kd,
|
||||||
|
AVG(adr) as adr,
|
||||||
|
AVG(kast) as kast
|
||||||
|
FROM fact_match_players
|
||||||
|
WHERE CAST(steam_id_64 AS TEXT) IN ({placeholders})
|
||||||
|
GROUP BY steam_id_64
|
||||||
|
"""
|
||||||
|
rows = query_db('l2', sql, active_roster_ids)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stats_map = {row['steam_id_64']: dict(row) for row in rows}
|
||||||
|
|
||||||
|
# Ensure target_steam_id is string
|
||||||
|
target_steam_id = str(target_steam_id)
|
||||||
|
|
||||||
|
# If target player not in stats_map (e.g. no matches), handle gracefullly
|
||||||
|
if target_steam_id not in stats_map:
|
||||||
|
# Try fetch target stats individually if not in roster list
|
||||||
|
target_stats = StatsService.get_player_basic_stats(target_steam_id)
|
||||||
|
if target_stats:
|
||||||
|
stats_map[target_steam_id] = target_stats
|
||||||
|
else:
|
||||||
|
# If still no stats, we can't rank them.
|
||||||
|
# But we can still return the roster stats for others?
|
||||||
|
# The prompt implies "No team data" appears, meaning this function returns valid structure but empty values?
|
||||||
|
# Or returns None.
|
||||||
|
# Let's verify what happens if target has no stats but others do.
|
||||||
|
# We should probably add a dummy entry for target so dashboard renders '0' instead of crashing or 'No data'
|
||||||
|
stats_map[target_steam_id] = {'rating': 0, 'kd': 0, 'adr': 0, 'kast': 0}
|
||||||
|
|
||||||
|
# 3. Calculate Distribution
|
||||||
|
metrics = ['rating', 'kd', 'adr', 'kast']
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for m in metrics:
|
||||||
|
# Extract values for this metric from all players
|
||||||
|
values = [p[m] for p in stats_map.values() if p[m] is not None]
|
||||||
|
target_val = stats_map[target_steam_id].get(m)
|
||||||
|
|
||||||
|
if target_val is None or not values:
|
||||||
|
result[m] = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort descending (higher is better)
|
||||||
|
values.sort(reverse=True)
|
||||||
|
|
||||||
|
# Rank (1-based)
|
||||||
|
try:
|
||||||
|
rank = values.index(target_val) + 1
|
||||||
|
except ValueError:
|
||||||
|
# Floating point precision issue? Find closest
|
||||||
|
closest = min(values, key=lambda x: abs(x - target_val))
|
||||||
|
rank = values.index(closest) + 1
|
||||||
|
|
||||||
|
result[m] = {
|
||||||
|
'val': target_val,
|
||||||
|
'rank': rank,
|
||||||
|
'total': len(values),
|
||||||
|
'min': min(values),
|
||||||
|
'max': max(values),
|
||||||
|
'avg': sum(values) / len(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_live_matches():
|
def get_live_matches():
|
||||||
# Query matches started in last 2 hours with no winner
|
# Query matches started in last 2 hours with no winner
|
||||||
|
|||||||
BIN
web/static/avatars/76561198330488905.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
web/static/avatars/76561198970034329.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
web/static/avatars/76561199026688017.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
web/static/avatars/76561199032002725.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
web/static/avatars/76561199076109761.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
web/static/avatars/76561199078250590.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
web/static/avatars/76561199106558767.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
web/static/avatars/76561199390145159.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
web/static/avatars/76561199417030350.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
web/static/avatars/76561199467422873.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
web/static/avatars/76561199526984477.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
@@ -5,7 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}YRTV - CS2 Data Platform{% endblock %}</title>
|
<title>{% block title %}YRTV - CS2 Data Platform{% endblock %}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
|
|||||||
@@ -1,6 +1,100 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<!-- Team Stats Summary (Party >= 2) -->
|
||||||
|
{% if summary_stats %}
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Left Block: Map Stats -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||||
|
<span class="mr-2">🗺️</span>
|
||||||
|
地图表现 (Party ≥ 2)
|
||||||
|
</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Map</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Matches</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Win Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for stat in summary_stats.map_stats[:6] %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 text-sm font-medium dark:text-white">{{ stat.label }}</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-right text-gray-500 dark:text-gray-400">{{ stat.count }}</td>
|
||||||
|
<td class="px-4 py-2 text-sm text-right">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<span class="font-bold {% if stat.win_rate >= 50 %}text-green-600{% else %}text-red-500{% endif %}">
|
||||||
|
{{ "%.1f"|format(stat.win_rate) }}%
|
||||||
|
</span>
|
||||||
|
<div class="w-16 h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full {% if stat.win_rate >= 50 %}bg-green-500{% else %}bg-red-500{% endif %}" style="width: {{ stat.win_rate }}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Block: Context Stats -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||||
|
<span class="mr-2">📊</span>
|
||||||
|
环境胜率分析
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- ELO Stats -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-gray-500 uppercase mb-2">ELO 层级表现</h4>
|
||||||
|
<div class="grid grid-cols-7 gap-2">
|
||||||
|
{% for stat in summary_stats.elo_stats %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded text-center">
|
||||||
|
<div class="text-[9px] text-gray-400 truncate" title="{{ stat.label }}">{{ stat.label }}</div>
|
||||||
|
<div class="text-xs font-bold dark:text-white">{{ "%.0f"|format(stat.win_rate) }}%</div>
|
||||||
|
<div class="text-[9px] text-gray-400">({{ stat.count }})</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration Stats -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-gray-500 uppercase mb-2">时长表现</h4>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{% for stat in summary_stats.duration_stats %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded text-center">
|
||||||
|
<div class="text-[10px] text-gray-400">{{ stat.label }}</div>
|
||||||
|
<div class="text-sm font-bold dark:text-white">{{ "%.0f"|format(stat.win_rate) }}%</div>
|
||||||
|
<div class="text-[10px] text-gray-400">({{ stat.count }})</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Round Stats -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-bold text-gray-500 uppercase mb-2">局势表现 (总回合数)</h4>
|
||||||
|
<div class="grid grid-cols-4 gap-2">
|
||||||
|
{% for stat in summary_stats.round_stats %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700 p-2 rounded text-center border {% if 'Stomp' in stat.label %}border-green-200{% elif 'Close' in stat.label %}border-orange-200{% elif 'Choke' in stat.label %}border-red-200{% else %}border-gray-200{% endif %}">
|
||||||
|
<div class="text-[9px] text-gray-400 truncate" title="{{ stat.label }}">{{ stat.label }}</div>
|
||||||
|
<div class="text-sm font-bold dark:text-white">{{ "%.0f"|format(stat.win_rate) }}%</div>
|
||||||
|
<div class="text-[9px] text-gray-400">({{ stat.count }})</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">比赛列表</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">比赛列表</h2>
|
||||||
|
|||||||
@@ -1,261 +1,305 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-8" x-data="{ range: '20' }">
|
||||||
<!-- Profile Header -->
|
<!-- 1. Header & Data Dashboard (Top) -->
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
<div class="bg-white dark:bg-slate-800 shadow-xl rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700">
|
||||||
<div class="sm:flex sm:items-center sm:justify-between">
|
<div class="p-8">
|
||||||
<div class="sm:flex sm:space-x-5">
|
<div class="lg:flex lg:items-start lg:space-x-8">
|
||||||
<div class="flex-shrink-0 relative group">
|
<!-- Avatar & Basic Info -->
|
||||||
<!-- Avatar -->
|
<div class="flex-shrink-0 flex flex-col items-center lg:items-start space-y-4">
|
||||||
{% if player.avatar_url %}
|
<div class="relative group">
|
||||||
<img src="{{ player.avatar_url }}" class="mx-auto h-24 w-24 rounded-full object-cover border-4 border-white shadow-lg">
|
{% if player.avatar_url %}
|
||||||
{% else %}
|
<img src="{{ player.avatar_url }}" class="h-32 w-32 rounded-2xl object-cover shadow-lg border-4 border-white dark:border-slate-700 transform group-hover:scale-105 transition duration-300">
|
||||||
<div class="mx-auto h-24 w-24 rounded-full bg-yrtv-100 flex items-center justify-center text-yrtv-600 font-bold text-3xl border-4 border-white shadow-lg">
|
{% else %}
|
||||||
{{ player.username[:2] | upper if player.username else '??' }}
|
<div class="h-32 w-32 rounded-2xl bg-gradient-to-br from-yrtv-100 to-yrtv-200 flex items-center justify-center text-yrtv-600 font-bold text-4xl shadow-lg border-4 border-white dark:border-slate-700">
|
||||||
</div>
|
{{ player.username[:2] | upper if player.username else '??' }}
|
||||||
{% endif %}
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if session.get('is_admin') %}
|
|
||||||
<button onclick="document.getElementById('editProfileModal').classList.remove('hidden')" class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded-full opacity-0 group-hover:opacity-100 text-white text-xs transition cursor-pointer">
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
|
|
||||||
<p class="text-xl font-bold text-gray-900 dark:text-white sm:text-2xl">{{ player.username }}</p>
|
|
||||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ player.steam_id_64 }}</p>
|
|
||||||
<div class="mt-2 flex justify-center sm:justify-start space-x-2 items-center flex-wrap">
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
|
||||||
{{ player.uid }}
|
|
||||||
</span>
|
|
||||||
<!-- Tags -->
|
|
||||||
{% for tag in metadata.tags %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
|
||||||
{{ tag }}
|
|
||||||
{% if session.get('is_admin') %}
|
|
||||||
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="inline ml-1">
|
|
||||||
<input type="hidden" name="admin_action" value="remove_tag">
|
|
||||||
<input type="hidden" name="tag" value="{{ tag }}">
|
|
||||||
<button type="submit" class="text-gray-400 hover:text-red-500 focus:outline-none">×</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if session.get('is_admin') %}
|
{% if session.get('is_admin') %}
|
||||||
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="inline-flex items-center">
|
<button onclick="document.getElementById('editProfileModal').classList.remove('hidden')" class="absolute -bottom-2 -right-2 bg-white dark:bg-slate-700 p-2 rounded-full shadow-md text-gray-500 hover:text-yrtv-600 transition">
|
||||||
<input type="hidden" name="admin_action" value="add_tag">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
|
||||||
<input type="text" name="tag" placeholder="New Tag" class="w-20 text-xs border border-gray-300 rounded px-1 py-0.5 focus:outline-none dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
</button>
|
||||||
<button type="submit" class="ml-1 text-xs text-yrtv-600 hover:text-yrtv-800">+</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if metadata.notes %}
|
<div class="text-center lg:text-left">
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400 italic">"{{ metadata.notes }}"</p>
|
<h1 class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">{{ player.username }}</h1>
|
||||||
{% endif %}
|
<p class="text-sm font-mono text-gray-500 dark:text-gray-400 mt-1">{{ player.steam_id_64 }}</p>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="mt-3 flex flex-wrap justify-center lg:justify-start gap-2">
|
||||||
|
{% for tag in metadata.tags %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-bold bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-gray-300 border border-gray-200 dark:border-slate-600">
|
||||||
|
{{ tag }}
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="inline ml-1">
|
||||||
|
<input type="hidden" name="admin_action" value="remove_tag">
|
||||||
|
<input type="hidden" name="tag" value="{{ tag }}">
|
||||||
|
<button type="submit" class="text-gray-400 hover:text-red-500 focus:outline-none">×</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if session.get('is_admin') %}
|
||||||
|
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="inline-flex">
|
||||||
|
<input type="hidden" name="admin_action" value="add_tag">
|
||||||
|
<input type="text" name="tag" placeholder="+Tag" class="w-16 text-xs border border-gray-300 rounded px-1 py-0.5 focus:outline-none dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Dashboard -->
|
||||||
|
<div class="flex-1 w-full mt-8 lg:mt-0">
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{% macro stat_card(label, metric_key, format_str, icon) %}
|
||||||
|
{% set dist = distribution[metric_key] if distribution else None %}
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-700/50 rounded-xl p-5 border border-gray-100 dark:border-slate-600 relative overflow-hidden group hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
{{ icon }} {{ label }}
|
||||||
|
</div>
|
||||||
|
{% if dist %}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold
|
||||||
|
{% if dist.rank == 1 %}bg-yellow-100 text-yellow-800 border border-yellow-200
|
||||||
|
{% elif dist.rank <= 3 %}bg-gray-100 text-gray-800 border border-gray-200
|
||||||
|
{% else %}bg-slate-100 text-slate-600 border border-slate-200{% endif %}">
|
||||||
|
Rank #{{ dist.rank }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-3xl font-black text-gray-900 dark:text-white mb-3">
|
||||||
|
{{ format_str.format(dist.val if dist else 0) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distribution Bar -->
|
||||||
|
{% if dist %}
|
||||||
|
<div class="w-full h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full overflow-hidden relative">
|
||||||
|
<!-- Range: Min to Max -->
|
||||||
|
{% set range = dist.max - dist.min %}
|
||||||
|
{% set percent = ((dist.val - dist.min) / range * 100) if range > 0 else 100 %}
|
||||||
|
<div class="absolute h-full bg-yrtv-500 rounded-full transition-all duration-1000" style="width: {{ percent }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-[10px] text-gray-400 mt-1 font-mono">
|
||||||
|
<span>{{ format_str.format(dist.min) }}</span>
|
||||||
|
<span>Avg: {{ format_str.format(dist.avg) }}</span>
|
||||||
|
<span>{{ format_str.format(dist.max) }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-xs text-gray-400">No team data</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{{ stat_card('Rating', 'rating', '{:.2f}', '⭐') }}
|
||||||
|
{{ stat_card('K/D Ratio', 'kd', '{:.2f}', '🔫') }}
|
||||||
|
{{ stat_card('ADR', 'adr', '{:.1f}', '🔥') }}
|
||||||
|
{{ stat_card('KAST', 'kast', '{:.1%}', '🛡️') }} <!-- Note: KAST is stored as 0-1, formatted as % -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-center sm:mt-0 space-x-2">
|
|
||||||
{% if session.get('is_admin') %}
|
|
||||||
<button onclick="document.getElementById('editProfileModal').classList.remove('hidden')" class="flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 dark:bg-slate-700 dark:text-white dark:border-slate-600 dark:hover:bg-slate-600">
|
|
||||||
Edit Profile
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Modal -->
|
<!-- 2. Charts Section (Middle) -->
|
||||||
<div id="editProfileModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-slate-800">
|
<!-- Trend Chart -->
|
||||||
<div class="mt-3 text-center">
|
<div class="lg:col-span-2 bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">Edit Profile</h3>
|
<div class="flex justify-between items-center mb-6">
|
||||||
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="mt-2 px-7 py-3" enctype="multipart/form-data">
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<input type="hidden" name="admin_action" value="update_profile">
|
<span>📈</span> 近期表现走势 (Performance Trend)
|
||||||
|
</h3>
|
||||||
<div class="mb-4">
|
<!-- Simple Range Filter (Visual Only for now, could be wired to JS) -->
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Avatar</label>
|
<div class="flex bg-gray-100 dark:bg-slate-700 rounded-lg p-1">
|
||||||
<input type="file" name="avatar" accept="image/*" class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-yrtv-50 file:text-yrtv-700 hover:file:bg-yrtv-100 dark:text-gray-300 dark:file:bg-slate-700 dark:file:text-white">
|
<button class="px-3 py-1 text-xs font-bold rounded-md bg-white dark:bg-slate-600 shadow-sm text-gray-800 dark:text-white">Recent 20</button>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 text-left">Supported: JPG, PNG. Will replace existing.</p>
|
<!-- <button class="px-3 py-1 text-xs font-medium rounded-md text-gray-500 hover:text-gray-900">All Time</button> -->
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mb-4">
|
<div class="relative h-80 w-full">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Notes</label>
|
<canvas id="trendChart"></canvas>
|
||||||
<textarea name="notes" rows="3" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 dark:bg-slate-700 dark:text-white dark:border-slate-600">{{ metadata.notes }}</textarea>
|
</div>
|
||||||
</div>
|
<div class="mt-4 flex justify-center gap-6 text-xs text-gray-500">
|
||||||
<div class="items-center px-4 py-3">
|
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-green-500/20 border border-green-500"></span> Carry (>1.5)</div>
|
||||||
<button type="submit" class="px-4 py-2 bg-yrtv-600 text-white text-base font-medium rounded-md w-full shadow-sm hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-yrtv-500">
|
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-yellow-500/20 border border-yellow-500"></span> Normal (1.0-1.5)</div>
|
||||||
Save
|
<div class="flex items-center gap-1"><span class="w-3 h-3 rounded-full bg-red-500/20 border border-red-500"></span> Poor (<0.6)</div>
|
||||||
</button>
|
|
||||||
<button type="button" onclick="document.getElementById('editProfileModal').classList.add('hidden')" class="mt-3 px-4 py-2 bg-gray-100 text-gray-700 text-base font-medium rounded-md w-full shadow-sm hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:bg-slate-700 dark:text-white dark:hover:bg-slate-600">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Layout Reorder: Trend First -->
|
<!-- Radar Chart -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700 flex flex-col">
|
||||||
<!-- Trend Chart (Full Width) -->
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
<span>🕸️</span> 能力六维图 (Capabilities)
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
|
|
||||||
<span class="mr-2">📈</span>近期 Rating 走势 (Trend)
|
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
<div class="relative flex-1 min-h-[300px] flex items-center justify-center">
|
||||||
<div class="relative h-72">
|
|
||||||
<canvas id="trendChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Grid: Stats + Radar -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<!-- Left: Stats Cards -->
|
|
||||||
<div class="lg:col-span-2 grid grid-cols-2 gap-4 h-fit">
|
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 text-center">
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Rating</dt>
|
|
||||||
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.2f"|format((features.basic_avg_rating if features else 0) or 0) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 text-center">
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">K/D</dt>
|
|
||||||
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.2f"|format((features.basic_avg_kd if features else 0) or 0) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 text-center">
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">ADR</dt>
|
|
||||||
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.1f"|format((features.basic_avg_adr if features else 0) or 0) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-4 text-center">
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">KAST</dt>
|
|
||||||
<dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ "%.1f"|format((features.basic_avg_kast if features else 0) * 100) }}%</dd>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: Radar -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6 flex flex-col items-center justify-center">
|
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4 w-full text-left">能力六维图</h3>
|
|
||||||
<div class="relative h-64 w-full">
|
|
||||||
<canvas id="radarChart"></canvas>
|
<canvas id="radarChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Match History (L2) -->
|
</div>
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">比赛记录 (History - {{ history|length }})</h3>
|
<!-- 3. Match History & Comments (Bottom) -->
|
||||||
<div class="overflow-x-auto max-h-96 overflow-y-auto">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 relative">
|
<!-- Match History Table -->
|
||||||
<thead class="bg-gray-50 dark:bg-slate-700 sticky top-0">
|
<div class="lg:col-span-2 bg-white dark:bg-slate-800 shadow-lg rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700">
|
||||||
<tr>
|
<div class="p-6 border-b border-gray-100 dark:border-slate-700 flex justify-between items-center">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Date</th>
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white">比赛记录 (Match History)</h3>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Map</th>
|
<span class="px-2.5 py-0.5 rounded-full text-xs font-bold bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-300">
|
||||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Result</th>
|
{{ history|length }} Matches
|
||||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Party</th>
|
</span>
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Rating</th>
|
</div>
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">K/D</th>
|
<div class="overflow-x-auto max-h-[600px] overflow-y-auto custom-scroll">
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ADR</th>
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Link</th>
|
<thead class="bg-gray-50 dark:bg-slate-700/50 sticky top-0 backdrop-blur-sm z-10">
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
<th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Date/Map</th>
|
||||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Result</th>
|
||||||
{% for m in history | reverse %}
|
<th class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">Rating</th>
|
||||||
<tr>
|
<th class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">K/D</th>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
<th class="px-6 py-3 text-right text-xs font-bold text-gray-500 uppercase tracking-wider">ADR</th>
|
||||||
<script>document.write(new Date({{ m.start_time }} * 1000).toLocaleDateString())</script>
|
<th class="px-6 py-3 text-center text-xs font-bold text-gray-500 uppercase tracking-wider">Link</th>
|
||||||
</td>
|
</tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{{ m.map_name }}</td>
|
</thead>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
<tbody class="divide-y divide-gray-100 dark:divide-slate-700 bg-white dark:bg-slate-800">
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if m.is_win %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}">
|
{% for m in history | reverse %}
|
||||||
{{ 'WIN' if m.is_win else 'LOSS' }}
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors group">
|
||||||
</span>
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
</td>
|
<div class="text-sm font-bold text-gray-900 dark:text-white">{{ m.map_name }}</div>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 font-mono">
|
||||||
{% if m.party_size and m.party_size > 1 %}
|
<script>document.write(new Date({{ m.start_time }} * 1000).toLocaleDateString())</script>
|
||||||
{% set p = m.party_size %}
|
</div>
|
||||||
{% set party_class = 'bg-gray-100 text-gray-800' %}
|
</td>
|
||||||
{% if p == 2 %} {% set party_class = 'bg-indigo-100 text-indigo-800' %}
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
{% elif p == 3 %} {% set party_class = 'bg-blue-100 text-blue-800' %}
|
<div class="flex flex-col items-center gap-1">
|
||||||
{% elif p == 4 %} {% set party_class = 'bg-purple-100 text-purple-800' %}
|
<span class="px-2.5 py-0.5 rounded text-[10px] font-black uppercase tracking-wide
|
||||||
{% elif p >= 5 %} {% set party_class = 'bg-orange-100 text-orange-800' %}
|
{% if m.is_win %}bg-green-100 text-green-700 border border-green-200
|
||||||
{% endif %}
|
{% else %}bg-red-50 text-red-600 border border-red-100{% endif %}">
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ party_class }}">
|
{{ 'WIN' if m.is_win else 'LOSS' }}
|
||||||
👥 {{ m.party_size }}
|
</span>
|
||||||
</span>
|
{% if m.party_size and m.party_size > 1 %}
|
||||||
{% else %}
|
<span class="text-[10px] text-gray-400 flex items-center gap-0.5" title="Party Size">
|
||||||
<span class="text-xs text-gray-400">Solo</span>
|
👥 {{ m.party_size }}
|
||||||
{% endif %}
|
</span>
|
||||||
</td>
|
{% endif %}
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right font-bold {% if (m.rating or 0) >= 1.1 %}text-green-600{% elif (m.rating or 0) < 0.9 %}text-red-600{% else %}text-gray-900 dark:text-white{% endif %}">{{ "%.2f"|format(m.rating or 0) }}</td>
|
</div>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.2f"|format(m.kd_ratio or 0) }}</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">{{ "%.1f"|format(m.adr or 0) }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right font-medium">
|
{% set r = m.rating or 0 %}
|
||||||
<a href="{{ url_for('matches.detail', match_id=m.match_id) }}" class="text-yrtv-600 hover:text-yrtv-900">View</a>
|
<div class="flex items-center justify-end gap-2">
|
||||||
</td>
|
<span class="text-sm font-bold font-mono {% if r >= 1.5 %}text-yrtv-600{% elif r >= 1.1 %}text-green-600{% elif r < 0.6 %}text-red-500{% else %}text-gray-700 dark:text-gray-300{% endif %}">
|
||||||
</tr>
|
{{ "%.2f"|format(r) }}
|
||||||
{% else %}
|
</span>
|
||||||
<tr>
|
<!-- Mini Bar -->
|
||||||
<td colspan="6" class="px-6 py-4 text-center text-gray-500">No recent matches found.</td>
|
<div class="w-12 h-1 bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||||
</tr>
|
<div class="h-full {% if r >= 1.1 %}bg-green-500{% elif r < 0.9 %}bg-red-500{% else %}bg-gray-400{% endif %}" style="width: {{ (r / 2.0 * 100)|int }}%"></div>
|
||||||
{% endfor %}
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{{ "%.2f"|format(m.kd_ratio or 0) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{{ "%.1f"|format(m.adr or 0) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<a href="{{ url_for('matches.detail', match_id=m.match_id) }}" class="p-2 text-gray-400 hover:text-yrtv-600 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-12 text-center text-gray-400">
|
||||||
|
<div class="text-4xl mb-2">🏜️</div>
|
||||||
|
No matches recorded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reviews / Comments -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-2xl p-6 border border-gray-100 dark:border-slate-700">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-6">留言板 (Comments)</h3>
|
||||||
|
|
||||||
|
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="mb-8 relative">
|
||||||
|
<input type="text" name="username" class="absolute top-2 left-2 text-xs border-none bg-transparent focus:ring-0 text-gray-500 w-full" placeholder="Name (Optional)">
|
||||||
|
<textarea name="content" rows="3" required class="block w-full pt-8 pb-2 px-3 border border-gray-200 dark:border-slate-600 rounded-xl bg-gray-50 dark:bg-slate-700/50 focus:ring-2 focus:ring-yrtv-500 focus:bg-white dark:focus:bg-slate-700 transition" placeholder="Write a comment..."></textarea>
|
||||||
|
<button type="submit" class="absolute bottom-2 right-2 px-3 py-1 bg-yrtv-600 text-white text-xs font-bold rounded-lg hover:bg-yrtv-700 transition shadow-sm">Post</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="space-y-4 max-h-[500px] overflow-y-auto custom-scroll pr-2">
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="flex gap-3 group">
|
||||||
|
<div class="flex-shrink-0 mt-1">
|
||||||
|
<div class="h-8 w-8 rounded-full bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center text-gray-500 text-xs font-bold border border-white shadow-sm">
|
||||||
|
{{ comment.username[:1] | upper }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 bg-gray-50 dark:bg-slate-700/30 rounded-r-xl rounded-bl-xl p-3 text-sm hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
|
<div class="flex justify-between items-baseline mb-1">
|
||||||
|
<span class="font-bold text-gray-900 dark:text-white">{{ comment.username }}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ comment.created_at }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 leading-relaxed">{{ comment.content }}</p>
|
||||||
|
<div class="mt-2 flex justify-end">
|
||||||
|
<button onclick="likeComment({{ comment.id }}, this)" class="text-xs text-gray-400 hover:text-red-500 flex items-center gap-1 transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/></svg>
|
||||||
|
<span class="like-count font-bold">{{ comment.likes }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8 text-gray-400 text-sm">No comments yet.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg p-6">
|
</div>
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-6">玩家评价 ({{ comments|length }})</h3>
|
|
||||||
|
<!-- Edit Modal (Hidden) -->
|
||||||
<!-- Comment Form -->
|
<div id="editProfileModal" class="hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||||
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" class="mb-8">
|
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-md p-6 m-4 animate-scale-in">
|
||||||
<div class="mb-4">
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Edit Profile</h3>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Your Name (Optional)</label>
|
<form action="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" method="POST" enctype="multipart/form-data">
|
||||||
<input type="text" name="username" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 dark:bg-slate-700 dark:text-white" placeholder="Anonymous">
|
<input type="hidden" name="admin_action" value="update_profile">
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">Avatar</label>
|
||||||
|
<input type="file" name="avatar" accept="image/*" class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-bold file:bg-yrtv-50 file:text-yrtv-700 hover:file:bg-yrtv-100">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1">Notes</label>
|
||||||
|
<textarea name="notes" rows="3" class="w-full border-gray-300 rounded-lg shadow-sm focus:border-yrtv-500 focus:ring-yrtv-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white">{{ metadata.notes }}</textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Comment</label>
|
<div class="mt-6 flex gap-3">
|
||||||
<textarea name="content" rows="3" required class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 dark:bg-slate-700 dark:text-white" placeholder="Share your thoughts..."></textarea>
|
<button type="button" onclick="document.getElementById('editProfileModal').classList.add('hidden')" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-bold transition">Cancel</button>
|
||||||
|
<button type="submit" class="flex-1 px-4 py-2 bg-yrtv-600 text-white rounded-lg hover:bg-yrtv-700 font-bold shadow-lg shadow-yrtv-500/30 transition">Save Changes</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="px-4 py-2 bg-yrtv-600 text-white rounded hover:bg-yrtv-700">Submit Review</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Comment List -->
|
|
||||||
<div class="space-y-6">
|
|
||||||
{% for comment in comments %}
|
|
||||||
<div class="flex space-x-4 p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<span class="inline-block h-10 w-10 rounded-full overflow-hidden bg-gray-100">
|
|
||||||
<svg class="h-full w-full text-gray-300" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 space-y-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ comment.username }}</h3>
|
|
||||||
<p class="text-sm text-gray-500">{{ comment.created_at }}</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-300">{{ comment.content }}</p>
|
|
||||||
|
|
||||||
<div class="mt-2 flex items-center space-x-2">
|
|
||||||
<button onclick="likeComment({{ comment.id }}, this)" class="flex items-center text-gray-400 hover:text-red-500">
|
|
||||||
<svg class="h-5 w-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
|
||||||
</svg>
|
|
||||||
<span class="like-count">{{ comment.likes }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-gray-500 text-center">No comments yet. Be the first!</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
let trendChartInstance = null;
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
if (trendChartInstance) {
|
||||||
|
trendChartInstance.resetZoom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function likeComment(commentId, btn) {
|
function likeComment(commentId, btn) {
|
||||||
fetch(`/players/comment/${commentId}/like`, { method: 'POST' })
|
fetch(`/players/comment/${commentId}/like`, { method: 'POST' })
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
@@ -274,28 +318,58 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
fetch(`/players/${steamId}/charts_data`)
|
fetch(`/players/${steamId}/charts_data`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
// Register Zoom Plugin Manually if needed (usually auto-registers in UMD)
|
||||||
|
if (window.ChartZoom) {
|
||||||
|
Chart.register(window.ChartZoom);
|
||||||
|
}
|
||||||
|
|
||||||
// Radar Chart
|
// Radar Chart
|
||||||
const ctxRadar = document.getElementById('radarChart').getContext('2d');
|
const ctxRadar = document.getElementById('radarChart').getContext('2d');
|
||||||
new Chart(ctxRadar, {
|
new Chart(ctxRadar, {
|
||||||
type: 'radar',
|
type: 'radar',
|
||||||
data: {
|
data: {
|
||||||
labels: ['STA', 'BAT', 'HPS', 'PTL', 'SIDE', 'UTIL'],
|
// Update labels to friendly names
|
||||||
|
labels: ['Aim (BAT)', 'Clutch (HPS)', 'Pistol (PTL)', 'Defense (SIDE)', 'Util (UTIL)', 'Rating (STA)'],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Ability',
|
label: 'Ability',
|
||||||
data: [
|
data: [
|
||||||
data.radar.STA, data.radar.BAT, data.radar.HPS,
|
data.radar.BAT, data.radar.HPS,
|
||||||
data.radar.PTL, data.radar.SIDE, data.radar.UTIL
|
data.radar.PTL, data.radar.SIDE, data.radar.UTIL,
|
||||||
|
data.radar.STA
|
||||||
],
|
],
|
||||||
backgroundColor: 'rgba(124, 58, 237, 0.2)',
|
backgroundColor: 'rgba(124, 58, 237, 0.2)',
|
||||||
borderColor: 'rgba(124, 58, 237, 1)',
|
borderColor: '#7c3aed',
|
||||||
pointBackgroundColor: 'rgba(124, 58, 237, 1)',
|
borderWidth: 2,
|
||||||
|
pointBackgroundColor: '#7c3aed',
|
||||||
|
pointBorderColor: '#fff',
|
||||||
|
pointHoverBackgroundColor: '#fff',
|
||||||
|
pointHoverBorderColor: '#7c3aed'
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
r: {
|
r: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
suggestedMax: 2.0 // Adjust based on data range
|
suggestedMax: 1.5,
|
||||||
|
angleLines: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.2)'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.2)'
|
||||||
|
},
|
||||||
|
pointLabels: {
|
||||||
|
font: {
|
||||||
|
size: 11,
|
||||||
|
weight: 'bold'
|
||||||
|
},
|
||||||
|
color: '#6b7280' // gray-500
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
display: false // Hide numbers on axis
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,25 +377,135 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Trend Chart
|
// Trend Chart
|
||||||
const ctxTrend = document.getElementById('trendChart').getContext('2d');
|
const ctxTrend = document.getElementById('trendChart').getContext('2d');
|
||||||
new Chart(ctxTrend, {
|
|
||||||
|
// Create Gradient
|
||||||
|
const gradient = ctxTrend.createLinearGradient(0, 0, 0, 400);
|
||||||
|
gradient.addColorStop(0, 'rgba(124, 58, 237, 0.5)'); // Purple
|
||||||
|
gradient.addColorStop(1, 'rgba(124, 58, 237, 0.0)');
|
||||||
|
|
||||||
|
trendChartInstance = new Chart(ctxTrend, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: data.trend.labels,
|
labels: data.trend.labels,
|
||||||
datasets: [{
|
datasets: [
|
||||||
label: 'Rating',
|
{
|
||||||
data: data.trend.values,
|
label: 'Rating',
|
||||||
borderColor: 'rgba(16, 185, 129, 1)',
|
data: data.trend.values,
|
||||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
borderColor: '#7c3aed', // YRTV Purple
|
||||||
tension: 0.1,
|
backgroundColor: gradient,
|
||||||
fill: true
|
borderWidth: 2,
|
||||||
}]
|
tension: 0.4, // Smoother curve
|
||||||
|
pointRadius: 3,
|
||||||
|
pointBackgroundColor: '#fff',
|
||||||
|
pointBorderColor: '#7c3aed',
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointHoverBackgroundColor: '#7c3aed',
|
||||||
|
pointHoverBorderColor: '#fff',
|
||||||
|
fill: true,
|
||||||
|
order: 1
|
||||||
|
},
|
||||||
|
// Baselines
|
||||||
|
{
|
||||||
|
label: 'Carry (1.5)',
|
||||||
|
data: Array(data.trend.labels.length).fill(1.5),
|
||||||
|
borderColor: 'rgba(34, 197, 94, 0.6)', // Green
|
||||||
|
borderWidth: 1,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
order: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Normal (1.0)',
|
||||||
|
data: Array(data.trend.labels.length).fill(1.0),
|
||||||
|
borderColor: 'rgba(234, 179, 8, 0.6)', // Yellow
|
||||||
|
borderWidth: 1,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
order: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Poor (0.6)',
|
||||||
|
data: Array(data.trend.labels.length).fill(0.6),
|
||||||
|
borderColor: 'rgba(239, 68, 68, 0.6)', // Red
|
||||||
|
borderWidth: 1,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
order: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
pan: {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'x',
|
||||||
|
modifierKey: null, // Allow plain drag
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
wheel: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
pinch: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
mode: 'x',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(17, 24, 39, 0.9)',
|
||||||
|
titleFont: { size: 12 },
|
||||||
|
bodyFont: { size: 14, weight: 'bold' },
|
||||||
|
padding: 12,
|
||||||
|
cornerRadius: 8,
|
||||||
|
displayColors: false, // Cleaner look
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
if (context.datasetIndex > 0) return null; // Hide baseline tooltips
|
||||||
|
let val = context.parsed.y.toFixed(2);
|
||||||
|
let label = "Rating: " + val;
|
||||||
|
if (val >= 1.5) label += " 🔥";
|
||||||
|
else if (val < 0.6) label += " 💀";
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: false
|
beginAtZero: true,
|
||||||
|
suggestedMax: 2.0,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(156, 163, 175, 0.1)',
|
||||||
|
borderDash: [2, 2]
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
font: { size: 10 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
maxRotation: 0, // Keep labels horizontal
|
||||||
|
minRotation: 0,
|
||||||
|
autoSkip: true,
|
||||||
|
maxTicksLimit: 10, // Avoid crowding
|
||||||
|
font: { size: 10 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,4 +513,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||