Files
SREBOT-web/views/squadron-profile.ejs

3059 lines
138 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="<%= lang %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= squadronData.short_name || squadronData.tag_name %> - Squadron Profile | <%= botName %></title>
<meta name="description" content="View <%= squadronData.long_name || squadronData.tag_name || squadronData.squadron_name %>'s squadron statistics and member performance.">
<link rel="icon" type="image/png" href="/images/transparent_toothlessssss.png">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preload" href="/Fonts/symbols_skyquake.ttf" as="font" type="font/ttf" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<link rel="stylesheet" href="/css/output.css">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" media="print" onload="this.media='all'">
<style>
@font-face {
font-family: 'skyquakesymbols';
src: url('/Fonts/symbols_skyquake.ttf') format('truetype');
font-display: swap;
}
body {
background: #1b1b1b;
min-height: 100vh;
}
.text-accent { color: #F5F5DC; }
.text-muted { color: #90EE90; }
.btn-primary {
background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%);
box-shadow: 0 4px 20px rgba(245, 245, 220, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.4);
color: #1E1E1E;
font-weight: 700;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-primary:hover {
box-shadow: 0 8px 25px rgba(245, 245, 220, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
}
.season-recap-modal.hidden { display: none; }
.season-recap-modal { position: fixed; inset: 0; z-index: 1000; overflow-y: auto; }
.season-recap-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); }
.season-recap-content {
position: relative;
width: min(92vw, 1100px);
margin: 2vh auto;
background: #1a1f2e;
color: #e2e8f0;
padding: 0.85rem 1.25rem 1rem;
border-radius: 0.75rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.season-recap-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.6rem; }
.season-recap-header h2 { margin: 0; font-size: 1.2rem; }
.season-recap-close { background: transparent; border: 0; color: #e2e8f0; font-size: 1.75rem; cursor: pointer; line-height: 1; }
.season-recap-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1.25rem; margin-bottom: 0.5rem; }
.season-recap-controls > div { display: flex; flex-direction: column; }
.season-recap-body label { display: block; margin-bottom: 0.15rem; color: #94a3b8; font-size: 0.85rem; }
.season-recap-theme-group { display: inline-flex; gap: 0.5rem; font-size: 0.9rem; }
.season-recap-theme-group label { margin: 0; color: #e2e8f0; font-size: 0.9rem; cursor: pointer; }
#season-recap-btn .fa-image { margin-right: 0.45rem; }
.season-recap-body select {
padding: 0.4rem 0.6rem;
border-radius: 0.35rem;
margin-right: 0.5rem;
background: #0f172a;
color: #e2e8f0;
border: 1px solid rgba(144,238,144,0.3);
min-width: 12rem;
font: inherit;
}
.season-recap-body select option { background: #0f172a; color: #e2e8f0; }
#season-recap-status { margin: 0.75rem 0; color: #94a3b8; font-size: 0.9rem; }
#season-recap-image-wrap { margin-top: 0.75rem; }
#season-recap-image {
display: none;
max-width: 100%;
max-height: 75vh;
object-fit: contain;
border-radius: 0.35rem;
}
.squadron-container {
max-width: none;
margin: 0 auto;
padding: 5rem 1rem 2rem;
min-height: calc(100vh - 200px);
width: 100%;
}
.squadron-header {
background: linear-gradient(135deg, rgba(30, 30, 30, 0.8), rgba(40, 40, 40, 0.8));
border-radius: 1rem;
padding: 2rem;
margin-bottom: 2rem;
color: white;
text-align: center;
border: 1px solid rgba(144, 238, 144, 0.1);
}
.squadron-title-section {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.squadron-name {
font-family: 'skyquakesymbols', 'Inter', sans-serif !important;
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.squadron-stats-summary {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 1.5rem;
}
@media (max-width: 1200px) {
.squadron-stats-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.squadron-stats-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.stat-card {
background: rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
padding: 0.75rem;
text-align: center;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
justify-content: center;
min-height: 78px;
}
.stat-number {
font-size: 1.3rem;
font-weight: 600;
display: block;
}
.stat-label {
font-size: 0.8rem;
opacity: 0.8;
margin-top: 0.2rem;
}
.members-section {
background: rgba(30, 30, 30, 0.6);
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
border: 1px solid rgba(144, 238, 144, 0.1);
}
.section-title {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #ffffff;
}
.members-table {
width: 100%;
border-collapse: collapse;
background: rgba(30, 30, 30, 0.8);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(144, 238, 144, 0.1);
}
.members-table th {
background: linear-gradient(135deg, rgba(144, 238, 144, 0.2), rgba(0, 255, 107, 0.2));
color: #90EE90;
padding: 1.25rem;
text-align: center;
font-weight: 600;
border-bottom: 1px solid rgba(144, 238, 144, 0.2);
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.5px;
text-shadow: 0 0 10px rgba(144, 238, 144, 0.3);
cursor: pointer;
transition: all 0.3s ease;
user-select: none;
}
.members-table th:hover {
background: linear-gradient(135deg, rgba(144, 238, 144, 0.3), rgba(0, 255, 107, 0.3));
}
.sortable {
position: relative;
}
.sort-icon {
font-size: 0.8rem;
opacity: 0.6;
margin-left: 0.5rem;
}
.sortable.sorted-asc .sort-icon {
opacity: 1;
}
.sortable.sorted-asc .sort-icon::before {
content: "\f0de";
}
.sortable.sorted-desc .sort-icon {
opacity: 1;
}
.sortable.sorted-desc .sort-icon::before {
content: "\f0dd";
}
.members-table th:first-child {
text-align: left;
}
.members-table td {
padding: 1.25rem;
border-bottom: 1px solid rgba(144, 238, 144, 0.1);
background: rgba(30, 30, 30, 0.4);
color: #ffffff;
transition: all 0.3s ease;
text-align: center;
vertical-align: middle;
}
.members-table td:first-child {
text-align: left;
}
.members-table tr:hover td {
background: rgba(144, 238, 144, 0.05);
cursor: pointer;
}
.player-name {
font-weight: 600;
color: #90EE90;
font-size: 1.1rem;
text-shadow: 0 0 10px rgba(144, 238, 144, 0.3);
white-space: nowrap;
vertical-align: middle;
}
.player-name .pdm-details-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid rgba(144,238,144,0.15);
color: rgba(144,238,144,0.5);
width: 22px; height: 22px;
border-radius: 4px;
cursor: pointer;
font-size: 0.65rem;
margin-left: 0.5rem;
vertical-align: middle;
padding: 0;
flex-shrink: 0;
transition: all 0.2s;
}
.player-name .pdm-details-btn:hover {
background: rgba(144,238,144,0.12);
color: #90EE90;
border-color: rgba(144,238,144,0.4);
}
.stat-cell {
font-weight: 600;
font-size: 1rem;
color: #90EE90;
text-shadow: 0 0 10px rgba(144, 238, 144, 0.5);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
margin-bottom: 2rem;
transition: color 0.2s;
}
.back-link:hover {
color: var(--accent-color);
}
.responsive-table {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Filter Controls Styling */
.leaderboard-controls {
background: rgba(30, 30, 30, 0.6);
border-radius: 1rem;
padding: 1.5rem;
backdrop-filter: blur(10px);
border: 1px solid rgba(144, 238, 144, 0.2);
}
.controls-row {
display: flex;
gap: 1rem;
align-items: end;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-group label {
color: #ffffff;
font-weight: 600;
font-size: 0.9rem;
}
.filter-select, .filter-input {
background: rgba(30, 30, 30, 0.9);
border: 2px solid rgba(144, 238, 144, 0.3);
border-radius: 0.75rem;
padding: 0.625rem 0.875rem;
color: #ffffff;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.3s ease;
min-width: 140px;
}
.filter-select:focus, .filter-input:focus {
outline: none;
border-color: #90EE90;
box-shadow: 0 0 20px rgba(144, 238, 144, 0.4);
}
.filter-select:hover, .filter-input:hover {
border-color: rgba(144, 238, 144, 0.5);
}
.filter-select option {
background: rgba(30, 30, 30, 0.95);
color: #ffffff;
}
.btn-action {
background: linear-gradient(135deg, rgba(144, 238, 144, 0.3), rgba(0, 255, 107, 0.3));
border: 2px solid #90EE90;
color: #90EE90;
padding: 0.625rem 1.5rem;
border-radius: 0.75rem;
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
transition: all 0.3s ease;
white-space: nowrap;
}
.btn-action:hover {
background: linear-gradient(135deg, rgba(144, 238, 144, 0.4), rgba(0, 255, 107, 0.4));
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(144, 238, 144, 0.4);
}
.clear-filters-btn {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.2));
border: 2px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 0.625rem 1.5rem;
border-radius: 0.75rem;
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
transition: all 0.3s ease;
white-space: nowrap;
}
.clear-filters-btn:hover {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.3), rgba(220, 38, 38, 0.3));
border-color: #ef4444;
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
}
.squadron-chart-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(144, 238, 144, 0.12);
}
.sq-time-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(144, 238, 144, 0.15);
}
.sq-time-filter-bar .filter-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.sq-time-filter-bar label {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
font-size: 0.85rem;
white-space: nowrap;
}
.sq-time-filter-bar select,
.sq-time-filter-bar input[type="date"] {
background: rgba(30, 30, 30, 0.9);
border: 1px solid rgba(144, 238, 144, 0.25);
border-radius: 0.5rem;
padding: 0.45rem 0.7rem;
color: #ffffff;
font-size: 0.9rem;
min-width: 140px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
font-family: inherit;
}
.sq-time-filter-bar select:focus,
.sq-time-filter-bar input[type="date"]:focus {
outline: none;
border-color: #90EE90;
box-shadow: 0 0 15px rgba(144, 238, 144, 0.3);
}
.sq-time-filter-bar select option {
background: rgba(30, 30, 30, 0.95);
color: #ffffff;
}
.sq-time-filter-bar input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(1) opacity(0.6);
cursor: pointer;
}
.sq-time-filter-bar .custom-range {
flex-direction: row;
align-items: end;
gap: 0.5rem;
}
.sq-time-filter-bar .filter-reset-btn {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.2));
border: 1px solid rgba(239, 68, 68, 0.4);
color: #ef4444;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
transition: all 0.25s ease;
}
.sq-time-filter-bar .filter-reset-btn:hover {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.35), rgba(220, 38, 38, 0.35));
border-color: #ef4444;
}
.chart-toggles-wrap {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.chart-toggle-group {
position: relative;
display: inline-flex;
background: rgba(255, 255, 255, 0.05);
border-radius: 2rem;
padding: 3px;
border: 1px solid rgba(144, 238, 144, 0.2);
}
.chart-toggle-slider {
position: absolute;
top: 3px;
left: 3px;
height: calc(100% - 6px);
background: #90EE90;
border-radius: calc(2rem - 2px);
transition: left 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.chart-toggle {
position: relative;
z-index: 1;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.45);
padding: 0.4rem 1.3rem;
border-radius: calc(2rem - 2px);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
transition: color 0.25s ease;
white-space: nowrap;
}
.chart-toggle.active {
color: #1b1b1b;
}
.chart-toggle:hover:not(.active) {
color: rgba(255, 255, 255, 0.8);
}
.chart-wrapper {
position: relative;
height: 220px;
}
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
height: 220px;
color: rgba(255, 255, 255, 0.35);
font-size: 0.88rem;
}
.chart-collapse {
display: flex;
justify-content: center;
align-items: center;
margin-top: 0.75rem;
cursor: pointer;
color: rgba(144, 238, 144, 0.35);
transition: color 0.2s;
padding: 0.2rem;
}
.chart-collapse:hover {
color: rgba(144, 238, 144, 0.75);
}
/* View Toggle (cumulative/individual) */
.section-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.section-header-row .section-title {
margin-bottom: 0;
}
.view-toggle-group {
position: relative;
display: inline-flex;
background: rgba(255, 255, 255, 0.05);
border-radius: 2rem;
padding: 3px;
border: 1px solid rgba(144, 238, 144, 0.2);
}
.view-toggle-slider {
position: absolute;
top: 3px;
left: 3px;
height: calc(100% - 6px);
background: #90EE90;
border-radius: calc(2rem - 2px);
transition: left 0.25s cubic-bezier(0.4, 0, 0.2, 1), width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.view-toggle {
position: relative;
z-index: 1;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.45);
padding: 0.35rem 1.1rem;
border-radius: calc(2rem - 2px);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: color 0.25s ease;
white-space: nowrap;
}
.view-toggle.active {
color: #1b1b1b;
}
.view-toggle:hover:not(.active) {
color: rgba(255, 255, 255, 0.8);
}
/* Games section */
.sq-games-section {
background: rgba(30, 30, 30, 0.6);
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
border: 1px solid rgba(144, 238, 144, 0.1);
}
.sq-games-loading, .sq-games-error, .sq-no-games {
text-align: center;
padding: 3rem;
color: #ffffff;
}
.sq-games-loading i {
font-size: 3rem;
color: #90EE90;
margin-bottom: 1rem;
}
.sq-games-table {
width: 100%;
min-width: 1100px;
border-collapse: collapse;
background: rgba(30, 30, 30, 0.8);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(144, 238, 144, 0.1);
table-layout: fixed;
}
.sq-games-table th {
background: linear-gradient(135deg, rgba(144, 238, 144, 0.2), rgba(0, 255, 107, 0.2));
color: #90EE90;
padding: 0.75rem;
text-align: center;
font-weight: 600;
border-bottom: 1px solid rgba(144, 238, 144, 0.2);
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.5px;
text-shadow: 0 0 10px rgba(144, 238, 144, 0.3);
cursor: pointer;
transition: all 0.3s ease;
}
.sq-games-table th:hover {
background: linear-gradient(135deg, rgba(144, 238, 144, 0.3), rgba(0, 255, 107, 0.3));
text-shadow: 0 0 15px rgba(144, 238, 144, 0.6);
}
.sq-games-table th.sortable::after {
content: '';
font-size: 0.8rem;
opacity: 0.6;
}
.sq-games-table th.sorted-asc::after {
content: ' ↑';
color: #90EE90;
opacity: 1;
}
.sq-games-table th.sorted-desc::after {
content: ' ↓';
color: #90EE90;
opacity: 1;
}
.sq-games-table th:first-child {
text-align: left;
}
/* Squadron games table column widths (8 columns) */
.sq-games-table th:nth-child(1),
.sq-games-table td:nth-child(1) { width: 15%; }
.sq-games-table th:nth-child(2),
.sq-games-table td:nth-child(2) { width: 22%; }
.sq-games-table th:nth-child(3),
.sq-games-table td:nth-child(3) { width: 10%; }
.sq-games-table th:nth-child(4),
.sq-games-table td:nth-child(4) { width: 10%; }
.sq-games-table th:nth-child(5),
.sq-games-table td:nth-child(5) { width: 10%; }
.sq-games-table th:nth-child(6),
.sq-games-table td:nth-child(6) { width: 10%; }
.sq-games-table th:nth-child(7),
.sq-games-table td:nth-child(7) { width: 10%; }
.sq-games-table th:nth-child(8),
.sq-games-table td:nth-child(8) { width: 13%; }
/* Per-row map background: paint on the tr so one image spans the row */
.sq-games-table tr[data-session-id] {
background-image:
linear-gradient(rgba(30, 30, 30, 0.92), rgba(30, 30, 30, 0.92)),
var(--bg-img, none);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.sq-games-table tr[data-session-id]:hover {
background-image:
linear-gradient(rgba(144, 238, 144, 0.05), rgba(144, 238, 144, 0.05)),
linear-gradient(rgba(30, 30, 30, 0.82), rgba(30, 30, 30, 0.82)),
var(--bg-img, none);
}
.sq-games-table td {
padding: 1rem 0.625rem;
border-bottom: 1px solid rgba(144, 238, 144, 0.1);
background: transparent;
color: #ffffff;
transition: all 0.3s ease;
text-align: center;
}
.sq-games-table td:first-child {
text-align: left;
}
.sq-games-table tr:last-child td {
border-bottom: none;
}
.sq-game-date {
font-weight: 600;
color: #90EE90;
text-shadow: 0 0 10px rgba(144, 238, 144, 0.3);
}
.sq-game-map {
font-weight: 600;
color: #A8E6CF;
text-shadow: 0 0 8px rgba(144, 238, 144, 0.2);
}
.sq-game-map i {
color: #90EE90;
font-size: 0.75rem;
margin-right: 0.4rem;
}
.sq-game-result {
font-weight: 700;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
.sq-game-result.win {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.sq-game-result.loss {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.sq-game-result.draw {
background: rgba(245, 158, 11, 0.2);
color: #f59e0b;
border: 1px solid rgba(245, 158, 11, 0.3);
}
.sq-games-filter-bar {
background: rgba(30, 30, 30, 0.9);
border-radius: 1rem;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(144, 238, 144, 0.15);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
}
.sq-games-filter-bar label {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
white-space: nowrap;
font-size: 0.9rem;
}
.sq-games-filter-bar input[type="text"] {
background: rgba(30, 30, 30, 0.9);
border: 1px solid rgba(144, 238, 144, 0.25);
border-radius: 0.5rem;
padding: 0.5rem 0.8rem;
color: #ffffff;
font-size: 0.9rem;
min-width: 180px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.sq-games-filter-bar input[type="text"]:focus {
outline: none;
border-color: #90EE90;
box-shadow: 0 0 15px rgba(144, 238, 144, 0.3);
}
.sq-games-filter-bar input[type="text"]::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.sq-games-filter-count {
margin-left: auto;
color: #90EE90;
font-weight: 700;
font-size: 1rem;
text-shadow: 0 0 15px rgba(144, 238, 144, 0.4);
padding: 0.4rem 0.8rem;
background: rgba(144, 238, 144, 0.08);
border-radius: 0.5rem;
border: 1px solid rgba(144, 238, 144, 0.3);
}
</style>
</head>
<body class="text-white antialiased">
<%- include('partials/nav', { activePage: '' }) %>
<div class="squadron-container">
<a href="/squadrons" class="back-link">
<i class="fas fa-arrow-left"></i>
<%= t('squadrons.backToSquadronHub') %>
</a>
<div class="squadron-header">
<div class="squadron-title-section">
<h1 class="squadron-name" style="font-family: 'skyquakesymbols', 'Inter', sans-serif !important;"><%= squadronData.tag_name || squadronData.squadron_name %><br><span style="font-size: 2rem;"><%= squadronData.long_name || '' %></span></h1>
</div>
<div style="font-size: 0.9rem; opacity: 0.8; margin-top: 0.5rem; font-style: italic;">
<i class="fas fa-info-circle" style="margin-right: 0.5rem;"></i>
<%= t('common.recordingSince') %>
</div>
<%
const summaryKps = typeof squadronData.squadron_summary.kps === 'number'
? squadronData.squadron_summary.kps.toFixed(2)
: (Number(squadronData.squadron_summary.kps || 0)).toFixed(2);
%>
<div class="squadron-stats-summary">
<% if (squadronData.squadron_summary.points && squadronData.squadron_summary.points.has_points_data) { %>
<div class="stat-card">
<span class="stat-number"><%= squadronData.squadron_summary.points.total_points.toLocaleString() %></span>
<div class="stat-label"><%= t('squadrons.squadronPoints') %></div>
</div>
<% } %>
<div class="stat-card">
<span class="stat-number"><%= squadronData.squadron_summary.player_count %></span>
<div class="stat-label"><%= t('common.members') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-total-battles"><%= squadronData.squadron_summary.total_battles %></span>
<div class="stat-label"><%= t('common.totalBattles') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-wins"><%= squadronData.squadron_summary.wins %></span>
<div class="stat-label"><%= t('common.wins') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-win-rate"><%= squadronData.squadron_summary.win_rate.toFixed(1) %>%</span>
<div class="stat-label"><%= t('common.winRate') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-total-kills"><%= squadronData.squadron_summary.total_kills %></span>
<div class="stat-label"><%= t('common.totalKills') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-kdr"><%= squadronData.squadron_summary.kdr.toFixed(2) %></span>
<div class="stat-label"><%= t('common.kdr') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-kps"><%= summaryKps %></span>
<div class="stat-label">KPS</div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-ground-kills"><%= squadronData.squadron_summary.ground_kills %></span>
<div class="stat-label"><%= t('common.groundKills') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-air-kills"><%= squadronData.squadron_summary.air_kills %></span>
<div class="stat-label"><%= t('common.airKills') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-assists"><%= squadronData.squadron_summary.assists %></span>
<div class="stat-label"><%= t('common.assists') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-deaths"><%= squadronData.squadron_summary.deaths %></span>
<div class="stat-label"><%= t('common.deaths') %></div>
</div>
<div class="stat-card">
<span class="stat-number" id="sq-stat-captures"><%= squadronData.squadron_summary.captures %></span>
<div class="stat-label"><%= t('common.captures') %></div>
</div>
</div>
<div style="margin-top: 1rem;">
<button type="button" id="season-recap-btn" class="btn btn-primary" <% if (!squadronData.clan_id) { %>disabled title="<%= t('seasonCard.buttonDisabledTitle') %>"<% } %>>
<i class="fas fa-image"></i> <%= t('seasonCard.buttonLabel') %>
</button>
</div>
<div class="squadron-chart-section" id="squadronChartSection" data-squadron="<%= squadronData.long_name || squadronData.tag_name %>">
<div class="chart-toggles-wrap">
<div class="chart-toggle-group" id="chartToggles">
<div class="chart-toggle-slider" id="chartToggleSlider"></div>
<button class="chart-toggle" data-metric="win_rate"><%= t('common.winRate') %></button>
<button class="chart-toggle" data-metric="kdr"><%= t('common.kdr') %></button>
<button class="chart-toggle" data-metric="battles"><%= t('common.battles') %></button>
<button class="chart-toggle active" data-metric="rating"><%= t('common.rating') %></button>
</div>
</div>
<div class="chart-wrapper" id="chartWrapper">
<div class="chart-loading" id="chartLoading"><%= t('player.loadingChartData') %></div>
<canvas id="squadronChart" style="display:none;"></canvas>
</div>
<div style="display:flex;justify-content:center;margin-top:0.5rem;" id="sqChartScaleWrap">
<div class="chart-toggle-group" id="sqChartScaleToggles" style="margin:0;">
<div class="chart-toggle-slider" id="sqChartScaleSlider"></div>
<button class="chart-toggle" data-scale="alltime"><%= t('player.allTime') %></button>
<button class="chart-toggle active" data-scale="relative"><%= t('player.relative') %></button>
</div>
</div>
<div class="chart-collapse" id="chartCollapseBtn" title="<%= t('player.collapseChart') %>">
<i class="fas fa-chevron-up" id="chartCollapseIcon"></i>
</div>
</div>
</div>
<div class="section-header-row">
<h2 class="section-title" id="viewSectionTitle"><%= t('squadrons.squadronMembers') %></h2>
<div class="view-toggle-group" id="viewToggles">
<div class="view-toggle-slider" id="viewToggleSlider"></div>
<button class="view-toggle active" data-page="cumulative"><%= t('player.cumulative') %></button>
<button class="view-toggle" data-page="games"><%= t('player.individual') %></button>
</div>
</div>
<!-- Time/date filter bar — shared between cumulative and games pages, mounted via JS -->
<div class="sq-time-filter-bar" id="sq-time-filter-bar">
<div class="filter-group">
<label for="sq-filter-category"><i class="fas fa-filter" style="margin-right: 0.3rem;"></i><%= t('player.filterBy') %></label>
<select id="sq-filter-category" onchange="updateSquadronFilterCategory()">
<option value="all"><%= t('player.allTime') %></option>
<option value="date"><%= t('player.dateRange') %></option>
<option value="season"><%= t('player.season') %></option>
<option value="week"><%= t('player.week') %></option>
</select>
</div>
<div class="filter-group" id="sq-date-type-filter" style="display: none;">
<label for="sq-date-filter-type"><i class="fas fa-calendar-alt" style="margin-right: 0.3rem;"></i><%= t('player.dateType') %></label>
<select id="sq-date-filter-type" onchange="handleSquadronDateTypeChange()">
<option value="last7"><%= t('player.last7Days') %></option>
<option value="last30"><%= t('player.last30Days') %></option>
<option value="last90"><%= t('player.last90Days') %></option>
<option value="custom"><%= t('player.customRange') %></option>
</select>
</div>
<div class="filter-group custom-range" id="sq-custom-range-filters" style="display: none;">
<div class="filter-group">
<label for="sq-start-date"><%= t('player.from') %></label>
<input type="date" id="sq-start-date" onchange="applySquadronFilters()">
</div>
<div class="filter-group">
<label for="sq-end-date"><%= t('player.to') %></label>
<input type="date" id="sq-end-date" onchange="applySquadronFilters()">
</div>
<div class="filter-group" id="sq-single-day-timeslot-filter" style="display: none;">
<label for="sq-single-day-timeslot"><%= t('player.timeslot') %></label>
<select id="sq-single-day-timeslot" onchange="applySquadronFilters()">
<option value="all"><%= t('player.fullDay') %></option>
<option value="na"><%= t('analytics.naTimeslot') %></option>
<option value="eu"><%= t('analytics.euTimeslot') %></option>
</select>
</div>
</div>
<div class="filter-group" id="sq-season-select-filter" style="display: none;">
<label for="sq-season"><%= t('player.selectSeason') %></label>
<select id="sq-season" onchange="applySquadronFilters()">
<option value=""><%= t('player.pleaseSelect') %></option>
</select>
</div>
<div class="filter-group" id="sq-week-season-filter" style="display: none;">
<label for="sq-week-season-select"><%= t('player.selectSeason') %></label>
<select id="sq-week-season-select" onchange="updateSquadronWeekOptions()">
<option value=""><%= t('player.pleaseSelect') %></option>
</select>
</div>
<div class="filter-group" id="sq-week-select-filter" style="display: none;">
<label for="sq-week"><%= t('player.selectWeek') %></label>
<select id="sq-week" onchange="applySquadronFilters()">
<option value=""><%= t('player.pleaseSelect') %></option>
</select>
</div>
<div class="filter-group" id="sq-reset-btn-group" style="display: none;">
<label>&nbsp;</label>
<button class="filter-reset-btn" onclick="resetSquadronFilters()" type="button">
<i class="fas fa-times-circle"></i> <%= t('player.resetFilters') %>
</button>
</div>
</div>
<div id="cumulative-page">
<div class="members-section">
<div class="sq-filter-mount" id="sq-filter-mount-cumulative"></div>
<% if (squadronData.players && squadronData.players.length > 0) { %>
<div class="responsive-table">
<table class="members-table">
<thead>
<tr>
<th class="sortable" onclick="sortMembersTable('nick')" data-sort="nick">
<%= t('common.player') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('sqb_points')" data-sort="sqb_points">
<%= t('common.points') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('total_battles')" data-sort="total_battles">
<%= t('common.battles') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('wins')" data-sort="wins">
<%= t('common.wins') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('win_rate')" data-sort="win_rate">
<%= t('common.winRate') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('total_kills')" data-sort="total_kills">
<%= t('common.totalKills') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('kps')" data-sort="kps">
<%= t('common.kps') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('kdr')" data-sort="kdr">
<%= t('common.kdr') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('ground_kills')" data-sort="ground_kills">
<%= t('common.groundKills') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('air_kills')" data-sort="air_kills">
<%= t('common.airKills') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('assists')" data-sort="assists">
<%= t('common.assists') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('deaths')" data-sort="deaths">
<%= t('common.deaths') %> <i class="fas fa-sort sort-icon"></i>
</th>
<th class="sortable" onclick="sortMembersTable('captures')" data-sort="captures">
<%= t('common.captures') %> <i class="fas fa-sort sort-icon"></i>
</th>
</tr>
</thead>
<tbody id="sq-members-table-body">
<% squadronData.players.forEach(player => { %>
<tr class="row-link">
<td class="player-name"><a href="/players/<%= player.uid %>" class="row-link-overlay" aria-label="View <%= player.nick %>"></a><%= player.nick %><button class="pdm-details-btn" onclick="event.stopPropagation(); openPlayerDetailsModal('<%= player.uid %>', '<%= player.nick.replace(/'/g, "\\'") %>')" title="<%= t('squadrons.quickDetails') %>"><i class="fas fa-eye"></i></button></td>
<td class="stat-cell"><%= player.sqb_points || 0 %></td>
<td class="stat-cell"><%= player.total_battles %></td>
<td class="stat-cell"><%= player.wins %></td>
<td class="stat-cell"><%= player.win_rate.toFixed(1) %>%</td>
<td class="stat-cell"><%= player.total_kills %></td>
<td class="stat-cell"><%= player.total_battles ? (player.total_kills / player.total_battles).toFixed(2) : '0.00' %></td>
<td class="stat-cell"><%= player.kdr.toFixed(2) %></td>
<td class="stat-cell"><%= player.ground_kills || 0 %></td>
<td class="stat-cell"><%= player.air_kills || 0 %></td>
<td class="stat-cell"><%= player.assists %></td>
<td class="stat-cell"><%= player.deaths || 0 %></td>
<td class="stat-cell"><%= player.captures %></td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } else { %>
<div style="text-align: center; padding: 3rem; color: rgba(255, 255, 255, 0.6);">
<i class="fas fa-users" style="font-size: 3rem; margin-bottom: 1rem;"></i>
<h3><%= t('squadrons.noMembersFound') %></h3>
<p><%= t('squadrons.noRecordedMembers') %></p>
</div>
<% } %>
</div>
</div>
<div id="games-page" style="display: none;">
<div class="sq-games-section">
<div class="sq-filter-mount" id="sq-filter-mount-games"></div>
<div class="sq-games-loading" id="sq-games-loading">
<i class="fas fa-spinner fa-spin"></i>
<p><%= t('squadrons.loadingSquadronGames') %></p>
</div>
<div class="sq-games-content" id="sq-games-content" style="display: none;">
<div class="sq-games-filter-bar">
<label for="sq-player-search"><i class="fas fa-user" style="margin-right: 0.3rem;"></i><%= t('common.player') %>:</label>
<input type="text" id="sq-player-search" placeholder="<%= t('common.searchPlayerByName') %>" oninput="filterSquadronGamesDebounced()">
<label for="sq-map-search"><i class="fas fa-map-marker-alt" style="margin-right: 0.3rem;"></i><%= t('common.map') %>:</label>
<input type="text" id="sq-map-search" placeholder="<%= t('squadrons.searchMapPlaceholder') %>" oninput="filterSquadronGamesDebounced()">
<div class="sq-games-filter-count"><span id="sq-filtered-count">0</span> <%= t('player.gamesShown') %></div>
</div>
<div class="responsive-table">
<table class="sq-games-table" id="sq-games-table">
<thead>
<tr>
<th onclick="sortSqGamesTable(0, 'string')" class="sortable"><%= t('common.date') %></th>
<th onclick="sortSqGamesTable(1, 'string')" class="sortable"><%= t('common.map') %></th>
<th onclick="sortSqGamesTable(2, 'number')" class="sortable"><%= t('common.groundKills') %></th>
<th onclick="sortSqGamesTable(3, 'number')" class="sortable"><%= t('common.airKills') %></th>
<th onclick="sortSqGamesTable(4, 'number')" class="sortable"><%= t('common.assists') %></th>
<th onclick="sortSqGamesTable(5, 'number')" class="sortable"><%= t('common.captures') %></th>
<th onclick="sortSqGamesTable(6, 'number')" class="sortable"><%= t('common.deaths') %></th>
<th onclick="sortSqGamesTable(7, 'string')" class="sortable"><%= t('common.result') %></th>
</tr>
</thead>
<tbody id="sq-games-table-body">
</tbody>
</table>
</div>
</div>
<div class="sq-games-error" id="sq-games-error" style="display: none;">
<i class="fas fa-exclamation-triangle" style="font-size: 3rem; color: #f59e0b; margin-bottom: 1rem;"></i>
<h3><%= t('common.failedToLoad') %></h3>
<button class="btn btn-primary" onclick="retryLoadSquadronGames()" style="margin-top: 1rem;"><%= t('squadrons.retryLoadGames') %></button>
</div>
<div class="sq-no-games" id="sq-no-games" style="display: none;">
<i class="fas fa-gamepad" style="font-size: 3rem; color: #d1d5db; margin-bottom: 1rem;"></i>
<h3><%= t('squadrons.noSquadronGames') %></h3>
</div>
</div>
</div>
</div>
<!-- Footer -->
<%- include('partials/footer') %>
<script>
window.__lang = '<%= lang %>';
window.__i18n = <%- localeJson %>;
window.__t = function(key) {
var parts = key.split('.'), obj = window.__i18n;
for (var i = 0; i < parts.length; i++) { obj = obj && obj[parts[i]]; }
return obj !== undefined ? obj : key;
};
window.switchLanguage = function(lang) {
var next = lang || (document.documentElement.lang === 'en' ? 'ru' : 'en');
if (next === document.documentElement.lang) return;
document.cookie = 'lang=' + next + ';path=/;max-age=31536000;SameSite=Lax';
window.location.reload();
};
</script>
<script src="/js/main.js?v=3"></script>
<script src="/js/api-client.js"></script>
<script src="/js/chart.umd.min.js"></script>
<div id="season-recap-modal" class="season-recap-modal hidden" role="dialog" aria-hidden="true">
<div class="season-recap-backdrop" data-close></div>
<div class="season-recap-content">
<header class="season-recap-header">
<h2><%= t('seasonCard.modalTitle') %></h2>
<button type="button" class="season-recap-close" data-close aria-label="Close">×</button>
</header>
<div class="season-recap-body">
<div class="season-recap-controls">
<div>
<label for="season-recap-select"><%= t('seasonCard.seasonLabel') %></label>
<select id="season-recap-select"></select>
</div>
<div>
<label><%= t('seasonCard.themeLabel') %></label>
<span class="season-recap-theme-group">
<label><input type="radio" name="season-recap-theme" value="dark" checked> <%= t('seasonCard.themeDark') %></label>
<label><input type="radio" name="season-recap-theme" value="light"> <%= t('seasonCard.themeLight') %></label>
</span>
</div>
<div>
<label>&nbsp;</label>
<button type="button" id="season-recap-generate" class="btn btn-primary"><%= t('seasonCard.generate') %></button>
</div>
</div>
<div id="season-recap-status"></div>
<div id="season-recap-image-wrap">
<img id="season-recap-image" alt="Season recap card" />
</div>
</div>
</div>
</div>
<script src="/js/player-details-modal.js"></script>
<script src="/js/recap-modal.js" data-clan-id="<%= squadronData.clan_id || '' %>"></script>
<script src="/js/header-search.js?v=2"></script>
<script src="/js/vehicle-i18n.js"></script>
<script>
(function() {
const squadronName = document.getElementById('squadronChartSection').dataset.squadron;
let chart = null;
let historyData = null;
let currentMetric = 'rating';
let renderedMetric = null;
let chartOpen = true;
let chartFilterStart = null;
let chartFilterEnd = null;
let chartScaleMode = 'relative';
const metricConfig = {
win_rate: { label: __t('common.winRate'), color: '#90EE90', suffix: '%' },
kdr: { label: __t('common.kdr'), color: '#64b5f6', suffix: '' },
battles: { label: __t('common.battles'), color: '#ffb74d', suffix: '' },
rating: { label: __t('common.rating'), color: '#ce93d8', suffix: '' }
};
function updateSlider(btn) {
const slider = document.getElementById('chartToggleSlider');
const group = document.getElementById('chartToggles');
const btnRect = btn.getBoundingClientRect();
const groupRect = group.getBoundingClientRect();
slider.style.left = (btnRect.left - groupRect.left) + 'px';
slider.style.width = btnRect.width + 'px';
}
function updateScaleSlider(btn) {
const slider = document.getElementById('sqChartScaleSlider');
const group = document.getElementById('sqChartScaleToggles');
const btnRect = btn.getBoundingClientRect();
const groupRect = group.getBoundingClientRect();
slider.style.left = (btnRect.left - groupRect.left) + 'px';
slider.style.width = btnRect.width + 'px';
}
function formatPeriod(dateStr) {
const d = new Date(dateStr + 'T00:00:00Z');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
}
function periodToTimestamp(dateStr) {
return new Date(dateStr + 'T00:00:00Z').getTime();
}
function formatUnixLabel(ts) {
const d = new Date(ts);
return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }) + ' UTC';
}
function withinFilter(ts) {
if (chartFilterStart && ts < chartFilterStart.getTime()) return false;
if (chartFilterEnd && ts > chartFilterEnd.getTime()) return false;
return true;
}
function renderChart(metric) {
const config = metricConfig[metric];
const metricChanged = renderedMetric !== metric;
let dataPoints;
let allDataPoints;
let isHourly = false;
if (metric === 'rating' && historyData && historyData.rating_hourly && historyData.rating_hourly.length) {
isHourly = true;
allDataPoints = historyData.rating_hourly.map(d => ({
x: d.t * 1000,
y: d.rating,
label: formatUnixLabel(d.t * 1000),
}));
dataPoints = allDataPoints.filter(p => withinFilter(p.x));
} else if (historyData && historyData.history && historyData.history.length) {
allDataPoints = historyData.history
.filter(d => d[metric] != null)
.map(d => ({ x: periodToTimestamp(d.period), y: d[metric], label: formatPeriod(d.period) }));
dataPoints = allDataPoints.filter(p => withinFilter(p.x));
} else {
return;
}
if (!dataPoints.length) return;
// X-axis bounds:
// alltime: span the full history range (earliest → latest of allDataPoints)
// so a season filter highlights a slice without zooming the axis
// relative: span only the filtered range (zoomed)
const earliestTs = allDataPoints[0].x;
const latestTs = allDataPoints[allDataPoints.length - 1].x;
const earliestYear = new Date(earliestTs).getUTCFullYear();
const xMin = chartScaleMode === 'alltime' ? Date.UTC(earliestYear, 0, 1) : dataPoints[0].x;
const xMax = chartScaleMode === 'alltime' ? latestTs : dataPoints[dataPoints.length - 1].x;
// Hourly points are dense — hide the dots and keep just the line
const pointRadius = isHourly ? 0 : 2;
const pointHoverRadius = isHourly ? 3 : 4;
document.getElementById('chartLoading').style.display = 'none';
const canvas = document.getElementById('squadronChart');
canvas.style.display = '';
if (chart && metricChanged) {
chart.destroy();
chart = null;
}
if (chart) {
chart.data.datasets[0].data = dataPoints;
chart.data.datasets[0].label = config.label;
chart.data.datasets[0].borderColor = config.color;
chart.data.datasets[0].backgroundColor = config.color + '18';
chart.data.datasets[0].pointBackgroundColor = config.color;
chart.data.datasets[0].pointBorderColor = config.color;
chart.data.datasets[0].pointHoverBackgroundColor = config.color;
chart.data.datasets[0].pointHoverBorderColor = config.color;
chart.data.datasets[0].pointRadius = pointRadius;
chart.data.datasets[0].pointHoverRadius = pointHoverRadius;
chart.options.plugins.tooltip.borderColor = config.color;
chart.options.plugins.tooltip.callbacks.label = ctx => `${config.label}: ${ctx.parsed.y}${config.suffix}`;
chart.options.plugins.tooltip.callbacks.title = ctx => ctx[0].raw.label;
chart.options.scales.x.min = xMin;
chart.options.scales.x.max = xMax;
chart.options.scales.y.ticks.callback = v => parseFloat(v.toFixed(2)) + config.suffix;
// Keep the dense hourly rating series snappy, but animate the
// normal daily/filtered transitions like the player chart.
if (isHourly) chart.update('none');
else chart.update();
renderedMetric = metric;
return;
}
chart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
datasets: [{
label: config.label,
data: dataPoints,
borderColor: config.color,
backgroundColor: config.color + '18',
borderWidth: 2,
pointBackgroundColor: config.color,
pointBorderColor: config.color,
pointHoverBackgroundColor: config.color,
pointHoverBorderColor: config.color,
pointRadius: pointRadius,
pointHoverRadius: pointHoverRadius,
tension: 0.15,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
parsing: false,
normalized: true,
plugins: {
legend: { display: false },
decimation: {
enabled: true,
algorithm: 'lttb',
samples: 350,
threshold: 350
},
tooltip: {
backgroundColor: 'rgba(15,15,15,0.92)',
borderColor: config.color,
borderWidth: 1,
titleColor: 'rgba(255,255,255,0.75)',
bodyColor: config.color,
padding: 10,
callbacks: {
title: ctx => ctx[0].raw.label,
label: ctx => `${config.label}: ${ctx.parsed.y}${config.suffix}`
}
}
},
scales: {
x: {
type: 'linear',
min: xMin,
max: xMax,
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: {
color: 'rgba(255,255,255,0.45)',
font: { size: 11 },
maxRotation: 45,
maxTicksLimit: 15,
autoSkip: true,
callback: function(val) {
const d = new Date(val);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
}
}
},
y: {
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: {
color: 'rgba(255,255,255,0.45)',
font: { size: 11 },
callback: v => parseFloat(v.toFixed(2)) + config.suffix
}
}
}
}
});
renderedMetric = metric;
}
async function loadHistory() {
try {
historyData = await window.apiClient.request('/api/squadrons/' + encodeURIComponent(squadronName) + '/history');
if (!historyData.history || historyData.history.length === 0) {
document.getElementById('chartLoading').textContent = __t('player.noHistoricalData');
return;
}
renderChart(currentMetric);
} catch (e) {
console.error('Failed to load squadron history:', e);
document.getElementById('chartLoading').textContent = __t('player.chartUnavailable');
}
}
document.getElementById('chartToggles').addEventListener('click', (e) => {
const btn = e.target.closest('.chart-toggle');
if (!btn) return;
document.querySelectorAll('#chartToggles .chart-toggle').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateSlider(btn);
currentMetric = btn.dataset.metric;
if (historyData) renderChart(currentMetric);
});
document.getElementById('sqChartScaleToggles').addEventListener('click', (e) => {
const btn = e.target.closest('.chart-toggle');
if (!btn || !btn.dataset.scale) return;
document.querySelectorAll('#sqChartScaleToggles .chart-toggle').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateScaleSlider(btn);
chartScaleMode = btn.dataset.scale;
if (historyData) renderChart(currentMetric);
});
requestAnimationFrame(() => {
const activeBtn = document.querySelector('#chartToggles .chart-toggle.active');
if (activeBtn) updateSlider(activeBtn);
const activeScaleBtn = document.querySelector('#sqChartScaleToggles .chart-toggle.active');
if (activeScaleBtn) updateScaleSlider(activeScaleBtn);
});
document.getElementById('chartCollapseBtn').addEventListener('click', () => {
chartOpen = !chartOpen;
document.getElementById('chartToggles').style.display = chartOpen ? '' : 'none';
document.getElementById('chartWrapper').style.display = chartOpen ? '' : 'none';
document.getElementById('sqChartScaleWrap').style.display = chartOpen ? '' : 'none';
document.getElementById('chartCollapseIcon').className = chartOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down';
});
// Expose filter hook for the outer filter controller
window.updateSquadronChartFilter = function(startDate, endDate) {
chartFilterStart = startDate;
chartFilterEnd = endDate;
if (historyData) renderChart(currentMetric);
};
loadHistory();
})();
</script>
<script>
let currentSort = { field: '', direction: 'desc' };
function sortMembersTable(field) {
const table = document.querySelector('.members-table');
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
if (currentSort.field === field) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.field = field;
currentSort.direction = field === 'nick' ? 'asc' : 'desc';
}
document.querySelectorAll('.sortable').forEach(header => {
header.classList.remove('sorted-asc', 'sorted-desc');
});
const currentHeader = document.querySelector(`[data-sort="${field}"]`);
if (currentHeader) {
currentHeader.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
}
rows.sort((a, b) => {
let valueA, valueB;
const cellIndex = Array.from(table.querySelectorAll('th')).findIndex(th => th.getAttribute('data-sort') === field);
const cellA = a.cells[cellIndex];
const cellB = b.cells[cellIndex];
if (field === 'nick') {
valueA = (cellA ? cellA.textContent : '').trim().toLowerCase();
valueB = (cellB ? cellB.textContent : '').trim().toLowerCase();
} else {
const rawA = cellA && cellA.dataset && cellA.dataset.sortValue != null
? String(cellA.dataset.sortValue)
: (cellA ? cellA.textContent : '');
const rawB = cellB && cellB.dataset && cellB.dataset.sortValue != null
? String(cellB.dataset.sortValue)
: (cellB ? cellB.textContent : '');
valueA = parseFloat(rawA.replace('%', '')) || 0;
valueB = parseFloat(rawB.replace('%', '')) || 0;
}
if (currentSort.direction === 'asc') {
return valueA > valueB ? 1 : valueA < valueB ? -1 : 0;
} else {
return valueA < valueB ? 1 : valueA > valueB ? -1 : 0;
}
});
tbody.innerHTML = '';
rows.forEach(row => tbody.appendChild(row));
}
</script>
<script>
(function() {
const squadronName = document.getElementById('squadronChartSection').dataset.squadron;
let sqGamesData = null;
let sqGamesLoadedKey = null;
let sqGamesTimestamp = null;
const SQ_GAMES_CACHE_DURATION = 5 * 60 * 1000;
let currentSqGamesSort = { column: 0, direction: 'desc' };
let sqCurrentFilterStart = null;
let sqCurrentFilterEnd = null;
let sqCurrentPage = 'cumulative';
let sqGamesRequestId = 0;
// View toggle
function updateViewSlider(btn) {
const slider = document.getElementById('viewToggleSlider');
const group = document.getElementById('viewToggles');
const btnRect = btn.getBoundingClientRect();
const groupRect = group.getBoundingClientRect();
slider.style.left = (btnRect.left - groupRect.left) + 'px';
slider.style.width = btnRect.width + 'px';
}
function mountFilterTo(pageName) {
const filterBar = document.getElementById('sq-time-filter-bar');
const mount = document.getElementById('sq-filter-mount-' + pageName);
if (filterBar && mount && filterBar.parentElement !== mount) {
mount.appendChild(filterBar);
}
}
function switchToPage(pageName) {
sqCurrentPage = pageName;
document.getElementById('cumulative-page').style.display = pageName === 'cumulative' ? 'block' : 'none';
document.getElementById('games-page').style.display = pageName === 'games' ? 'block' : 'none';
document.querySelectorAll('.view-toggle').forEach(b => {
b.classList.toggle('active', b.dataset.page === pageName);
});
const activeBtn = document.querySelector('.view-toggle[data-page="' + pageName + '"]');
if (activeBtn) updateViewSlider(activeBtn);
document.getElementById('viewSectionTitle').textContent = pageName === 'cumulative'
? __t('squadrons.squadronMembers')
: __t('squadrons.squadronGames');
mountFilterTo(pageName);
if (pageName === 'games') loadSquadronGames();
}
// Initial mount
mountFilterTo('cumulative');
document.getElementById('viewToggles').addEventListener('click', function(e) {
var btn = e.target.closest('.view-toggle');
if (!btn) return;
switchToPage(btn.dataset.page);
});
requestAnimationFrame(function() {
var activeBtn = document.querySelector('.view-toggle.active');
if (activeBtn) updateViewSlider(activeBtn);
});
function gamesCacheKey(start, end) {
return (start ? start.getTime() : 'null') + '_' + (end ? end.getTime() : 'null');
}
// Games loading
async function loadSquadronGames() {
var now = Date.now();
var key = gamesCacheKey(sqCurrentFilterStart, sqCurrentFilterEnd);
if (sqGamesLoadedKey === key && sqGamesData && sqGamesTimestamp) {
var dataAge = now - sqGamesTimestamp;
if (dataAge < SQ_GAMES_CACHE_DURATION) {
showGamesState(sqGamesData.games && sqGamesData.games.length > 0 ? 'content' : 'empty');
return;
}
sqGamesLoadedKey = null;
sqGamesData = null;
sqGamesTimestamp = null;
}
showGamesState('loading');
const thisRequest = ++sqGamesRequestId;
const startSnap = sqCurrentFilterStart;
const endSnap = sqCurrentFilterEnd;
try {
const data = await window.apiClient.getSquadronGames(squadronName, startSnap, endSnap);
if (thisRequest !== sqGamesRequestId) return;
sqGamesData = data;
if (!sqGamesData.games || sqGamesData.games.length === 0) {
showGamesState('empty');
sqGamesLoadedKey = gamesCacheKey(startSnap, endSnap);
sqGamesTimestamp = Date.now();
return;
}
displaySquadronGamesTable();
showGamesState('content');
sqGamesLoadedKey = gamesCacheKey(startSnap, endSnap);
sqGamesTimestamp = Date.now();
} catch (err) {
if (thisRequest !== sqGamesRequestId) return;
console.error('Error loading squadron games:', err);
showGamesState('error');
sqGamesLoadedKey = null;
}
}
// Expose for the outer filter controller
window.refreshSquadronGamesForFilter = function(startDate, endDate) {
sqCurrentFilterStart = startDate;
sqCurrentFilterEnd = endDate;
// Clear the games-page cached data on filter change so re-entry refetches
const key = gamesCacheKey(startDate, endDate);
if (sqGamesLoadedKey !== key) {
sqGamesData = null;
sqGamesLoadedKey = null;
sqGamesTimestamp = null;
}
if (sqCurrentPage === 'games') loadSquadronGames();
};
function showGamesState(state) {
document.getElementById('sq-games-loading').style.display = state === 'loading' ? 'block' : 'none';
document.getElementById('sq-games-content').style.display = state === 'content' ? 'block' : 'none';
document.getElementById('sq-games-error').style.display = state === 'error' ? 'block' : 'none';
document.getElementById('sq-no-games').style.display = state === 'empty' ? 'block' : 'none';
}
function getMapImage(mapName) {
if (!mapName) return null;
var clean = mapName.replace(/^\s*\[[^\]]+\]\s*/, '').trim();
var fileName = clean.replace(/\s+/g, '_').replace(/[^\w\u00C0-\u024F\-_.()]/g, '').replace(/_+/g, '_').replace(/^_|_$/g, '').toLowerCase();
return '/MAPS/' + fileName + '.jpg';
}
function cleanMapName(mapName) {
if (!mapName) return 'Unknown';
return mapName.replace(/^\s*\[[^\]]+\]\s*/, '').trim() || 'Unknown';
}
function displaySquadronGamesTable() {
var tableBody = document.getElementById('sq-games-table-body');
if (!tableBody || !sqGamesData.games) return;
sqGamesData.games.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0); });
tableBody.innerHTML = sqGamesData.games.map(function(game, index) {
var date = new Date(game.timestamp * 1000);
var formattedDate = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
var result = game.result || 'Unknown';
var resultClass = result.toLowerCase();
var hasSession = game.session_id ? true : false;
var mapDisplay = cleanMapName(game.map_name);
var mapBg = getMapImage(game.map_name);
var rowStyle = (mapBg ? '--bg-img:url(\'' + mapBg + '\');' : '');
var rowClasses = hasSession ? 'row-link' : '';
var linkHtml = hasSession ? '<a href="/games/' + game.session_id + '" class="row-link-overlay" aria-label="View game"></a>' : '';
var playersAttr = (game.players || '').replace(/"/g, '&quot;');
return '<tr data-game-index="' + index + '" data-players="' + playersAttr + '"' + (hasSession ? ' data-session-id="' + game.session_id + '"' : '') + ' class="' + rowClasses + '"' + (rowStyle ? ' style="' + rowStyle + '"' : '') + '>' +
'<td class="sq-game-date">' + linkHtml + formattedDate + '</td>' +
'<td class="sq-game-map"><i class="fas fa-map-marker-alt"></i>' + mapDisplay + '</td>' +
'<td class="stat-cell">' + (game.stats.ground_kills || 0) + '</td>' +
'<td class="stat-cell">' + (game.stats.air_kills || 0) + '</td>' +
'<td class="stat-cell">' + (game.stats.assists || 0) + '</td>' +
'<td class="stat-cell">' + (game.stats.captures || 0) + '</td>' +
'<td class="stat-cell">' + (game.stats.deaths || 0) + '</td>' +
'<td><span class="sq-game-result ' + resultClass + '">' + result + '</span></td>' +
'</tr>';
}).join('');
currentSqGamesSort = { column: 0, direction: 'desc' };
var headers = document.getElementById('sq-games-table').querySelectorAll('th');
if (headers[0]) headers[0].classList.add('sorted-desc');
updateFilteredCount();
}
// Filtering (debounced so typing isn't blocked by per-keystroke DOM work)
var sqFilterTimer = null;
window.filterSquadronGamesDebounced = function() {
if (sqFilterTimer) clearTimeout(sqFilterTimer);
sqFilterTimer = setTimeout(function() {
sqFilterTimer = null;
window.filterSquadronGames();
}, 150);
};
window.filterSquadronGames = function() {
var playerSearch = document.getElementById('sq-player-search').value.toLowerCase();
var mapSearch = document.getElementById('sq-map-search').value.toLowerCase();
var rows = document.querySelectorAll('#sq-games-table-body tr');
rows.forEach(function(row) {
var players = (row.dataset.players || '').toLowerCase();
var map = row.cells[1] ? row.cells[1].textContent.toLowerCase() : '';
var matchPlayer = !playerSearch || players.indexOf(playerSearch) !== -1;
var matchMap = !mapSearch || map.indexOf(mapSearch) !== -1;
row.style.display = (matchPlayer && matchMap) ? '' : 'none';
});
updateFilteredCount();
};
function updateFilteredCount() {
var rows = document.querySelectorAll('#sq-games-table-body tr');
var visible = 0;
rows.forEach(function(row) {
if (row.style.display !== 'none') visible++;
});
var countEl = document.getElementById('sq-filtered-count');
if (countEl) countEl.textContent = visible;
}
// Sorting
window.sortSqGamesTable = function(columnIndex, dataType) {
var table = document.getElementById('sq-games-table');
var tbody = table.querySelector('tbody');
var headers = table.querySelectorAll('th');
var rows = Array.from(tbody.querySelectorAll('tr'));
if (currentSqGamesSort.column === columnIndex) {
currentSqGamesSort.direction = currentSqGamesSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSqGamesSort.column = columnIndex;
currentSqGamesSort.direction = dataType === 'number' ? 'desc' : 'asc';
}
headers.forEach(function(h) { h.classList.remove('sorted-asc', 'sorted-desc'); });
headers[columnIndex].classList.add('sorted-' + currentSqGamesSort.direction);
rows.sort(function(a, b) {
var valA, valB;
if (dataType === 'number') {
valA = parseFloat(a.cells[columnIndex].textContent.replace('%', '')) || 0;
valB = parseFloat(b.cells[columnIndex].textContent.replace('%', '')) || 0;
} else {
valA = a.cells[columnIndex].textContent.trim().toLowerCase();
valB = b.cells[columnIndex].textContent.trim().toLowerCase();
}
if (currentSqGamesSort.direction === 'asc') {
return valA > valB ? 1 : valA < valB ? -1 : 0;
} else {
return valA < valB ? 1 : valA > valB ? -1 : 0;
}
});
tbody.innerHTML = '';
rows.forEach(function(row) { tbody.appendChild(row); });
};
// Retry
window.retryLoadSquadronGames = function() {
sqGamesLoadedKey = null;
sqGamesData = null;
sqGamesTimestamp = null;
loadSquadronGames();
};
})();
</script>
<script src="/js/seasons-filter.js"></script>
<script>
(function() {
const squadronName = document.getElementById('squadronChartSection').dataset.squadron;
let sqRequestId = 0;
let initialPlayers = <%- JSON.stringify(squadronData.players || []) %>;
const initialSummary = <%- JSON.stringify(squadronData.squadron_summary || {}) %>;
let currentSquadronPlayers = [];
let performanceMetric = 'kdr';
let performanceTrendChart = null;
let performanceCombatRadarChart = null;
let performanceResultsRadarChart = null;
let performanceSort = { field: 'kdr', direction: 'desc' };
let performanceSelectedPlayers = [];
let performancePlayerGamesCache = new Map();
let performanceSparkCharts = new Map();
let performanceVehicleList = [];
let performanceVehicleTypes = {};
let performanceVehicleSearchTimeout = null;
let performancePlayerSearchTimeout = null;
let performanceSpecificVehicle = null;
let performanceFilterStart = null;
let performanceFilterEnd = null;
let performanceRenderToken = 0;
let performanceAvailableVehicles = [];
let performanceAvailableTypes = [];
const TIMESLOT_BOUNDS = {
all: { start: [0, 0, 0, 0], end: [23, 59, 59, 999] },
na: { start: [1, 0, 0, 0], end: [7, 59, 59, 999] },
eu: { start: [14, 0, 0, 0], end: [22, 59, 59, 999] },
};
const PERFORMANCE_METRICS = {
total_kills: { label: __t('common.totalKills'), color: '#90EE90', digits: 0 },
total_battles:{ label: __t('common.battles'), color: '#9fa8da', digits: 0 },
wins: { label: __t('common.wins'), color: '#aed581', digits: 0 },
kps: { label: __t('common.kps'), color: '#F5C16C', digits: 2 },
kdr: { label: __t('common.kdr'), color: '#64b5f6', digits: 2 },
win_rate: { label: __t('common.winRate'), color: '#ce93d8', digits: 1, suffix: '%' },
ground_kills: { label: __t('common.groundKills'), color: '#4dd0e1', digits: 0 },
air_kills: { label: __t('common.airKills'), color: '#ff8a65', digits: 0 },
assists: { label: __t('common.assists'), color: '#81c784', digits: 0 },
deaths: { label: __t('common.deaths'), color: '#ef9a9a', digits: 0 },
captures: { label: __t('common.captures'), color: '#ffd54f', digits: 0 },
};
const PERFORMANCE_COLOR_POOL = ['#90EE90', '#64b5f6', '#ffb74d', '#ce93d8', '#4dd0e1', '#ff8a65', '#81c784', '#ffd54f'];
const PERFORMANCE_HEADER_METRICS = new Set(['total_battles', 'wins', 'win_rate', 'total_kills', 'kps', 'kdr', 'ground_kills', 'air_kills', 'assists', 'deaths', 'captures']);
const PERFORMANCE_COMBAT_RADAR_KEYS = ['total_kills', 'kps', 'kdr', 'ground_kills', 'air_kills', 'assists'];
const PERFORMANCE_RESULTS_RADAR_KEYS = ['total_battles', 'wins', 'win_rate', 'captures', 'deaths'];
function getElement(id) { return document.getElementById(id); }
function parseUtcDateInput(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || '');
if (!match) return null;
return new Date(Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
0, 0, 0, 0
));
}
function buildTimeslotBoundary(dateValue, slotKey, edge) {
const date = parseUtcDateInput(dateValue);
if (!date) return null;
const slot = TIMESLOT_BOUNDS[slotKey] || TIMESLOT_BOUNDS.all;
const parts = edge === 'start' ? slot.start : slot.end;
date.setUTCHours(parts[0], parts[1], parts[2], parts[3]);
return date;
}
function normalizePerformancePlayer(player) {
const totalBattles = Number(player && player.total_battles) || 0;
const totalKills = Number(player && player.total_kills) || 0;
const wins = Number(player && player.wins) || 0;
const deaths = Number(player && player.deaths) || 0;
const winRate = typeof player?.win_rate === 'number' ? player.win_rate : (Number(player && player.win_rate) || 0);
const kdr = typeof player?.kdr === 'number' ? player.kdr : (Number(player && player.kdr) || 0);
const kps = typeof player?.kps === 'number' ? player.kps : (totalBattles > 0 ? totalKills / totalBattles : 0);
return {
...player,
sqb_points: Number(player && player.sqb_points) || 0,
total_battles: totalBattles,
wins,
total_kills: totalKills,
ground_kills: Number(player && player.ground_kills) || 0,
air_kills: Number(player && player.air_kills) || 0,
assists: Number(player && player.assists) || 0,
captures: Number(player && player.captures) || 0,
deaths,
win_rate: winRate,
kdr,
kps,
};
}
function setCurrentPlayers(players) {
currentSquadronPlayers = (players || []).map(normalizePerformancePlayer);
}
function formatPerformanceValue(metricKey, rawValue) {
const config = PERFORMANCE_METRICS[metricKey] || { digits: 0, suffix: '' };
const value = Number(rawValue) || 0;
if (config.digits > 0) return value.toFixed(config.digits) + (config.suffix || '');
return Math.round(value).toLocaleString() + (config.suffix || '');
}
function performanceFilterKey() {
return `${performanceFilterStart ? performanceFilterStart.getTime() : 'all'}_${performanceFilterEnd ? performanceFilterEnd.getTime() : 'all'}`;
}
function playerGamesEndpoint(uid) {
let endpoint = `/api/player/${encodeURIComponent(uid)}/games`;
if (performanceFilterStart || performanceFilterEnd) {
const params = new URLSearchParams();
if (performanceFilterStart) params.append('start_date', performanceFilterStart.toISOString());
if (performanceFilterEnd) params.append('end_date', performanceFilterEnd.toISOString());
endpoint += `?${params.toString()}`;
}
return endpoint;
}
function performanceColor(index) {
return PERFORMANCE_COLOR_POOL[index % PERFORMANCE_COLOR_POOL.length];
}
function vehicleTypeLabel(code) {
switch (code) {
case 'F': return __t('analytics.compTypeFighters');
case 'B': return __t('analytics.compTypeBombers');
case 'H': return __t('analytics.compTypeHelicopters');
case 'L': return __t('analytics.compTypeLight');
case 'T': return __t('analytics.compTypeTanks');
case 'AA': return __t('analytics.compTypeSPAA');
default: return __t('analytics.compTypeUnknown');
}
}
async function ensurePerformanceVehicleMeta() {
if (!performanceVehicleList.length) {
const listRes = await window.apiClient.request('/api/analytics/vehicle-list');
performanceVehicleList = (listRes && listRes.vehicles) || [];
}
if (!Object.keys(performanceVehicleTypes).length) {
const typeRes = await window.apiClient.request('/api/i18n/vehicle-types');
performanceVehicleTypes = (typeRes && typeRes.types) || {};
}
if (window.vehicleI18n && window.vehicleI18n.ensureLoaded) {
await window.vehicleI18n.ensureLoaded();
}
}
function performanceVehicleDisplay(internal, fallback) {
if (!internal) return fallback || '';
if (window.vehicleI18n && window.vehicleI18n.translate) {
return window.vehicleI18n.translate(internal, fallback || internal);
}
return fallback || internal;
}
function getPerformanceSelectedType() {
const select = getElement('sq-performance-vehicle-type');
return select ? (select.value || 'all') : 'all';
}
function getPerformanceSelectedVehicleInternal() {
return performanceSpecificVehicle && performanceSpecificVehicle.internal
? String(performanceSpecificVehicle.internal).toLowerCase()
: null;
}
function gameMatchesPerformanceVehicleFilter(game) {
const internal = String(game && game.vehicle_internal || '').toLowerCase();
const specificInternal = getPerformanceSelectedVehicleInternal();
if (specificInternal) return internal === specificInternal;
const typeCode = getPerformanceSelectedType();
if (typeCode === 'all') return true;
return (performanceVehicleTypes[internal] || '?') === typeCode;
}
function aggregatePerformanceGames(rawGames) {
const sessions = new Map();
for (const game of (rawGames || [])) {
if (!gameMatchesPerformanceVehicleFilter(game)) continue;
const sessionId = String(game.session_id || '');
if (!sessionId) continue;
if (!sessions.has(sessionId)) {
sessions.set(sessionId, {
session_id: sessionId,
timestamp: Number(game.timestamp) || 0,
result: game.result || 'Unknown',
ground_kills: 0,
air_kills: 0,
assists: 0,
captures: 0,
deaths: 0
});
}
const target = sessions.get(sessionId);
target.timestamp = Math.max(target.timestamp, Number(game.timestamp) || 0);
if (target.result === 'Unknown' && game.result) target.result = game.result;
target.ground_kills += Number(game.stats && game.stats.ground_kills) || 0;
target.air_kills += Number(game.stats && game.stats.air_kills) || 0;
target.assists += Number(game.stats && game.stats.assists) || 0;
target.captures += Number(game.stats && game.stats.captures) || 0;
target.deaths += Number(game.stats && game.stats.deaths) || 0;
}
const sessionRows = Array.from(sessions.values()).sort((a, b) => a.timestamp - b.timestamp);
const totals = {
total_battles: sessionRows.length,
wins: 0,
total_kills: 0,
ground_kills: 0,
air_kills: 0,
assists: 0,
deaths: 0,
captures: 0,
win_rate: 0,
kdr: 0,
kps: 0,
performance: 0
};
const daily = new Map();
for (const row of sessionRows) {
const totalKills = row.ground_kills + row.air_kills;
totals.ground_kills += row.ground_kills;
totals.air_kills += row.air_kills;
totals.total_kills += totalKills;
totals.assists += row.assists;
totals.deaths += row.deaths;
totals.captures += row.captures;
if (String(row.result).toUpperCase() === 'WIN') totals.wins += 1;
const day = new Date((row.timestamp || 0) * 1000);
const dayKey = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate())).toISOString().slice(0, 10);
if (!daily.has(dayKey)) {
daily.set(dayKey, {
period: dayKey,
total_battles: 0,
wins: 0,
total_kills: 0,
ground_kills: 0,
air_kills: 0,
assists: 0,
deaths: 0,
captures: 0
});
}
const bucket = daily.get(dayKey);
bucket.total_battles += 1;
if (String(row.result).toUpperCase() === 'WIN') bucket.wins += 1;
bucket.total_kills += totalKills;
bucket.ground_kills += row.ground_kills;
bucket.air_kills += row.air_kills;
bucket.assists += row.assists;
bucket.deaths += row.deaths;
bucket.captures += row.captures;
}
totals.win_rate = totals.total_battles > 0 ? (totals.wins / totals.total_battles) * 100 : 0;
totals.kdr = totals.deaths > 0 ? (totals.total_kills / totals.deaths) : totals.total_kills;
totals.kps = totals.total_battles > 0 ? (totals.total_kills / totals.total_battles) : 0;
totals.performance = totals[performanceMetric] || 0;
const series = Array.from(daily.values())
.sort((a, b) => a.period.localeCompare(b.period))
.map(day => {
const winRate = day.total_battles > 0 ? (day.wins / day.total_battles) * 100 : 0;
const kdr = day.deaths > 0 ? (day.total_kills / day.deaths) : day.total_kills;
const kps = day.total_battles > 0 ? (day.total_kills / day.total_battles) : 0;
return {
...day,
win_rate: winRate,
kdr,
kps,
x: new Date(day.period + 'T00:00:00Z').getTime()
};
});
return { sessions: sessionRows, totals, series };
}
function buildPerformancePlayerState(player, rawGames) {
const aggregate = aggregatePerformanceGames(rawGames);
return {
...player,
rawGames: rawGames || [],
...aggregate.totals,
series: aggregate.series,
performance: aggregate.totals[performanceMetric] || 0
};
}
function getPerformanceSearchPlayers() {
return currentSquadronPlayers
.filter(p => !performanceSelectedPlayers.some(sel => String(sel.uid) === String(p.uid)))
.slice()
.sort((a, b) => (Number(b.total_kills) || 0) - (Number(a.total_kills) || 0));
}
function getSelectedPerformancePlayersMeta() {
const byUid = new Map(currentSquadronPlayers.map(p => [String(p.uid), p]));
return performanceSelectedPlayers.map(sel => byUid.get(String(sel.uid)) || sel);
}
function getCustomRangeDayPair() {
const startInput = getElement('sq-start-date').value;
const endInput = getElement('sq-end-date').value;
if (!startInput && !endInput) return null;
return {
startDay: startInput || endInput,
endDay: endInput || startInput,
};
}
function updateSingleDayTimeslotVisibility() {
const wrap = getElement('sq-single-day-timeslot-filter');
const select = getElement('sq-single-day-timeslot');
const pair = getCustomRangeDayPair();
const cat = getElement('sq-filter-category').value;
const filterType = getElement('sq-date-filter-type').value;
const singleDay = !!pair && pair.startDay === pair.endDay;
const show = cat === 'date' && filterType === 'custom' && singleDay;
wrap.style.display = show ? 'flex' : 'none';
if (!show) select.value = 'all';
}
async function initSeasons() {
if (!window.seasonsFilter) return;
try {
await window.seasonsFilter.loadSeasons();
window.seasonsFilter.populateSeasonSelect(getElement('sq-season'));
window.seasonsFilter.populateSeasonSelect(getElement('sq-week-season-select'));
} catch (e) {
console.error('Failed to load seasons for squadron filter:', e);
}
}
window.updateSquadronFilterCategory = function() {
const cat = getElement('sq-filter-category').value;
getElement('sq-date-type-filter').style.display = 'none';
getElement('sq-custom-range-filters').style.display = 'none';
getElement('sq-season-select-filter').style.display = 'none';
getElement('sq-week-season-filter').style.display = 'none';
getElement('sq-week-select-filter').style.display = 'none';
if (cat === 'date') {
getElement('sq-date-type-filter').style.display = 'flex';
handleSquadronDateTypeChange();
return;
} else if (cat === 'season') {
getElement('sq-season-select-filter').style.display = 'flex';
} else if (cat === 'week') {
getElement('sq-week-season-filter').style.display = 'flex';
getElement('sq-week-select-filter').style.display = 'flex';
}
updateResetBtn();
if (cat === 'all') {
applySquadronFilters();
}
};
window.handleSquadronDateTypeChange = function() {
const filterType = getElement('sq-date-filter-type').value;
const showCustom = filterType === 'custom';
getElement('sq-custom-range-filters').style.display = showCustom ? 'flex' : 'none';
updateSingleDayTimeslotVisibility();
updateResetBtn();
if (filterType !== 'custom') {
applySquadronFilters();
}
};
window.updateSquadronWeekOptions = function() {
const seasonSelect = getElement('sq-week-season-select');
const weekSelect = getElement('sq-week');
const seasonName = seasonSelect.value;
if (seasonName && window.seasonsFilter) {
window.seasonsFilter.populateWeekSelect(weekSelect, seasonName, true);
} else {
weekSelect.innerHTML = '<option value="">' + __t('player.pleaseSelect') + '</option>';
}
};
function updateResetBtn() {
const active = getElement('sq-filter-category').value !== 'all';
getElement('sq-reset-btn-group').style.display = active ? 'flex' : 'none';
}
function syncPerformanceFilterState(startDate, endDate) {
performanceFilterStart = startDate || null;
performanceFilterEnd = endDate || null;
// Cache key already includes the filter via performanceFilterKey(), so we keep
// entries from previous filters — toggling back becomes free instead of refetching.
performanceRenderToken += 1;
if (typeof window.renderSquadronPerformanceView === 'function') {
window.renderSquadronPerformanceView();
}
}
window.resetSquadronFilters = function() {
getElement('sq-filter-category').value = 'all';
getElement('sq-date-filter-type').value = 'last7';
getElement('sq-start-date').value = '';
getElement('sq-end-date').value = '';
getElement('sq-single-day-timeslot').value = 'all';
getElement('sq-season').value = '';
getElement('sq-week-season-select').value = '';
getElement('sq-week').innerHTML = '<option value="">' + __t('player.pleaseSelect') + '</option>';
updateSquadronFilterCategory();
};
window.applySquadronFilters = function() {
const category = getElement('sq-filter-category').value;
let startDate = null;
let endDate = null;
const now = new Date();
if (category === 'all') {
// proceed with nulls
} else if (category === 'date') {
const filterType = getElement('sq-date-filter-type').value;
if (!filterType) return;
switch (filterType) {
case 'last7':
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
endDate = now;
break;
case 'last30':
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
endDate = now;
break;
case 'last90':
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
endDate = now;
break;
case 'custom':
const pair = getCustomRangeDayPair();
if (!pair) return;
const slot = pair.startDay === pair.endDay
? (getElement('sq-single-day-timeslot').value || 'all')
: 'all';
startDate = buildTimeslotBoundary(pair.startDay, slot, 'start');
endDate = buildTimeslotBoundary(pair.endDay, slot, 'end');
if (!startDate || !endDate) return;
break;
}
} else if (category === 'season') {
const seasonName = getElement('sq-season').value;
if (!seasonName) return;
if (window.seasonsFilter) {
const range = window.seasonsFilter.getSeasonDateRange(seasonName);
if (range) { startDate = range.startDate; endDate = range.endDate; }
}
} else if (category === 'week') {
const selectedSeason = getElement('sq-week-season-select').value;
const selectedWeek = getElement('sq-week').value;
if (!selectedSeason || !selectedWeek) return;
if (window.seasonsFilter) {
if (selectedWeek === 'all') {
const range = window.seasonsFilter.getSeasonDateRange(selectedSeason);
if (range) { startDate = range.startDate; endDate = range.endDate; }
} else {
const weekNum = selectedWeek === 'final' ? null : parseInt(selectedWeek);
const range = window.seasonsFilter.getWeekDateRange(selectedSeason, weekNum);
if (range) { startDate = range.startDate; endDate = range.endDate; }
}
}
}
updateResetBtn();
syncPerformanceFilterState(startDate, endDate);
if (typeof window.updateSquadronChartFilter === 'function') {
window.updateSquadronChartFilter(startDate, endDate);
}
if (typeof window.refreshSquadronGamesForFilter === 'function') {
window.refreshSquadronGamesForFilter(startDate, endDate);
}
fetchAndUpdateSquadronStats(startDate, endDate);
};
getElement('sq-start-date').addEventListener('change', updateSingleDayTimeslotVisibility);
getElement('sq-end-date').addEventListener('change', updateSingleDayTimeslotVisibility);
setCurrentPlayers(initialPlayers);
async function fetchAndUpdateSquadronStats(startDate, endDate) {
const thisRequest = ++sqRequestId;
// All-time and we have server-rendered data → reuse it
if (!startDate && !endDate && initialPlayers) {
setCurrentPlayers(initialPlayers);
updateStatCards(initialSummary);
renderMembersTable(currentSquadronPlayers);
return;
}
try {
const data = await window.apiClient.getSquadronDetails(squadronName, startDate, endDate);
if (thisRequest !== sqRequestId) return;
if (data && data.players) {
setCurrentPlayers(data.players);
}
if (data && data.squadron_summary) updateStatCards(data.squadron_summary);
if (data && data.players) {
renderMembersTable(currentSquadronPlayers);
}
} catch (err) {
if (thisRequest !== sqRequestId) return;
console.error('Failed to refresh squadron stats:', err);
}
}
function updateStatCards(s) {
const setText = (id, val) => { const el = getElement(id); if (el) el.textContent = val; };
setText('sq-stat-total-battles', s.total_battles);
setText('sq-stat-wins', s.wins);
setText('sq-stat-win-rate', (typeof s.win_rate === 'number' ? s.win_rate.toFixed(1) : s.win_rate) + '%');
setText('sq-stat-total-kills', s.total_kills);
setText('sq-stat-kdr', typeof s.kdr === 'number' ? s.kdr.toFixed(2) : s.kdr);
setText('sq-stat-kps', typeof s.kps === 'number' ? s.kps.toFixed(2) : (Number(s.kps) || 0).toFixed(2));
setText('sq-stat-ground-kills', s.ground_kills);
setText('sq-stat-air-kills', s.air_kills);
setText('sq-stat-assists', s.assists);
setText('sq-stat-deaths', s.deaths);
setText('sq-stat-captures', s.captures);
}
function escapeHtml(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function syncPerformanceHeaderSort() {
document.querySelectorAll('[data-performance-sort]').forEach(header => {
header.classList.remove('sorted-asc', 'sorted-desc', 'performance-focus');
});
const active = document.querySelector('[data-performance-sort="' + performanceSort.field + '"]');
if (active) active.classList.add(performanceSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
const focused = document.querySelector('[data-performance-sort="' + performanceMetric + '"]');
if (focused) focused.classList.add('performance-focus');
}
function getSortedPerformancePlayers(rows) {
const { field, direction } = performanceSort;
return rows.slice().sort((a, b) => {
const aVal = a[field];
const bVal = b[field];
const cmp = field === 'nick'
? String(aVal || '').localeCompare(String(bVal || ''), undefined, { sensitivity: 'base' })
: ((Number(aVal) || 0) - (Number(bVal) || 0));
return direction === 'asc' ? cmp : -cmp;
});
}
function destroyPerformanceSparks() {
performanceSparkCharts.forEach(chart => {
try { chart.destroy(); } catch (e) {}
});
performanceSparkCharts.clear();
}
async function ensurePerformancePlayerGames(uid) {
const cacheKey = `${uid}|${performanceFilterKey()}`;
if (performancePlayerGamesCache.has(cacheKey)) return performancePlayerGamesCache.get(cacheKey);
const response = await window.apiClient.request(playerGamesEndpoint(uid));
const games = (response && response.games) || [];
performancePlayerGamesCache.set(cacheKey, games);
return games;
}
function renderPerformanceSelectedPlayers() {
const wrap = getElement('sq-performance-selected');
const hint = getElement('sq-performance-player-hint');
if (!wrap || !hint) return;
if (!performanceSelectedPlayers.length) {
wrap.innerHTML = '';
hint.textContent = __t('leaderboard.selectPlayersToCompare');
return;
}
hint.textContent = '';
wrap.innerHTML = performanceSelectedPlayers.map(player => `
<span class="performance-chip">
${escapeHtml(player.nick || player.uid || '')}
<button type="button" onclick="removePerformancePlayer('${escapeHtml(String(player.uid))}')"><i class="fas fa-times"></i></button>
</span>
`).join('');
}
function renderPerformanceVehicleSummary() {
const wrap = getElement('sq-performance-vehicle-active');
if (!wrap) return;
if (!performanceSelectedPlayers.length) {
wrap.textContent = '';
return;
}
if (performanceSpecificVehicle && performanceSpecificVehicle.internal) {
wrap.innerHTML = `
<span style="font-family:'skyquakesymbols','Inter',sans-serif;">${escapeHtml(performanceSpecificVehicle.name)}</span>
<button type="button" onclick="clearPerformanceVehicleFilter()"><i class="fas fa-times"></i></button>
`;
return;
}
const type = getPerformanceSelectedType();
wrap.textContent = type === 'all'
? __t('leaderboard.searchVehiclePlaceholder')
: __t('analytics.compRefineAny', { type: vehicleTypeLabel(type) });
}
function renderPerformanceFilterOptions(rows) {
const typeSelect = getElement('sq-performance-vehicle-type');
const vehicleInput = getElement('sq-performance-vehicle-search');
if (!typeSelect || !vehicleInput) return false;
if (!rows.length) {
performanceAvailableTypes = [];
performanceAvailableVehicles = [];
performanceSpecificVehicle = null;
typeSelect.innerHTML = `<option value="all">${escapeHtml(__t('leaderboard.any'))}</option>`;
typeSelect.value = 'all';
typeSelect.disabled = true;
vehicleInput.value = '';
vehicleInput.placeholder = __t('leaderboard.searchVehiclePlaceholder');
vehicleInput.disabled = true;
getElement('sq-performance-vehicle-results')?.classList.add('hidden');
return false;
}
const typeTotals = new Map();
const vehicleTotals = new Map();
rows.forEach(row => {
(row.rawGames || []).forEach(game => {
const internal = String(game && game.vehicle_internal || '').toLowerCase();
if (!internal || internal === 'disconnected') return;
const typeCode = performanceVehicleTypes[internal] || '?';
typeTotals.set(typeCode, (typeTotals.get(typeCode) || 0) + 1);
if (!vehicleTotals.has(internal)) {
vehicleTotals.set(internal, {
internal,
name: performanceVehicleDisplay(internal, internal),
type: typeCode,
total: 0
});
}
vehicleTotals.get(internal).total += 1;
});
});
const orderedTypes = ['F', 'B', 'H', 'L', 'T', 'AA', '?'].filter(code => typeTotals.has(code));
performanceAvailableTypes = orderedTypes;
performanceAvailableVehicles = Array.from(vehicleTotals.values()).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
const currentType = getPerformanceSelectedType();
const nextType = currentType === 'all' || orderedTypes.includes(currentType) ? currentType : 'all';
let filterChanged = nextType !== currentType;
typeSelect.innerHTML = `<option value="all">${escapeHtml(__t('leaderboard.any'))}</option>` + orderedTypes.map(code =>
`<option value="${escapeHtml(code)}">${escapeHtml(vehicleTypeLabel(code))}</option>`
).join('');
typeSelect.value = nextType;
typeSelect.disabled = performanceAvailableTypes.length === 0;
const specificInternal = getPerformanceSelectedVehicleInternal();
if (specificInternal && !performanceAvailableVehicles.some(vehicle => vehicle.internal === specificInternal)) {
performanceSpecificVehicle = null;
vehicleInput.value = '';
filterChanged = true;
}
const activeType = typeSelect.value || 'all';
vehicleInput.disabled = performanceAvailableVehicles.length === 0;
vehicleInput.placeholder = activeType === 'all'
? __t('leaderboard.searchVehiclePlaceholder')
: `${__t('leaderboard.searchVehiclePlaceholder')} · ${vehicleTypeLabel(activeType)}`;
renderPerformanceVehicleSummary();
return filterChanged;
}
async function renderPerformancePlayerSearchResults() {
const input = getElement('sq-performance-player-search');
const results = getElement('sq-performance-player-results');
if (!input || !results) return;
const term = (input.value || '').trim().toLowerCase();
if (term.length < 1) {
results.classList.add('hidden');
return;
}
const hits = getPerformanceSearchPlayers()
.filter(player => String(player.nick || '').toLowerCase().includes(term))
.slice(0, 10);
if (!hits.length) {
results.innerHTML = `<div class="performance-search-hit">${escapeHtml(__t('common.noPlayersFound'))}</div>`;
} else {
results.innerHTML = hits.map(player => `
<div class="performance-search-hit" data-performance-pick-player="${escapeHtml(String(player.uid))}">
<div class="performance-search-name">${escapeHtml(player.nick || player.uid || '')}</div>
<div class="performance-search-meta">${formatPerformanceValue('total_kills', player.total_kills)} ${escapeHtml(__t('js.killsSuffix'))} · ${formatPerformanceValue('win_rate', player.win_rate)}</div>
</div>
`).join('');
}
results.classList.remove('hidden');
}
async function renderPerformanceVehicleSearchResults() {
const input = getElement('sq-performance-vehicle-search');
const results = getElement('sq-performance-vehicle-results');
if (!input || !results) return;
const term = (input.value || '').trim().toLowerCase();
if (term.length < 1 || input.disabled) {
results.classList.add('hidden');
return;
}
const activeType = getPerformanceSelectedType();
const hits = performanceAvailableVehicles
.filter(vehicle => activeType === 'all' || vehicle.type === activeType)
.filter(vehicle => vehicle.name.toLowerCase().includes(term))
.slice(0, 20);
if (!hits.length) {
results.innerHTML = `<div class="performance-search-hit">${escapeHtml(__t('common.noVehiclesFound'))}</div>`;
} else {
results.innerHTML = hits.map(vehicle => `
<div class="performance-search-hit" data-performance-pick-vehicle="${escapeHtml(vehicle.internal)}">
<div class="performance-search-name" style="font-family:'skyquakesymbols','Inter',sans-serif;">${escapeHtml(vehicle.name)}</div>
<div class="performance-search-meta">${vehicle.total} ${escapeHtml(__t('analytics.compColSpawns').toLowerCase())}</div>
</div>
`).join('');
}
results.classList.remove('hidden');
}
function buildPerformanceSeriesValue(point, metricKey) {
return Number(point && point[metricKey]) || 0;
}
function destroyPerformanceCharts() {
[performanceTrendChart, performanceCombatRadarChart, performanceResultsRadarChart].forEach(chart => {
if (!chart) return;
try { chart.destroy(); } catch (e) {}
});
performanceTrendChart = null;
performanceCombatRadarChart = null;
performanceResultsRadarChart = null;
}
function togglePerformanceCanvas(canvasId, emptyId, showCanvas, emptyText) {
const canvas = getElement(canvasId);
const empty = getElement(emptyId);
if (!canvas || !empty) return;
canvas.style.display = showCanvas ? '' : 'none';
empty.style.display = showCanvas ? 'none' : 'flex';
if (!showCanvas && emptyText) empty.textContent = emptyText;
}
function formatPerformanceDateLabel(value) {
return new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
}
function buildPerformanceRadarDatasets(rows, keys) {
const maxima = {};
keys.forEach(key => {
maxima[key] = Math.max(...rows.map(row => Number(row[key]) || 0), 1);
});
return rows.map((row, index) => {
const color = performanceColor(index);
return {
label: row.nick || row.uid || '?',
data: keys.map(key => ((Number(row[key]) || 0) / maxima[key]) * 100),
actualValues: keys.map(key => Number(row[key]) || 0),
borderColor: color,
backgroundColor: color + '1f',
pointBackgroundColor: color,
pointBorderColor: color,
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 4,
fill: true
};
});
}
function renderPerformanceTrendChart(rows) {
const canvas = getElement('sq-performance-trend-chart');
const title = getElement('sq-performance-trend-title');
const meta = getElement('sq-performance-trend-meta');
const config = PERFORMANCE_METRICS[performanceMetric];
const rowsWithSeries = rows.filter(row => Array.isArray(row.series) && row.series.length > 0);
title.textContent = __t('squadrons.performance') + ' · ' + config.label;
meta.textContent = rows.length ? `${rows.length} ${__t('common.players')}` : '';
if (!rows.length) {
togglePerformanceCanvas('sq-performance-trend-chart', 'sq-performance-trend-empty', false, __t('leaderboard.selectPlayersToCompare'));
if (performanceTrendChart) {
performanceTrendChart.destroy();
performanceTrendChart = null;
}
return;
}
if (!rowsWithSeries.length) {
togglePerformanceCanvas('sq-performance-trend-chart', 'sq-performance-trend-empty', false, __t('squadrons.performanceNoData'));
if (performanceTrendChart) {
performanceTrendChart.destroy();
performanceTrendChart = null;
}
return;
}
const datasets = rowsWithSeries.map((row, index) => {
const color = performanceColor(index);
return {
label: row.nick || row.uid || '?',
data: row.series.map(point => ({ x: point.x, y: buildPerformanceSeriesValue(point, performanceMetric) })),
borderColor: color,
backgroundColor: color + '18',
pointBackgroundColor: color,
pointBorderColor: color,
pointHoverBackgroundColor: color,
pointHoverBorderColor: color,
pointRadius: 0,
pointHoverRadius: 3,
borderWidth: 2,
tension: 0.18,
fill: false
};
});
const allPoints = datasets.flatMap(dataset => dataset.data);
const xMin = Math.min(...allPoints.map(point => point.x));
const xMax = Math.max(...allPoints.map(point => point.x));
togglePerformanceCanvas('sq-performance-trend-chart', 'sq-performance-trend-empty', true);
if (performanceTrendChart) {
performanceTrendChart.data.datasets = datasets;
performanceTrendChart.options.scales.x.min = xMin;
performanceTrendChart.options.scales.x.max = xMax;
performanceTrendChart.options.plugins.tooltip.borderColor = config.color;
performanceTrendChart.options.plugins.tooltip.callbacks.label = (ctx) => `${ctx.dataset.label}: ${formatPerformanceValue(performanceMetric, ctx.parsed.y)}`;
performanceTrendChart.options.plugins.tooltip.callbacks.title = (ctx) => {
const point = ctx[0] && ctx[0].raw;
if (!point || !point.x) return '';
return formatPerformanceDateLabel(point.x);
};
performanceTrendChart.options.scales.y.ticks.callback = value => formatPerformanceValue(performanceMetric, value);
performanceTrendChart.update('none');
return;
}
performanceTrendChart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
parsing: false,
plugins: {
legend: {
display: false,
onClick: () => {},
labels: {
color: 'rgba(255,255,255,0.72)',
usePointStyle: true,
boxWidth: 8
}
},
tooltip: {
backgroundColor: 'rgba(15,15,15,0.92)',
borderColor: config.color,
borderWidth: 1,
titleColor: 'rgba(255,255,255,0.75)',
bodyColor: '#f5f5dc',
padding: 10,
callbacks: {
title: (ctx) => {
const point = ctx[0] && ctx[0].raw;
if (!point || !point.x) return '';
return formatPerformanceDateLabel(point.x);
},
label: (ctx) => `${ctx.dataset.label}: ${formatPerformanceValue(performanceMetric, ctx.parsed.y)}`
}
}
},
scales: {
x: {
type: 'linear',
min: xMin,
max: xMax,
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: {
color: 'rgba(255,255,255,0.5)',
callback: (value) => formatPerformanceDateLabel(value)
}
},
y: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: {
color: 'rgba(255,255,255,0.78)',
font: { size: 12, weight: '600' },
callback: value => formatPerformanceValue(performanceMetric, value)
}
}
}
}
});
}
function renderPerformanceRadarChart(chartKey, canvasId, emptyId, rows, keys) {
const canvas = getElement(canvasId);
if (!canvas) return;
const hasData = rows.length && rows.some(row => keys.some(key => (Number(row[key]) || 0) > 0));
if (!rows.length) {
togglePerformanceCanvas(canvasId, emptyId, false, __t('leaderboard.selectPlayersToCompare'));
if (chartKey === 'combat' && performanceCombatRadarChart) {
performanceCombatRadarChart.destroy();
performanceCombatRadarChart = null;
}
if (chartKey === 'results' && performanceResultsRadarChart) {
performanceResultsRadarChart.destroy();
performanceResultsRadarChart = null;
}
return;
}
if (!hasData) {
togglePerformanceCanvas(canvasId, emptyId, false, __t('squadrons.performanceNoData'));
if (chartKey === 'combat' && performanceCombatRadarChart) {
performanceCombatRadarChart.destroy();
performanceCombatRadarChart = null;
}
if (chartKey === 'results' && performanceResultsRadarChart) {
performanceResultsRadarChart.destroy();
performanceResultsRadarChart = null;
}
return;
}
const labels = keys.map(key => PERFORMANCE_METRICS[key].label);
const datasets = buildPerformanceRadarDatasets(rows, keys);
togglePerformanceCanvas(canvasId, emptyId, true);
const existingChart = chartKey === 'combat' ? performanceCombatRadarChart : performanceResultsRadarChart;
if (existingChart) {
existingChart.data.labels = labels;
existingChart.data.datasets = datasets;
existingChart.update('none');
return;
}
const radarChart = new Chart(canvas.getContext('2d'), {
type: 'radar',
data: {
labels,
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
display: false,
onClick: () => {},
labels: {
color: 'rgba(255,255,255,0.72)',
usePointStyle: true,
boxWidth: 8
}
},
tooltip: {
backgroundColor: 'rgba(15,15,15,0.92)',
borderColor: '#90EE90',
borderWidth: 1,
titleColor: 'rgba(255,255,255,0.75)',
bodyColor: '#f5f5dc',
padding: 10,
callbacks: {
label: (ctx) => {
const actual = ctx.dataset.actualValues?.[ctx.dataIndex] ?? 0;
return `${ctx.dataset.label}: ${formatPerformanceValue(keys[ctx.dataIndex], actual)}`;
}
}
}
},
scales: {
r: {
beginAtZero: true,
min: 0,
max: 100,
angleLines: { color: 'rgba(255,255,255,0.06)' },
grid: { color: 'rgba(255,255,255,0.05)' },
pointLabels: {
color: 'rgba(255,255,255,0.75)',
font: { size: 11, weight: '600' }
},
ticks: {
display: false,
stepSize: 20
}
}
}
}
});
if (chartKey === 'combat') performanceCombatRadarChart = radarChart;
else performanceResultsRadarChart = radarChart;
}
function renderPerformanceOverview(rows) {
renderPerformanceTrendChart(rows);
renderPerformanceRadarChart('combat', 'sq-performance-combat-radar', 'sq-performance-combat-empty', rows, PERFORMANCE_COMBAT_RADAR_KEYS);
renderPerformanceRadarChart('results', 'sq-performance-results-radar', 'sq-performance-results-empty', rows, PERFORMANCE_RESULTS_RADAR_KEYS);
}
function renderPerformanceSparks(rows) {
destroyPerformanceSparks();
if (!rows.length || typeof Chart === 'undefined') return;
const seriesWithData = rows.filter(row => row.series && row.series.length);
if (!seriesWithData.length) return;
const allX = seriesWithData.flatMap(row => row.series.map(point => point.x));
const allY = seriesWithData.flatMap(row => row.series.map(point => buildPerformanceSeriesValue(point, performanceMetric)));
const xMin = Math.min(...allX);
const xMax = Math.max(...allX);
const yMax = Math.max(...allY, 1);
rows.forEach((row, index) => {
const canvas = getElement(`sq-performance-spark-${row.uid}`);
if (!canvas || !row.series || !row.series.length) return;
const color = performanceColor(index);
const spark = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
datasets: [{
data: row.series.map(point => ({ x: point.x, y: buildPerformanceSeriesValue(point, performanceMetric) })),
borderColor: color,
backgroundColor: color + '14',
pointRadius: 0,
pointHoverRadius: 2,
borderWidth: 1.6,
tension: 0.18,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
parsing: false,
plugins: {
legend: { display: false },
tooltip: {
enabled: true,
displayColors: false,
backgroundColor: 'rgba(15,15,15,0.92)',
borderColor: color,
borderWidth: 1,
titleColor: 'rgba(255,255,255,0.75)',
bodyColor: color,
padding: 6,
callbacks: {
title: (ctx) => {
const point = ctx[0] && ctx[0].raw;
if (!point || !point.x) return '';
return formatPerformanceDateLabel(point.x);
},
label: (ctx) => formatPerformanceValue(performanceMetric, ctx.parsed.y)
}
}
},
scales: {
x: { type: 'linear', display: false, min: xMin, max: xMax },
y: { display: false, beginAtZero: true, suggestedMax: yMax * 1.05 }
}
}
});
performanceSparkCharts.set(String(row.uid), spark);
});
}
function renderPerformanceTable(rows) {
const tbody = getElement('sq-performance-table-body');
if (!tbody) return;
const sortedRows = getSortedPerformancePlayers(rows);
syncPerformanceHeaderSort();
if (!sortedRows.length) {
tbody.innerHTML = '<tr><td colspan="13" style="text-align:center; padding:2rem; color:rgba(255,255,255,0.5);">' +
escapeHtml(__t('leaderboard.selectPlayersToCompare')) + '</td></tr>';
destroyPerformanceSparks();
return;
}
tbody.innerHTML = sortedRows.map(p => {
const nickRaw = p.nick || '';
const nickEsc = escapeHtml(nickRaw);
const nickJs = nickRaw.replace(/'/g, "\\'");
return '<tr class="row-link">' +
'<td><div class="player-name"><a href="/players/' + encodeURIComponent(p.uid) + '" class="row-link-overlay" aria-label="View ' + nickEsc + '"></a>' + nickEsc +
'<button class="pdm-details-btn" onclick="event.stopPropagation(); openPlayerDetailsModal(\'' + p.uid + '\', \'' + nickJs + '\')" title="' + __t('squadrons.quickDetails') + '">' +
'<i class="fas fa-eye"></i></button></div></td>' +
'<td class="stat-cell performance-cell"><div class="performance-spark-wrap">' +
(p.series && p.series.length ? `<canvas class="performance-spark" id="sq-performance-spark-${p.uid}"></canvas>` : '') +
'</div></td>' +
'<td class="stat-cell">' + (p.total_battles || 0) + '</td>' +
'<td class="stat-cell">' + (p.wins || 0) + '</td>' +
'<td class="stat-cell">' + formatPerformanceValue('win_rate', p.win_rate) + '</td>' +
'<td class="stat-cell">' + (p.total_kills || 0) + '</td>' +
'<td class="stat-cell">' + formatPerformanceValue('kps', p.kps) + '</td>' +
'<td class="stat-cell">' + formatPerformanceValue('kdr', p.kdr) + '</td>' +
'<td class="stat-cell">' + (p.ground_kills || 0) + '</td>' +
'<td class="stat-cell">' + (p.air_kills || 0) + '</td>' +
'<td class="stat-cell">' + (p.assists || 0) + '</td>' +
'<td class="stat-cell">' + (p.deaths || 0) + '</td>' +
'<td class="stat-cell">' + (p.captures || 0) + '</td>' +
'</tr>';
}).join('');
renderPerformanceSparks(sortedRows);
}
async function renderPerformanceView() {
renderPerformanceSelectedPlayers();
renderPerformanceVehicleSummary();
const renderToken = ++performanceRenderToken;
const selectedMeta = getSelectedPerformancePlayersMeta();
if (!selectedMeta.length) {
renderPerformanceFilterOptions([]);
renderPerformanceOverview([]);
renderPerformanceTable([]);
return;
}
try {
await ensurePerformanceVehicleMeta();
const rows = await Promise.all(selectedMeta.map(async player => {
const games = await ensurePerformancePlayerGames(player.uid);
return buildPerformancePlayerState(player, games);
}));
if (renderToken !== performanceRenderToken) return;
if (renderPerformanceFilterOptions(rows)) {
renderPerformanceView();
return;
}
rows.forEach(row => { row.performance = Number(row[performanceMetric]) || 0; });
renderPerformanceOverview(rows);
renderPerformanceTable(rows);
} catch (err) {
if (renderToken !== performanceRenderToken) return;
console.error('Failed to render squadron performance view:', err);
renderPerformanceFilterOptions([]);
renderPerformanceOverview([]);
renderPerformanceTable([]);
}
}
window.renderSquadronPerformanceView = renderPerformanceView;
window.sortPerformanceTable = function(field) {
if (PERFORMANCE_HEADER_METRICS.has(field)) {
performanceMetric = field;
}
if (performanceSort.field === field) {
performanceSort.direction = performanceSort.direction === 'asc' ? 'desc' : 'asc';
} else {
performanceSort.field = field;
performanceSort.direction = field === 'nick' ? 'asc' : 'desc';
}
renderPerformanceView();
};
window.removePerformancePlayer = function(uid) {
performanceSelectedPlayers = performanceSelectedPlayers.filter(player => String(player.uid) !== String(uid));
renderPerformanceView();
};
window.clearPerformanceVehicleFilter = function() {
performanceSpecificVehicle = null;
const input = getElement('sq-performance-vehicle-search');
if (input) input.value = '';
renderPerformanceVehicleSummary();
renderPerformanceView();
};
function renderMembersTable(players) {
const tbody = getElement('sq-members-table-body');
if (!tbody) return;
if (!players.length) {
tbody.innerHTML = '';
return;
}
tbody.innerHTML = players.map(p => {
const nickRaw = p.nick || '';
const nickEsc = escapeHtml(nickRaw);
const nickJs = nickRaw.replace(/'/g, "\\'");
const winRate = (typeof p.win_rate === 'number' ? p.win_rate.toFixed(1) : (p.win_rate || 0)) + '%';
const kdr = typeof p.kdr === 'number' ? p.kdr.toFixed(2) : (p.kdr || 0);
const kps = typeof p.kps === 'number' ? p.kps.toFixed(2) : (Number(p.kps) || 0).toFixed(2);
return '<tr data-uid="' + escapeHtml(String(p.uid)) + '" class="row-link">' +
'<td class="player-name"><a href="/players/' + encodeURIComponent(p.uid) + '" class="row-link-overlay" aria-label="View ' + nickEsc + '"></a>' + nickEsc +
'<button class="pdm-details-btn" onclick="event.stopPropagation(); openPlayerDetailsModal(\'' + p.uid + '\', \'' + nickJs + '\')" title="' + __t('squadrons.quickDetails') + '">' +
'<i class="fas fa-eye"></i></button></td>' +
'<td class="stat-cell">' + (p.sqb_points || 0) + '</td>' +
'<td class="stat-cell">' + (p.total_battles || 0) + '</td>' +
'<td class="stat-cell">' + (p.wins || 0) + '</td>' +
'<td class="stat-cell">' + winRate + '</td>' +
'<td class="stat-cell">' + (p.total_kills || 0) + '</td>' +
'<td class="stat-cell">' + kps + '</td>' +
'<td class="stat-cell">' + kdr + '</td>' +
'<td class="stat-cell">' + (p.ground_kills || 0) + '</td>' +
'<td class="stat-cell">' + (p.air_kills || 0) + '</td>' +
'<td class="stat-cell">' + (p.assists || 0) + '</td>' +
'<td class="stat-cell">' + (p.deaths || 0) + '</td>' +
'<td class="stat-cell">' + (p.captures || 0) + '</td>' +
'</tr>';
}).join('');
// Reset sort state so the next call lands on desc instead of toggling
try { currentSort = { field: '', direction: 'desc' }; } catch (e) { /* no-op */ }
if (typeof sortMembersTable === 'function') {
sortMembersTable('sqb_points');
}
}
initSeasons();
renderMembersTable(currentSquadronPlayers);
})();
</script>
</body>
</html>