3466 lines
179 KiB
Plaintext
3466 lines
179 KiB
Plaintext
<!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><%= t('analytics.pageTitle') %> | <%= botName %></title>
|
||
<meta name="description" content="<%= t('analytics.pageSubtitle') %>">
|
||
<link rel="icon" type="image/png" href="/images/transparent_toothlessssss.png">
|
||
|
||
<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">
|
||
<link rel="stylesheet" href="/css/output.css">
|
||
<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; }
|
||
|
||
.leaderboard-container {
|
||
max-width: 1600px;
|
||
margin: 0 auto;
|
||
padding: 6rem 1rem 2rem;
|
||
min-height: calc(100vh - 200px);
|
||
}
|
||
.leaderboard-header { text-align: center; margin-bottom: 2rem; }
|
||
.leaderboard-title {
|
||
font-size: 2.5rem; font-weight: 700; margin-bottom: 0.5rem;
|
||
background: linear-gradient(135deg, #F5F5DC 0%, #E8E8D0 100%);
|
||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||
}
|
||
.leaderboard-subtitle { font-size: 1.1rem; color: rgba(255, 255, 255, 0.8); margin-bottom: 1rem; }
|
||
.disclaimer { font-size: 0.9rem; opacity: 0.8; font-style: italic; color: rgba(255, 255, 255, 0.7); }
|
||
|
||
.mode-pills {
|
||
display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.mode-pill {
|
||
padding: 0.6rem 1.4rem;
|
||
background: rgba(30, 30, 30, 0.8);
|
||
border: 1px solid rgba(144, 238, 144, 0.2);
|
||
border-radius: 999px;
|
||
color: #ffffff; font-weight: 500; cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.mode-pill:hover { background: rgba(144, 238, 144, 0.1); color: #90EE90; border-color: #90EE90; }
|
||
.mode-pill.active {
|
||
background: linear-gradient(135deg, rgba(144, 238, 144, 0.15), rgba(144, 238, 144, 0.05));
|
||
color: #90EE90; border-color: #90EE90;
|
||
text-shadow: 0 0 12px rgba(144, 238, 144, 0.5);
|
||
}
|
||
|
||
.search-wrapper {
|
||
max-width: 800px; margin: 0 auto 2.5rem; position: relative; z-index: 100;
|
||
}
|
||
.search-bar {
|
||
background: rgba(28, 28, 28, 0.6);
|
||
border: 1px solid rgba(144, 238, 144, 0.08);
|
||
border-radius: 16px;
|
||
backdrop-filter: blur(20px);
|
||
transition: border-color 0.3s ease;
|
||
padding: 0.75rem 1.25rem;
|
||
display: flex; align-items: center; gap: 0.75rem;
|
||
position: relative;
|
||
}
|
||
.search-bar:focus-within { border-color: rgba(144, 238, 144, 0.2); }
|
||
.search-bar input {
|
||
flex: 1; width: 100%; background: transparent; border: none; outline: none;
|
||
color: #fff; font-size: 14px; font-weight: 500; padding: 0.5rem 0;
|
||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||
}
|
||
/* Squadron tags + vehicle names contain skyquake glyphs (▄ ◢ ◊ ␗ etc.)
|
||
that need the symbol font to render. Player nicks don't, so leave
|
||
the player input on Inter. */
|
||
#squadronInput, #vehicleInput {
|
||
font-family: 'skyquakesymbols', 'Inter', system-ui, -apple-system, sans-serif;
|
||
}
|
||
.search-label {
|
||
font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase;
|
||
color: rgba(144, 238, 144, 0.35); margin-bottom: 0.25rem;
|
||
}
|
||
.search-drop {
|
||
position: absolute;
|
||
left: 0; right: 0; top: calc(100% + 8px);
|
||
border-radius: 12px; max-height: 280px; overflow-y: auto; z-index: 999;
|
||
background: rgba(20, 20, 20, 0.98);
|
||
border: 1px solid rgba(144, 238, 144, 0.1);
|
||
backdrop-filter: blur(24px);
|
||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||
}
|
||
.search-drop::-webkit-scrollbar { width: 4px; }
|
||
.search-drop::-webkit-scrollbar-thumb { background: rgba(144, 238, 144, 0.15); border-radius: 4px; }
|
||
.search-hit {
|
||
padding: 10px 16px; cursor: pointer; transition: background 0.15s;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||
}
|
||
.search-hit:last-child { border-bottom: none; }
|
||
.search-hit:hover { background: rgba(144, 238, 144, 0.05); }
|
||
.sq-tag { font-family: 'skyquakesymbols', 'Inter', sans-serif; color: #A8E6CF; }
|
||
/* Apply the skyquake symbol font everywhere a WT-translated vehicle
|
||
name is shown, so country-leak / event indicator glyphs (▄ ◢ ◊ ␗
|
||
etc.) render as proper icons instead of fallback Unicode boxes.
|
||
Browsers vary on how strictly they let custom fonts apply inside
|
||
<select>/<option> — applying it on both is the most we can do. */
|
||
.vehicle-name,
|
||
.comp-lineup-items {
|
||
font-family: 'skyquakesymbols', 'Inter', sans-serif;
|
||
}
|
||
|
||
.leaderboard-nav {
|
||
background: rgba(30, 30, 30, 0.6);
|
||
border-radius: 1rem;
|
||
padding: 1rem;
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(144, 238, 144, 0.1);
|
||
margin-bottom: 2rem;
|
||
display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap;
|
||
}
|
||
.leaderboard-tab {
|
||
padding: 0.65rem 1.25rem;
|
||
background: rgba(30, 30, 30, 0.8);
|
||
border: 1px solid rgba(144, 238, 144, 0.2);
|
||
border-radius: 0.5rem;
|
||
color: #ffffff; cursor: pointer; font-weight: 500;
|
||
transition: all 0.3s ease;
|
||
}
|
||
.leaderboard-tab:hover { background: rgba(144, 238, 144, 0.1); color: #90EE90; border-color: #90EE90; }
|
||
.leaderboard-tab.active {
|
||
background: linear-gradient(135deg, rgba(144, 238, 144, 0.15), rgba(144, 238, 144, 0.05));
|
||
color: #90EE90; border-color: #90EE90;
|
||
text-shadow: 0 0 12px rgba(144, 238, 144, 0.5);
|
||
}
|
||
|
||
.leaderboard-content {
|
||
background: rgba(30, 30, 30, 0.6);
|
||
border-radius: 1rem;
|
||
padding: 2rem;
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(144, 238, 144, 0.1);
|
||
min-height: 300px;
|
||
}
|
||
|
||
.loading-state, .empty-state, .error-state {
|
||
text-align: center; padding: 4rem 2rem; color: #90EE90;
|
||
}
|
||
.loading-spinner {
|
||
border: 3px solid rgba(144, 238, 144, 0.3); border-top: 3px solid #90EE90;
|
||
border-radius: 50%; width: 40px; height: 40px;
|
||
animation: spin 1s linear infinite; margin: 0 auto 1rem;
|
||
}
|
||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||
.error-state { color: #ff6b6b; }
|
||
|
||
.analytics-table {
|
||
width: 100%; border-collapse: collapse;
|
||
background: rgba(30, 30, 30, 0.8);
|
||
border-radius: 0.75rem; overflow: hidden;
|
||
border: 1px solid rgba(144, 238, 144, 0.1);
|
||
}
|
||
.analytics-table th {
|
||
background: linear-gradient(135deg, rgba(144, 238, 144, 0.15), rgba(144, 238, 144, 0.05));
|
||
color: #F5F5DC; padding: 0.9rem 1rem; text-align: center;
|
||
font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.5px;
|
||
border-bottom: 1px solid rgba(144, 238, 144, 0.2);
|
||
text-shadow: 0 0 10px rgba(144, 238, 144, 0.3);
|
||
}
|
||
.analytics-table th:first-child, .analytics-table td:first-child { text-align: left; }
|
||
.analytics-table td {
|
||
padding: 0.75rem 1rem; border-bottom: 1px solid rgba(144, 238, 144, 0.08);
|
||
color: #fff; text-align: center; font-size: 0.9rem;
|
||
}
|
||
.analytics-table tr:last-child td { border-bottom: none; }
|
||
.analytics-table tr:hover td { background: rgba(144, 238, 144, 0.04); }
|
||
.analytics-table th.sortable-th { cursor: pointer; user-select: none; }
|
||
.analytics-table th.sortable-th:hover { background: linear-gradient(135deg, rgba(144, 238, 144, 0.25), rgba(144, 238, 144, 0.1)); }
|
||
.analytics-table th .sort-ind { margin-left: 4px; font-size: 0.7rem; opacity: 0.4; }
|
||
.analytics-table th[data-sort-dir="asc"] .sort-ind,
|
||
.analytics-table th[data-sort-dir="desc"] .sort-ind { opacity: 0.95; color: #90EE90; }
|
||
|
||
.bar-track {
|
||
width: 100%; max-width: 160px; height: 8px;
|
||
background: rgba(144, 238, 144, 0.08); border-radius: 999px;
|
||
overflow: hidden; margin: 0 auto;
|
||
}
|
||
.bar-fill {
|
||
height: 100%; background: linear-gradient(90deg, #90EE90, #A8E6CF);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.section-divider {
|
||
text-align: left; padding: 0.8rem 1rem 0.4rem;
|
||
color: #90EE90; font-weight: 600; text-transform: uppercase;
|
||
font-size: 0.75rem; letter-spacing: 0.15em;
|
||
}
|
||
|
||
.matchup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||
.matchup-col h3 {
|
||
text-align: center; margin-bottom: 1rem;
|
||
color: #F5F5DC; font-size: 1.05rem; font-weight: 600;
|
||
}
|
||
|
||
.maps-layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
gap: 1.5rem;
|
||
align-items: start;
|
||
}
|
||
.maps-table-wrap {
|
||
max-height: 640px;
|
||
overflow-y: auto;
|
||
border-radius: 0.75rem;
|
||
border: 1px solid rgba(144, 238, 144, 0.1);
|
||
}
|
||
.maps-table-wrap::-webkit-scrollbar { width: 6px; }
|
||
.maps-table-wrap::-webkit-scrollbar-thumb { background: rgba(144, 238, 144, 0.2); border-radius: 4px; }
|
||
.maps-table-wrap .analytics-table { border: none; border-radius: 0; }
|
||
.maps-table-wrap .analytics-table thead th {
|
||
position: sticky; top: 0; z-index: 2;
|
||
background: #2a352a;
|
||
box-shadow: inset 0 -1px 0 rgba(144, 238, 144, 0.25);
|
||
}
|
||
.maps-table .col-map { width: 32%; }
|
||
.maps-table .col-num { width: 9%; }
|
||
.maps-table .col-bar { width: 32%; }
|
||
.maps-table .bar-track {
|
||
max-width: none; width: 100%; height: 12px;
|
||
}
|
||
.squadmates-table .sqmate-link {
|
||
color: #A8E6CF;
|
||
text-decoration: none;
|
||
}
|
||
.squadmates-table .sqmate-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.squadmates-table .sqmate-meta {
|
||
display: block;
|
||
margin-top: 0.15rem;
|
||
font-size: 0.72rem;
|
||
color: rgba(168, 230, 207, 0.65);
|
||
letter-spacing: 0.03em;
|
||
}
|
||
|
||
.radar-card {
|
||
background: linear-gradient(135deg, rgba(40, 50, 40, 0.55), rgba(20, 25, 20, 0.55));
|
||
border: 1px solid rgba(144, 238, 144, 0.15);
|
||
border-radius: 1rem;
|
||
padding: 1.25rem 1.25rem 0.75rem;
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||
position: sticky; top: 1rem;
|
||
}
|
||
.radar-card-header {
|
||
display: flex; justify-content: space-between; align-items: baseline;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.radar-card-title {
|
||
font-size: 0.95rem; font-weight: 600; color: #F5F5DC;
|
||
text-transform: uppercase; letter-spacing: 0.1em;
|
||
}
|
||
.radar-card-meta { font-size: 0.7rem; color: rgba(168, 230, 207, 0.7); letter-spacing: 0.05em; }
|
||
.radar-canvas-wrap { position: relative; width: 100%; aspect-ratio: 1 / 1; max-width: 620px; margin: 0 auto; }
|
||
.radar-legend {
|
||
display: flex; justify-content: center; gap: 1.25rem; flex-wrap: wrap;
|
||
font-size: 0.75rem; color: rgba(255, 255, 255, 0.75); margin-top: 0.5rem;
|
||
}
|
||
.radar-legend .swatch {
|
||
display: inline-block; width: 10px; height: 10px; border-radius: 2px;
|
||
margin-right: 0.4rem; vertical-align: middle;
|
||
}
|
||
.radar-footnote {
|
||
margin-top: 0.6rem; font-size: 0.7rem; color: rgba(255,255,255,0.45);
|
||
text-align: center; font-style: italic;
|
||
}
|
||
|
||
.entity-header {
|
||
display: flex; flex-wrap: wrap; align-items: center; gap: 1.25rem;
|
||
padding: 1rem 1.25rem;
|
||
background: rgba(30, 30, 30, 0.6);
|
||
border: 1px solid rgba(144, 238, 144, 0.12);
|
||
border-radius: 0.75rem;
|
||
margin-bottom: 1.25rem;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
.entity-name {
|
||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||
font-size: 1.15rem; font-weight: 600; color: #F5F5DC;
|
||
text-decoration: none; padding-right: 1rem;
|
||
border-right: 1px solid rgba(144, 238, 144, 0.18);
|
||
}
|
||
.entity-name:hover { color: #90EE90; }
|
||
.entity-name i { color: rgba(144, 238, 144, 0.7); font-size: 0.95rem; }
|
||
|
||
/* Filter bar nests inline inside the entity header now. */
|
||
.af-time-filter-bar {
|
||
display: flex; flex-wrap: wrap; align-items: end; gap: 1rem;
|
||
padding: 0; background: transparent; border: none;
|
||
border-radius: 0; margin-bottom: 0; backdrop-filter: none;
|
||
flex: 1 1 auto;
|
||
}
|
||
.af-time-filter-bar .filter-group {
|
||
display: flex; flex-direction: column; gap: 0.4rem;
|
||
}
|
||
.af-time-filter-bar label {
|
||
color: rgba(255, 255, 255, 0.9); font-weight: 500;
|
||
font-size: 0.85rem; white-space: nowrap;
|
||
}
|
||
.af-time-filter-bar select,
|
||
.af-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: #fff; font-size: 0.9rem; min-width: 140px;
|
||
font-family: inherit;
|
||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
.af-time-filter-bar select:focus,
|
||
.af-time-filter-bar input[type="date"]:focus {
|
||
outline: none; border-color: #90EE90;
|
||
box-shadow: 0 0 15px rgba(144, 238, 144, 0.3);
|
||
}
|
||
.af-time-filter-bar select option { background: rgba(30,30,30,0.95); color: #fff; }
|
||
.af-time-filter-bar input[type="date"]::-webkit-calendar-picker-indicator {
|
||
filter: invert(1) opacity(0.6); cursor: pointer;
|
||
}
|
||
.af-time-filter-bar .custom-range { flex-direction: row; align-items: end; gap: 0.5rem; }
|
||
.af-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;
|
||
}
|
||
.af-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;
|
||
}
|
||
|
||
.consistency-layout {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||
gap: 1.5rem;
|
||
align-items: start;
|
||
}
|
||
.consistency-table-wrap {
|
||
max-height: 640px; overflow-y: auto;
|
||
border-radius: 0.75rem;
|
||
border: 1px solid rgba(144, 238, 144, 0.1);
|
||
}
|
||
.consistency-table-wrap::-webkit-scrollbar { width: 6px; }
|
||
.consistency-table-wrap::-webkit-scrollbar-thumb { background: rgba(144, 238, 144, 0.2); border-radius: 4px; }
|
||
.consistency-table-wrap .analytics-table { border: none; border-radius: 0; }
|
||
.consistency-table-wrap .analytics-table thead th {
|
||
position: sticky; top: 0; z-index: 2;
|
||
background: #2a352a;
|
||
box-shadow: inset 0 -1px 0 rgba(144, 238, 144, 0.25);
|
||
}
|
||
.consistency-row { cursor: pointer; transition: background 0.15s; }
|
||
.consistency-row.active td { background: rgba(144, 238, 144, 0.12) !important; }
|
||
.scatter-card {
|
||
background: linear-gradient(135deg, rgba(40, 50, 40, 0.55), rgba(20, 25, 20, 0.55));
|
||
border: 1px solid rgba(144, 238, 144, 0.15);
|
||
border-radius: 1rem;
|
||
padding: 1.25rem 1.25rem 0.75rem;
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||
position: sticky; top: 1rem;
|
||
}
|
||
.scatter-card-header {
|
||
display: flex; justify-content: space-between; align-items: baseline;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.scatter-card-title {
|
||
font-size: 0.95rem; font-weight: 600; color: #F5F5DC;
|
||
text-transform: uppercase; letter-spacing: 0.1em;
|
||
}
|
||
.scatter-reset {
|
||
background: rgba(144,238,144,0.15); border: 1px solid rgba(144,238,144,0.3);
|
||
color: #90EE90; border-radius: 6px; padding: 0.25rem 0.6rem;
|
||
font-size: 0.7rem; cursor: pointer;
|
||
}
|
||
.scatter-reset:hover { background: rgba(144,238,144,0.25); }
|
||
.scatter-canvas-wrap { position: relative; width: 100%; aspect-ratio: 1 / 1; max-width: 620px; margin: 0 auto; }
|
||
|
||
.comps-layout {
|
||
display: flex; flex-direction: column; gap: 1.5rem;
|
||
}
|
||
.comps-section { display: flex; flex-direction: column; gap: 0.6rem; }
|
||
.comps-section-header {
|
||
display: flex; justify-content: space-between; align-items: baseline;
|
||
padding: 0 0.25rem;
|
||
}
|
||
.comps-section-title {
|
||
font-size: 0.95rem; font-weight: 600; color: #F5F5DC;
|
||
text-transform: uppercase; letter-spacing: 0.1em;
|
||
}
|
||
.comps-section-meta {
|
||
font-size: 0.7rem; color: rgba(168, 230, 207, 0.7); letter-spacing: 0.05em;
|
||
}
|
||
.comps-table-wrap {
|
||
max-height: 540px; overflow-y: auto;
|
||
border-radius: 0.75rem;
|
||
border: 1px solid rgba(144, 238, 144, 0.1);
|
||
}
|
||
.comps-table-wrap::-webkit-scrollbar { width: 6px; }
|
||
.comps-table-wrap::-webkit-scrollbar-thumb { background: rgba(144, 238, 144, 0.2); border-radius: 4px; }
|
||
.comps-table-wrap .analytics-table { border: none; border-radius: 0; }
|
||
.comps-table-wrap .analytics-table thead th {
|
||
position: sticky; top: 0; z-index: 2;
|
||
background: #2a352a;
|
||
box-shadow: inset 0 -1px 0 rgba(144, 238, 144, 0.25);
|
||
}
|
||
.comp-type-tag {
|
||
display: inline-block; margin-left: 0.4rem;
|
||
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em;
|
||
padding: 0.05rem 0.35rem;
|
||
border: 1px solid currentColor; border-radius: 4px;
|
||
opacity: 0.85;
|
||
}
|
||
.comp-type-badges {
|
||
display: inline-flex; gap: 0.35rem; flex-wrap: wrap;
|
||
align-items: center; justify-content: flex-start;
|
||
}
|
||
.comp-type-badge {
|
||
display: inline-flex; align-items: baseline; gap: 0.2rem;
|
||
padding: 0.18rem 0.5rem;
|
||
background: rgba(0, 0, 0, 0.25);
|
||
border: 1px solid; border-radius: 6px;
|
||
font-size: 0.85rem; font-weight: 700;
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
.comp-type-badge-letter {
|
||
font-size: 0.68rem; font-weight: 600; letter-spacing: 0.05em;
|
||
opacity: 0.85;
|
||
}
|
||
.comp-lineup {
|
||
display: flex; flex-direction: column; gap: 0.2rem;
|
||
text-align: left;
|
||
}
|
||
.comp-lineup-row {
|
||
display: flex; gap: 0.6rem; align-items: baseline;
|
||
font-size: 0.85rem; line-height: 1.35;
|
||
}
|
||
.comp-lineup-label {
|
||
flex: 0 0 140px;
|
||
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em;
|
||
text-transform: uppercase; opacity: 0.95;
|
||
white-space: nowrap;
|
||
}
|
||
.comp-lineup-items {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
color: rgba(245, 245, 220, 0.92);
|
||
}
|
||
.comps-comp-table td:nth-child(2),
|
||
.comps-comp-table td:nth-child(3) { text-align: left; }
|
||
.comps-comp-table th:nth-child(2),
|
||
.comps-comp-table th:nth-child(3) { text-align: left; padding-left: 1rem; }
|
||
|
||
.comp-search {
|
||
display: flex; flex-direction: column; gap: 0.6rem;
|
||
padding: 0.85rem 1rem;
|
||
background: rgba(20, 25, 20, 0.55);
|
||
border: 1px solid rgba(144, 238, 144, 0.15);
|
||
border-radius: 0.75rem;
|
||
}
|
||
.comp-search-row {
|
||
display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center;
|
||
}
|
||
.comp-search-label {
|
||
flex: 0 0 auto;
|
||
font-size: 0.75rem; color: rgba(168, 230, 207, 0.85);
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
min-width: 110px;
|
||
}
|
||
.comp-search-select {
|
||
flex: 1 1 220px;
|
||
background: rgba(15, 20, 15, 0.95);
|
||
border: 1px solid rgba(144, 238, 144, 0.3);
|
||
border-radius: 0.45rem;
|
||
padding: 0.4rem 0.6rem;
|
||
color: #F5F5DC; font-size: 0.85rem;
|
||
}
|
||
.comp-search-select option { background: rgba(15, 20, 15, 0.98); color: #F5F5DC; }
|
||
.comp-search-btn {
|
||
padding: 0.35rem 0.85rem;
|
||
border-radius: 0.45rem; border: 1px solid rgba(144, 238, 144, 0.3);
|
||
background: rgba(144, 238, 144, 0.1); color: #F5F5DC;
|
||
cursor: pointer; font-size: 0.8rem;
|
||
}
|
||
.comp-search-btn:hover { background: rgba(144, 238, 144, 0.2); }
|
||
.comp-search-btn-primary {
|
||
background: rgba(144, 238, 144, 0.25);
|
||
border-color: #90EE90; color: #1b1b1b; font-weight: 600;
|
||
}
|
||
.comp-search-btn-primary:hover { background: #90EE90; }
|
||
.comp-search-btn-ghost { background: transparent; border-color: rgba(144, 238, 144, 0.2); }
|
||
.comp-custom-types {
|
||
display: flex; flex-wrap: wrap; gap: 0.5rem;
|
||
}
|
||
.comp-custom-type {
|
||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||
padding: 0.4rem 0.65rem;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border: 1px solid rgba(144, 238, 144, 0.15);
|
||
border-radius: 0.5rem;
|
||
cursor: help; /* hints that the title tooltip exists */
|
||
transition: border-color 0.15s ease, background 0.15s ease;
|
||
}
|
||
.comp-custom-type:hover {
|
||
border-color: rgba(144, 238, 144, 0.45);
|
||
background: rgba(144, 238, 144, 0.05);
|
||
}
|
||
/* Bump the type letter inside the customizer specifically — the small
|
||
badges in the Comp column stay at their original size. */
|
||
.comp-custom-type .comp-type-badge {
|
||
padding: 0.3rem 0.65rem;
|
||
font-size: 1.05rem;
|
||
border-radius: 7px;
|
||
}
|
||
.comp-custom-type .comp-type-badge-letter {
|
||
font-size: 0.95rem;
|
||
opacity: 0.95;
|
||
}
|
||
.comp-custom-type input[type="number"] {
|
||
width: 64px;
|
||
background: rgba(15, 20, 15, 0.95);
|
||
border: 1px solid rgba(144, 238, 144, 0.25);
|
||
border-radius: 0.4rem;
|
||
padding: 0.35rem 0.5rem;
|
||
color: #F5F5DC; font-size: 1rem; text-align: center;
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 600;
|
||
-moz-appearance: textfield;
|
||
}
|
||
.comp-custom-type input[type="number"]:focus {
|
||
outline: none;
|
||
border-color: #90EE90;
|
||
box-shadow: 0 0 12px rgba(144, 238, 144, 0.18);
|
||
}
|
||
.comp-custom-type input[type="number"]::-webkit-outer-spin-button,
|
||
.comp-custom-type input[type="number"]::-webkit-inner-spin-button {
|
||
-webkit-appearance: none; margin: 0;
|
||
}
|
||
.comp-search-row-block { align-items: flex-start; }
|
||
.comp-search-hint {
|
||
font-size: 0.7rem; color: rgba(245, 245, 220, 0.55);
|
||
font-style: italic;
|
||
}
|
||
.comp-refine-container {
|
||
display: flex; flex-direction: column; gap: 0.45rem;
|
||
flex: 1 1 100%;
|
||
}
|
||
.comp-refine-hint {
|
||
font-size: 0.78rem;
|
||
color: rgba(245, 245, 220, 0.55);
|
||
font-style: italic;
|
||
}
|
||
.comp-refine-row {
|
||
display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap;
|
||
}
|
||
.comp-refine-label {
|
||
flex: 0 0 130px;
|
||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em;
|
||
text-transform: uppercase; opacity: 0.95;
|
||
}
|
||
.comp-refine-slots {
|
||
display: flex; flex-wrap: wrap; gap: 0.35rem;
|
||
flex: 1 1 auto;
|
||
}
|
||
/* Custom dropdown — native <select> is replaced with a div+button so
|
||
the skyquake symbol font actually applies to options across browsers
|
||
(Firefox in particular delegates option rendering to the OS). */
|
||
.comp-refine-select {
|
||
position: relative;
|
||
flex: 1 1 160px;
|
||
min-width: 140px;
|
||
}
|
||
.comp-refine-select-btn {
|
||
width: 100%;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
gap: 0.4rem;
|
||
background: rgba(15, 20, 15, 0.95);
|
||
border: 1px solid rgba(144, 238, 144, 0.25);
|
||
border-radius: 0.4rem;
|
||
padding: 0.3rem 0.6rem;
|
||
color: #F5F5DC; font-size: 0.82rem;
|
||
font-family: 'skyquakesymbols', 'Inter', sans-serif;
|
||
cursor: pointer; text-align: left;
|
||
}
|
||
.comp-refine-select-btn:hover { border-color: rgba(144, 238, 144, 0.45); }
|
||
.comp-refine-select.is-open .comp-refine-select-btn {
|
||
border-color: #90EE90;
|
||
box-shadow: 0 0 12px rgba(144, 238, 144, 0.18);
|
||
}
|
||
.comp-refine-select-btn-label {
|
||
flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.comp-refine-select-btn-label.is-placeholder { color: rgba(245, 245, 220, 0.5); }
|
||
.comp-refine-select-btn i {
|
||
font-size: 0.65rem; opacity: 0.55;
|
||
transition: transform 0.15s ease;
|
||
}
|
||
.comp-refine-select.is-open .comp-refine-select-btn i { transform: rotate(180deg); opacity: 0.95; }
|
||
.comp-refine-select-drop {
|
||
position: absolute;
|
||
left: 0; right: 0; top: calc(100% + 4px);
|
||
border-radius: 0.5rem; max-height: 280px; overflow-y: auto;
|
||
z-index: 60;
|
||
background: rgba(20, 20, 20, 0.98);
|
||
border: 1px solid rgba(144, 238, 144, 0.15);
|
||
backdrop-filter: blur(24px);
|
||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||
display: none;
|
||
}
|
||
.comp-refine-select.is-open .comp-refine-select-drop { display: block; }
|
||
.comp-refine-select-drop::-webkit-scrollbar { width: 4px; }
|
||
.comp-refine-select-drop::-webkit-scrollbar-thumb { background: rgba(144, 238, 144, 0.15); border-radius: 4px; }
|
||
.comp-refine-select-opt {
|
||
padding: 0.4rem 0.65rem;
|
||
color: #F5F5DC; font-size: 0.82rem;
|
||
font-family: 'skyquakesymbols', 'Inter', sans-serif;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.comp-refine-select-opt:last-child { border-bottom: none; }
|
||
.comp-refine-select-opt:hover { background: rgba(144, 238, 144, 0.08); }
|
||
.comp-refine-select-opt.is-selected {
|
||
background: rgba(144, 238, 144, 0.12);
|
||
color: #90EE90;
|
||
}
|
||
.comp-refine-select-opt.is-placeholder { color: rgba(245, 245, 220, 0.55); font-style: italic; }
|
||
.comp-search-meta {
|
||
font-size: 0.75rem; color: rgba(168, 230, 207, 0.7);
|
||
text-align: right;
|
||
}
|
||
|
||
.time-table .time-cell { width: 50%; }
|
||
.time-table .time-cell .time-label {
|
||
display: inline-block; min-width: 95px; color: #fff;
|
||
}
|
||
.time-table .bar-track.time-bar {
|
||
display: inline-block; vertical-align: middle;
|
||
width: calc(100% - 110px); max-width: none; height: 10px;
|
||
margin-left: 0.5rem;
|
||
}
|
||
|
||
.micro-bar {
|
||
display: inline-block; vertical-align: middle;
|
||
width: 70px; height: 8px;
|
||
background: rgba(144, 238, 144, 0.08); border-radius: 999px; overflow: hidden;
|
||
margin-left: 0.4rem;
|
||
}
|
||
.micro-bar > span {
|
||
display: block; height: 100%;
|
||
background: linear-gradient(90deg, #90EE90, #A8E6CF); border-radius: 999px;
|
||
}
|
||
.micro-bar.kd > span { background: linear-gradient(90deg, #F5C16C, #F5E6A0); }
|
||
|
||
.player-stat-strip {
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||
gap: 0.75rem; margin-bottom: 1rem;
|
||
}
|
||
.player-stat-strip > div {
|
||
background: rgba(30,30,30,0.7);
|
||
border: 1px solid rgba(144, 238, 144, 0.12);
|
||
border-radius: 0.6rem;
|
||
padding: 0.65rem 0.85rem;
|
||
display: flex; flex-direction: column; gap: 0.25rem;
|
||
}
|
||
.player-stat-strip .ps-label {
|
||
font-size: 0.7rem; color: rgba(144,238,144,0.7);
|
||
text-transform: uppercase; letter-spacing: 0.08em;
|
||
}
|
||
.player-stat-strip .ps-val { font-size: 1.1rem; font-weight: 600; color: #F5F5DC; }
|
||
|
||
.timeline-card {
|
||
background: linear-gradient(135deg, rgba(40, 50, 40, 0.55), rgba(20, 25, 20, 0.55));
|
||
border: 1px solid rgba(144, 238, 144, 0.15);
|
||
border-radius: 1rem;
|
||
padding: 1.25rem;
|
||
backdrop-filter: blur(14px);
|
||
-webkit-backdrop-filter: blur(14px);
|
||
}
|
||
.timeline-card-header {
|
||
display: flex; justify-content: space-between; align-items: baseline;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.timeline-card-title {
|
||
font-size: 0.95rem; font-weight: 600; color: #F5F5DC;
|
||
text-transform: uppercase; letter-spacing: 0.1em;
|
||
}
|
||
.timeline-card-meta { font-size: 0.7rem; color: rgba(168, 230, 207, 0.7); letter-spacing: 0.05em; }
|
||
.timeline-canvas-wrap { position: relative; width: 100%; height: 320px; }
|
||
.calendar-wrap {
|
||
overflow-x: auto; padding: 0.25rem 0;
|
||
}
|
||
.calendar-wrap::-webkit-scrollbar { height: 6px; }
|
||
.calendar-wrap::-webkit-scrollbar-thumb { background: rgba(144,238,144,0.2); border-radius: 4px; }
|
||
|
||
@media (max-width: 1024px) {
|
||
.maps-layout, .consistency-layout { grid-template-columns: 1fr; }
|
||
.radar-card, .scatter-card { position: static; }
|
||
}
|
||
@media (max-width: 768px) {
|
||
.matchup-grid { grid-template-columns: 1fr; }
|
||
.leaderboard-title { font-size: 2rem; }
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body class="text-white antialiased">
|
||
<%- include('partials/nav', { activePage: 'analytics' }) %>
|
||
|
||
<div class="leaderboard-container">
|
||
<div class="leaderboard-header">
|
||
<h1 class="leaderboard-title"><%= t('analytics.pageTitle') %></h1>
|
||
<p class="leaderboard-subtitle"><%= t('analytics.pageSubtitle') %></p>
|
||
<div class="disclaimer">
|
||
<i class="fas fa-info-circle" style="margin-right: 0.5rem;"></i>
|
||
<%= t('common.recordingSince') %>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mode-pills">
|
||
<button class="mode-pill active" data-mode="squadron">
|
||
<i class="fas fa-users"></i> <%= t('analytics.modeSquadron') %>
|
||
</button>
|
||
<button class="mode-pill" data-mode="player">
|
||
<i class="fas fa-user"></i> <%= t('analytics.modePlayer') %>
|
||
</button>
|
||
<button class="mode-pill" data-mode="vehicle">
|
||
<i class="fas fa-fighter-jet"></i> <%= t('analytics.modeVehicle') %>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="search-wrapper">
|
||
<div id="squadronSearchBlock">
|
||
<div class="search-bar">
|
||
<div style="flex:1; position:relative;">
|
||
<div class="search-label"><%= t('analytics.modeSquadron') %></div>
|
||
<input type="text" id="squadronInput" placeholder="<%= t('home.typeSquadronName') %>" autocomplete="off">
|
||
<div id="squadronDropdown" class="search-drop hidden"></div>
|
||
</div>
|
||
<button id="squadronClear" class="hidden" style="background:none; border:none; color:rgba(255,255,255,0.3); cursor:pointer; padding:4px 8px;">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="playerSearchBlock" class="hidden">
|
||
<div class="search-bar">
|
||
<div style="flex:1; position:relative;">
|
||
<div class="search-label"><%= t('analytics.modePlayer') %></div>
|
||
<input type="text" id="playerInput" placeholder="<%= t('home.typePlayerName') %>" autocomplete="off">
|
||
<div id="playerDropdown" class="search-drop hidden"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="vehicleSearchBlock" class="hidden">
|
||
<div class="search-bar">
|
||
<div style="flex:1; position:relative;">
|
||
<div class="search-label"><%= t('analytics.modeVehicle') %></div>
|
||
<input type="text" id="vehicleInput" placeholder="<%= t('leaderboard.searchVehiclePlaceholder') %>" autocomplete="off">
|
||
<div id="vehicleDropdown" class="search-drop hidden"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="analyticsResultsRoot">
|
||
<div class="leaderboard-content">
|
||
<div class="empty-state" id="emptyHint">
|
||
<i class="fas fa-arrow-up" style="font-size:1.5rem; margin-bottom:0.75rem; display:block;"></i>
|
||
<span id="emptyHintText"><%= t('analytics.pickSquadron') %></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%- include('partials/footer') %>
|
||
|
||
<script>
|
||
window.__lang = '<%= lang %>';
|
||
window.__i18n = <%- localeJson %>;
|
||
window.__t = function(key, params) {
|
||
var parts = key.split('.'), obj = window.__i18n;
|
||
for (var i = 0; i < parts.length; i++) { obj = obj && obj[parts[i]]; }
|
||
var val = obj !== undefined ? obj : key;
|
||
if (params && typeof val === 'string') {
|
||
for (var p in params) {
|
||
if (Object.prototype.hasOwnProperty.call(params, p)) {
|
||
val = val.replace(new RegExp('\\{' + p + '\\}', 'g'), params[p]);
|
||
}
|
||
}
|
||
}
|
||
return val;
|
||
};
|
||
</script>
|
||
<script src="/js/main.js?v=3"></script>
|
||
<script src="/js/api-client.js"></script>
|
||
<script src="/js/seasons-filter.js"></script>
|
||
<script src="/js/vehicle-i18n.js"></script>
|
||
<script src="/js/chart.umd.min.js"></script>
|
||
<script>
|
||
(function() {
|
||
const escapeHtml = (t) => { const d = document.createElement('div'); d.textContent = t == null ? '' : String(t); return d.innerHTML; };
|
||
const T = (k, params) => window.__t(k, params);
|
||
|
||
// ─────────── Mode switching ───────────
|
||
const pills = document.querySelectorAll('.mode-pill');
|
||
const blocks = {
|
||
squadron: document.getElementById('squadronSearchBlock'),
|
||
player: document.getElementById('playerSearchBlock'),
|
||
vehicle: document.getElementById('vehicleSearchBlock'),
|
||
};
|
||
const hintText = document.getElementById('emptyHintText');
|
||
const hintMsgs = {
|
||
squadron: 'analytics.pickSquadron',
|
||
player: 'analytics.pickPlayer',
|
||
vehicle: 'analytics.pickVehicle',
|
||
};
|
||
let currentMode = 'squadron';
|
||
|
||
function setMode(mode) {
|
||
currentMode = mode;
|
||
pills.forEach(p => p.classList.toggle('active', p.dataset.mode === mode));
|
||
Object.entries(blocks).forEach(([key, el]) => el.classList.toggle('hidden', key !== mode));
|
||
// Reset results panel when switching modes
|
||
resetResults();
|
||
hintText.textContent = T(hintMsgs[mode]);
|
||
}
|
||
pills.forEach(p => p.addEventListener('click', () => setMode(p.dataset.mode)));
|
||
|
||
// ─────────── Data loading (shared squadrons + players) ───────────
|
||
let playersData = [];
|
||
let allSquadrons = [];
|
||
|
||
async function loadData() {
|
||
try {
|
||
// Load player leaderboard + squadron leaderboard in parallel.
|
||
// The player count we used to derive from playersData was
|
||
// "every player who's ever logged a match wearing this tag",
|
||
// which can exceed WT's 128-member cap because it counts
|
||
// historical members too. /api/leaderboard/squadrons exposes
|
||
// the real current-member count from the squadron_members
|
||
// cache — use that as the authoritative number, and only
|
||
// fall back to the historical derivation for squadrons not
|
||
// present in that cache.
|
||
const [playerResp, squadronResp] = await Promise.all([
|
||
window.apiClient.getPlayerLeaderboard(),
|
||
window.apiClient.getSquadronLeaderboard().catch(err => {
|
||
console.warn('squadron leaderboard load failed, falling back to player-derived counts', err);
|
||
return null;
|
||
}),
|
||
]);
|
||
playersData = (playerResp.players || []).map(p => ({
|
||
...p,
|
||
total_kills: p.total_kills || ((p.ground_kills || 0) + (p.air_kills || 0)),
|
||
})).sort((a, b) => b.total_kills - a.total_kills);
|
||
|
||
// Authoritative current-member counts keyed by clan_id and short_name.
|
||
const memberByClanId = new Map();
|
||
const memberByShort = new Map();
|
||
if (squadronResp && Array.isArray(squadronResp.squadrons)) {
|
||
for (const s of squadronResp.squadrons) {
|
||
const n = s.player_count || 0;
|
||
if (s.clan_id != null) memberByClanId.set(s.clan_id, n);
|
||
if (s.short_name) memberByShort.set(s.short_name.toLowerCase(), n);
|
||
}
|
||
}
|
||
|
||
const groups = {};
|
||
playersData.forEach(p => {
|
||
if (!p.squadron_name) return;
|
||
const key = p.squadron_clan_id != null
|
||
? `id:${p.squadron_clan_id}`
|
||
: (p.squadron_short_name ? `s:${p.squadron_short_name.toLowerCase()}` : `n:${p.squadron_name.toLowerCase()}`);
|
||
if (!groups[key]) {
|
||
groups[key] = {
|
||
name: p.squadron_name,
|
||
short_name: p.squadron_short_name || p.squadron_name,
|
||
clan_id: p.squadron_clan_id != null ? p.squadron_clan_id : null,
|
||
count: 0, // authoritative current member count (set below)
|
||
historical: 0, // distinct players ever seen with this tag
|
||
};
|
||
}
|
||
if (p.squadron_short_name && groups[key].short_name === groups[key].name) {
|
||
groups[key].name = p.squadron_name;
|
||
groups[key].short_name = p.squadron_short_name;
|
||
}
|
||
if (p.squadron_clan_id != null && groups[key].clan_id == null) {
|
||
groups[key].clan_id = p.squadron_clan_id;
|
||
}
|
||
groups[key].historical += 1;
|
||
});
|
||
|
||
for (const g of Object.values(groups)) {
|
||
let canonical = null;
|
||
if (g.clan_id != null && memberByClanId.has(g.clan_id)) {
|
||
canonical = memberByClanId.get(g.clan_id);
|
||
} else if (g.short_name && memberByShort.has(g.short_name.toLowerCase())) {
|
||
canonical = memberByShort.get(g.short_name.toLowerCase());
|
||
}
|
||
g.count = canonical != null ? canonical : g.historical;
|
||
}
|
||
|
||
allSquadrons = Object.values(groups).sort((a, b) => b.count - a.count);
|
||
} catch (e) { console.error('Error loading data:', e); }
|
||
}
|
||
loadData();
|
||
|
||
// ─────────── Squadron search ───────────
|
||
const sqInput = document.getElementById('squadronInput');
|
||
const sqDrop = document.getElementById('squadronDropdown');
|
||
const sqClear = document.getElementById('squadronClear');
|
||
let sqTimeout, selectedSquadronShort = null;
|
||
|
||
sqInput.addEventListener('input', () => {
|
||
clearTimeout(sqTimeout);
|
||
if (selectedSquadronShort) {
|
||
selectedSquadronShort = null;
|
||
sqClear.classList.add('hidden');
|
||
}
|
||
const val = sqInput.value.trim().toLowerCase();
|
||
if (val.length < 1) { sqDrop.classList.add('hidden'); return; }
|
||
sqTimeout = setTimeout(() => {
|
||
const hits = allSquadrons.filter(s => s.name.toLowerCase().includes(val)).slice(0, 12);
|
||
if (!hits.length) {
|
||
sqDrop.innerHTML = '<div class="p-3 text-center text-xs" style="color:rgba(144,238,144,0.25)">' + T('home.noSquadronsFound') + '</div>';
|
||
} else {
|
||
sqDrop.innerHTML = hits.map(s => `
|
||
<div class="search-hit" data-name="${escapeHtml(s.name)}" data-short="${escapeHtml(s.short_name)}">
|
||
<span class="sq-tag text-sm">${escapeHtml(s.name)}</span>
|
||
<span class="text-[11px] ml-2" style="color:rgba(144,238,144,0.25)">${s.count} ${T('common.playersCount')}</span>
|
||
</div>`).join('');
|
||
sqDrop.querySelectorAll('.search-hit').forEach(el =>
|
||
el.addEventListener('click', () => pickSquadron(el.dataset.name, el.dataset.short))
|
||
);
|
||
}
|
||
sqDrop.classList.remove('hidden');
|
||
}, 100);
|
||
});
|
||
sqInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const val = sqInput.value.trim().toLowerCase();
|
||
const match = allSquadrons.find(s => s.name.toLowerCase() === val)
|
||
|| allSquadrons.find(s => s.name.toLowerCase().includes(val));
|
||
if (match) pickSquadron(match.name, match.short_name);
|
||
}
|
||
if (e.key === 'Escape') { sqDrop.classList.add('hidden'); sqInput.blur(); }
|
||
});
|
||
sqClear.addEventListener('click', () => {
|
||
selectedSquadronShort = null;
|
||
sqInput.value = '';
|
||
sqClear.classList.add('hidden');
|
||
resetResults();
|
||
});
|
||
function pickSquadron(name, shortName) {
|
||
selectedSquadronShort = shortName || name;
|
||
sqInput.value = shortName || name;
|
||
sqClear.classList.remove('hidden');
|
||
sqDrop.classList.add('hidden');
|
||
renderSquadronPanel(selectedSquadronShort);
|
||
}
|
||
|
||
// ─────────── Player search (loads inline analytics) ───────────
|
||
const plInput = document.getElementById('playerInput');
|
||
const plDrop = document.getElementById('playerDropdown');
|
||
let plTimeout;
|
||
let activePlayerUid = null;
|
||
let activePlayerNick = null;
|
||
plInput.addEventListener('input', () => {
|
||
clearTimeout(plTimeout);
|
||
const val = plInput.value.trim().toLowerCase();
|
||
if (val.length < 2) { plDrop.classList.add('hidden'); return; }
|
||
plTimeout = setTimeout(() => {
|
||
const hits = playersData.filter(p => p.nick.toLowerCase().includes(val)).slice(0, 20);
|
||
if (!hits.length) {
|
||
plDrop.innerHTML = '<div class="p-3 text-center text-xs" style="color:rgba(144,238,144,0.25)">' + T('home.noPlayersFound') + '</div>';
|
||
} else {
|
||
plDrop.innerHTML = hits.map(p => {
|
||
const tag = p.squadron_name ? `<span class="sq-tag text-[11px] mr-1.5" style="opacity:0.5">${escapeHtml(p.squadron_name)}</span>` : '';
|
||
return `<div class="search-hit" data-uid="${escapeHtml(p.uid)}" data-nick="${escapeHtml(p.nick)}">${tag}<span class="text-sm" style="color:#A8E6CF">${escapeHtml(p.nick)}</span></div>`;
|
||
}).join('');
|
||
plDrop.querySelectorAll('.search-hit').forEach(el => {
|
||
el.addEventListener('click', () => pickPlayer(el.dataset.uid, el.dataset.nick));
|
||
});
|
||
}
|
||
plDrop.classList.remove('hidden');
|
||
}, 100);
|
||
});
|
||
plInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') { plDrop.classList.add('hidden'); plInput.blur(); }
|
||
});
|
||
function pickPlayer(uid, nick) {
|
||
activePlayerUid = uid;
|
||
activePlayerNick = nick;
|
||
plInput.value = nick;
|
||
plDrop.classList.add('hidden');
|
||
renderPlayerPanel(uid, nick);
|
||
}
|
||
|
||
// ─────────── Vehicle search (loads inline analytics) ───────────
|
||
// Driven by the translation map: /api/analytics/vehicle-list gives us
|
||
// the set of (lowercased) vehicle_internal ids that have data + their
|
||
// spawn counts; the per-language display name comes from the i18n map
|
||
// (/api/i18n/vehicles) which already bakes in country-leak / event
|
||
// glyphs (▄ ◢ ◊ ␗ etc.). So search filters on the localized name and
|
||
// routing is by internal id, with no DB display string in the loop.
|
||
const vhInput = document.getElementById('vehicleInput');
|
||
const vhDrop = document.getElementById('vehicleDropdown');
|
||
let vehicleList = []; // [{vehicle_internal, total}, ...] sorted by total desc
|
||
let vhTimeout;
|
||
let activeVehicle = null; // display name (for the input + header)
|
||
let activeVehicleInternal = null; // canonical lowercased internal id (API key)
|
||
|
||
async function ensureVehicleList() {
|
||
if (vehicleList.length) return;
|
||
try {
|
||
const data = await window.apiClient.request('/api/analytics/vehicle-list');
|
||
vehicleList = (data && data.vehicles) || [];
|
||
} catch (e) { console.error('vehicle-list fetch failed', e); }
|
||
}
|
||
// Resolve the localized display name for an internal id. The translation
|
||
// map (/api/i18n/vehicles) is the single source of truth — it bakes in
|
||
// country/event glyphs per language (BOT/utils.py:1344). Returns the
|
||
// internal id itself if the map hasn't loaded or doesn't know it.
|
||
function vehicleDisplay(internal) {
|
||
if (!internal) return '';
|
||
if (window.vehicleI18n && window.vehicleI18n.translate) {
|
||
return window.vehicleI18n.translate(internal, internal);
|
||
}
|
||
return internal;
|
||
}
|
||
vhInput.addEventListener('focus', () => {
|
||
ensureVehicleList();
|
||
if (window.vehicleI18n && window.vehicleI18n.ensureLoaded) window.vehicleI18n.ensureLoaded();
|
||
});
|
||
vhInput.addEventListener('input', () => {
|
||
clearTimeout(vhTimeout);
|
||
const val = vhInput.value.trim().toLowerCase();
|
||
if (val.length < 1) { vhDrop.classList.add('hidden'); return; }
|
||
vhTimeout = setTimeout(async () => {
|
||
await ensureVehicleList();
|
||
if (window.vehicleI18n && window.vehicleI18n.ensureLoaded) await window.vehicleI18n.ensureLoaded();
|
||
// vehicleList is already total-desc; filter preserves order.
|
||
const hits = [];
|
||
for (const v of vehicleList) {
|
||
const display = vehicleDisplay(v.vehicle_internal);
|
||
if (!display.toLowerCase().includes(val)) continue;
|
||
hits.push({ vehicle: display, vehicle_internal: v.vehicle_internal, total: v.total });
|
||
if (hits.length >= 25) break;
|
||
}
|
||
if (!hits.length) {
|
||
vhDrop.innerHTML = '<div class="p-3 text-center text-xs" style="color:rgba(144,238,144,0.25)">No vehicles found</div>';
|
||
} else {
|
||
vhDrop.innerHTML = hits.map(v => `
|
||
<div class="search-hit" data-pick-internal="${escapeHtml(v.vehicle_internal)}" data-pick-display="${escapeHtml(v.vehicle)}">
|
||
<span class="vehicle-name text-sm" style="color:#A8E6CF">${escapeHtml(v.vehicle)}</span>
|
||
<span class="text-[11px]" style="color:rgba(255,255,255,0.4); margin-left:0.5rem;">${v.total} uses</span>
|
||
</div>`).join('');
|
||
vhDrop.querySelectorAll('.search-hit').forEach(el => {
|
||
el.addEventListener('click', () => pickVehicle(el.dataset.pickDisplay, el.dataset.pickInternal));
|
||
});
|
||
}
|
||
vhDrop.classList.remove('hidden');
|
||
}, 100);
|
||
});
|
||
vhInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') { vhDrop.classList.add('hidden'); vhInput.blur(); }
|
||
else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
// Accept Enter only on an exact display match. If multiple
|
||
// internals translate to the same display (country variants
|
||
// sharing a name in the cache, rare), pick the most-used one
|
||
// (first in the total-desc vehicleList).
|
||
const val = vhInput.value.trim().toLowerCase();
|
||
if (!val) return;
|
||
for (const v of vehicleList) {
|
||
const display = vehicleDisplay(v.vehicle_internal);
|
||
if (display.toLowerCase() === val) { pickVehicle(display, v.vehicle_internal); break; }
|
||
}
|
||
}
|
||
});
|
||
function pickVehicle(name, internal) {
|
||
activeVehicle = name;
|
||
activeVehicleInternal = internal || null;
|
||
vhInput.value = name;
|
||
vhDrop.classList.add('hidden');
|
||
renderVehiclePanel(name);
|
||
}
|
||
|
||
// ─────────── Results panel (squadron analytics) ───────────
|
||
const resultsRoot = document.getElementById('analyticsResultsRoot');
|
||
const cache = new Map(); // squadron|view|filterKey -> data
|
||
let activeView = 'maps';
|
||
let activeSquadron = null;
|
||
let dateFilter = { type: 'all', startTs: 0, endTs: 0 };
|
||
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] },
|
||
};
|
||
|
||
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 getCustomRangeDayPair() {
|
||
const startInput = document.getElementById('af-start-date')?.value || '';
|
||
const endInput = document.getElementById('af-end-date')?.value || '';
|
||
if (!startInput && !endInput) return null;
|
||
return {
|
||
startDay: startInput || endInput,
|
||
endDay: endInput || startInput,
|
||
};
|
||
}
|
||
|
||
function filterKey() {
|
||
return `${dateFilter.type}:${dateFilter.startTs}:${dateFilter.endTs}`;
|
||
}
|
||
function filterQueryString() {
|
||
const p = new URLSearchParams();
|
||
if (dateFilter.startTs) p.set('start_date', String(dateFilter.startTs));
|
||
if (dateFilter.endTs) p.set('end_date', String(dateFilter.endTs));
|
||
// When the filter is narrower than a whole season (i.e. week or custom
|
||
// date range), relax per-view min_games cutoffs so low-sample views
|
||
// still show data instead of coming back empty.
|
||
if (dateFilter.type === 'week' || dateFilter.type === 'date') {
|
||
p.set('min_games', '1');
|
||
}
|
||
const s = p.toString();
|
||
return s ? `?${s}` : '';
|
||
}
|
||
|
||
const VIEWS = [
|
||
{ key: 'maps', label: 'analytics.tabMaps', icon: 'map' },
|
||
{ key: 'comps', label: 'analytics.tabComps', icon: 'layer-group' },
|
||
{ key: 'consistency', label: 'analytics.tabConsistency', icon: 'chart-line' },
|
||
{ key: 'time', label: 'analytics.tabTime', icon: 'clock' },
|
||
{ key: 'matchup', label: 'analytics.tabMatchups', icon: 'users-between-lines' },
|
||
];
|
||
|
||
const PLAYER_VIEWS = [
|
||
{ key: 'maps', label: 'analytics.tabMaps', icon: 'map' },
|
||
{ key: 'squadmates', label: 'analytics.tabSquadmates', icon: 'users' },
|
||
{ key: 'time', label: 'analytics.tabTime', icon: 'clock' },
|
||
{ key: 'timeline', label: 'analytics.tabTimeline', icon: 'chart-line' },
|
||
{ key: 'matchup', label: 'analytics.tabMatchups', icon: 'users-between-lines' },
|
||
];
|
||
|
||
let panelMode = null; // 'squadron' | 'player' | null
|
||
|
||
function entityHeaderHtml(kind, name, href) {
|
||
// Vehicle has no profile page yet — render as plain text, no icon.
|
||
const iconHtml = kind === 'player' ? '<i class="fas fa-user"></i>'
|
||
: kind === 'squadron' ? '<i class="fas fa-shield-halved"></i>'
|
||
: '';
|
||
// Squadron and vehicle names need the skyquake font to render
|
||
// country/event glyphs (◢ ▄ ◊ ␗ etc.). Player nicks are plain Inter.
|
||
// For vehicles the caller already passes the localized display
|
||
// (from the translation map), so we don't add data-vehicle-internal
|
||
// here — that would just trigger redundant retranslation.
|
||
const nameStyle = (kind === 'vehicle' || kind === 'squadron')
|
||
? ' style="font-family:\'skyquakesymbols\',\'Inter\',sans-serif;"' : '';
|
||
const inner = `${iconHtml}<span${nameStyle}>${escapeHtml(name)}</span>`;
|
||
const node = href
|
||
? `<a class="entity-name" href="${href}">${inner}</a>`
|
||
: `<span class="entity-name">${inner}</span>`;
|
||
return `
|
||
<div class="entity-header">
|
||
${node}
|
||
${filterBarHtml()}
|
||
</div>`;
|
||
}
|
||
|
||
function filterBarHtml() {
|
||
return `
|
||
<div class="af-time-filter-bar" id="afFilterBar">
|
||
<div class="filter-group">
|
||
<label for="af-filter-category"><i class="fas fa-filter" style="margin-right:0.3rem;"></i>${escapeHtml(T('player.filterBy'))}</label>
|
||
<select id="af-filter-category">
|
||
<option value="all">${escapeHtml(T('player.allTime'))}</option>
|
||
<option value="date">${escapeHtml(T('player.dateRange'))}</option>
|
||
<option value="season">${escapeHtml(T('player.season'))}</option>
|
||
<option value="week">${escapeHtml(T('player.week'))}</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-group" id="af-date-type-filter" style="display:none;">
|
||
<label for="af-date-filter-type"><i class="fas fa-calendar-alt" style="margin-right:0.3rem;"></i>${escapeHtml(T('player.dateType'))}</label>
|
||
<select id="af-date-filter-type">
|
||
<option value="last7">${escapeHtml(T('player.last7Days'))}</option>
|
||
<option value="last30">${escapeHtml(T('player.last30Days'))}</option>
|
||
<option value="last90">${escapeHtml(T('player.last90Days'))}</option>
|
||
<option value="custom">${escapeHtml(T('player.customRange'))}</option>
|
||
</select>
|
||
</div>
|
||
<div class="filter-group custom-range" id="af-custom-range-filters" style="display:none;">
|
||
<div class="filter-group">
|
||
<label for="af-start-date">${escapeHtml(T('player.from'))}</label>
|
||
<input type="date" id="af-start-date">
|
||
</div>
|
||
<div class="filter-group">
|
||
<label for="af-end-date">${escapeHtml(T('player.to'))}</label>
|
||
<input type="date" id="af-end-date">
|
||
</div>
|
||
<div class="filter-group" id="af-single-day-timeslot-filter" style="display:none;">
|
||
<label for="af-single-day-timeslot">${escapeHtml(T('player.timeslot'))}</label>
|
||
<select id="af-single-day-timeslot">
|
||
<option value="all">${escapeHtml(T('player.fullDay'))}</option>
|
||
<option value="na">${escapeHtml(T('analytics.naTimeslot'))}</option>
|
||
<option value="eu">${escapeHtml(T('analytics.euTimeslot'))}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="filter-group" id="af-season-select-filter" style="display:none;">
|
||
<label for="af-season">${escapeHtml(T('player.selectSeason'))}</label>
|
||
<select id="af-season"><option value="">${escapeHtml(T('player.pleaseSelect'))}</option></select>
|
||
</div>
|
||
<div class="filter-group" id="af-week-season-filter" style="display:none;">
|
||
<label for="af-week-season-select">${escapeHtml(T('player.selectSeason'))}</label>
|
||
<select id="af-week-season-select"><option value="">${escapeHtml(T('player.pleaseSelect'))}</option></select>
|
||
</div>
|
||
<div class="filter-group" id="af-week-select-filter" style="display:none;">
|
||
<label for="af-week">${escapeHtml(T('player.selectWeek'))}</label>
|
||
<select id="af-week"><option value="">${escapeHtml(T('player.pleaseSelect'))}</option></select>
|
||
</div>
|
||
<div class="filter-group" id="af-reset-btn-group" style="display:none;">
|
||
<label> </label>
|
||
<button class="filter-reset-btn" id="af-reset-btn" type="button">
|
||
<i class="fas fa-times-circle"></i> ${escapeHtml(T('player.resetFilters'))}
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function reloadActiveView() {
|
||
cache.clear();
|
||
if (panelMode === 'squadron' && activeSquadron && activeView) loadView(activeView);
|
||
else if (panelMode === 'player' && activePlayerUid && activeView) loadPlayerView(activeView);
|
||
else if (panelMode === 'vehicle' && activeVehicle && activeView) loadVehicleView(activeView);
|
||
}
|
||
|
||
function resetResults() {
|
||
activeSquadron = null;
|
||
activePlayerUid = null;
|
||
activePlayerNick = null;
|
||
activeVehicle = null;
|
||
activeVehicleInternal = null;
|
||
panelMode = null;
|
||
cache.clear();
|
||
resultsRoot.innerHTML = `
|
||
<div class="leaderboard-content">
|
||
<div class="empty-state" id="emptyHint">
|
||
<i class="fas fa-arrow-up" style="font-size:1.5rem; margin-bottom:0.75rem; display:block;"></i>
|
||
<span id="emptyHintText">${escapeHtml(T(hintMsgs[currentMode]))}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderSquadronPanel(sqShort) {
|
||
panelMode = 'squadron';
|
||
activeSquadron = sqShort;
|
||
activePlayerUid = null;
|
||
activeView = 'maps';
|
||
dateFilter = { type: 'all', startTs: 0, endTs: 0 };
|
||
resultsRoot.innerHTML = `
|
||
${entityHeaderHtml('squadron', sqShort, '/squadrons/' + encodeURIComponent(sqShort))}
|
||
<div class="leaderboard-nav" id="analyticsTabs">
|
||
${VIEWS.map(v => `
|
||
<button class="leaderboard-tab ${v.key === 'maps' ? 'active' : ''}" data-view="${v.key}">
|
||
<i class="fas fa-${v.icon}"></i> ${escapeHtml(T(v.label))}
|
||
</button>`).join('')}
|
||
</div>
|
||
<div class="leaderboard-content" id="analyticsViewSlot"></div>`;
|
||
document.querySelectorAll('#analyticsTabs .leaderboard-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => switchView(btn.dataset.view));
|
||
});
|
||
wireDateFilter();
|
||
switchView('maps');
|
||
}
|
||
|
||
function renderPlayerPanel(uid, nick) {
|
||
panelMode = 'player';
|
||
activeSquadron = null;
|
||
activePlayerUid = uid;
|
||
activePlayerNick = nick;
|
||
activeView = 'maps';
|
||
dateFilter = { type: 'all', startTs: 0, endTs: 0 };
|
||
resultsRoot.innerHTML = `
|
||
${entityHeaderHtml('player', nick, '/players/' + encodeURIComponent(uid))}
|
||
<div class="leaderboard-nav" id="analyticsTabs">
|
||
${PLAYER_VIEWS.map(v => `
|
||
<button class="leaderboard-tab ${v.key === 'maps' ? 'active' : ''}" data-view="${v.key}">
|
||
<i class="fas fa-${v.icon}"></i> ${escapeHtml(T(v.label))}
|
||
</button>`).join('')}
|
||
</div>
|
||
<div class="leaderboard-content" id="analyticsViewSlot"></div>`;
|
||
document.querySelectorAll('#analyticsTabs .leaderboard-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => playerSwitchView(btn.dataset.view));
|
||
});
|
||
wireDateFilter();
|
||
playerSwitchView('maps');
|
||
}
|
||
|
||
// Lookup helper: short_name (e.g. "DSPL") → full glyph-wrapped tag
|
||
// ("⫨DSPL⌐" rendered as a clan emblem in skyquakesymbols).
|
||
// Falls back to the short name if we don't have a mapping yet.
|
||
function squadronTagDisplay(shortName) {
|
||
if (!shortName) return '';
|
||
const match = allSquadrons.find(s => s.short_name === shortName);
|
||
return match && match.name ? match.name : shortName;
|
||
}
|
||
|
||
// Makes any <table> sortable by header click. Respects per-<th>
|
||
// data-sortable="false" (skips) and data-sort-type="text"/"num" (forces).
|
||
// Rows carrying .section-divider-row are held in place; useful for time
|
||
// table where NA/EU dividers shouldn't move.
|
||
function makeTableSortable(table) {
|
||
if (!table) return;
|
||
const thead = table.querySelector('thead tr');
|
||
const tbody = table.querySelector('tbody');
|
||
if (!thead || !tbody) return;
|
||
const ths = Array.from(thead.children);
|
||
ths.forEach((th, idx) => {
|
||
if (th.dataset.sortable === 'false') return;
|
||
th.classList.add('sortable-th');
|
||
if (!th.querySelector('.sort-ind')) {
|
||
th.insertAdjacentHTML('beforeend', '<span class="sort-ind"></span>');
|
||
}
|
||
th.addEventListener('click', () => {
|
||
const current = th.dataset.sortDir || '';
|
||
ths.forEach(h => {
|
||
h.removeAttribute('data-sort-dir');
|
||
const s = h.querySelector('.sort-ind'); if (s) s.textContent = '';
|
||
});
|
||
const next = current === 'desc' ? 'asc' : 'desc';
|
||
th.dataset.sortDir = next;
|
||
const ind = th.querySelector('.sort-ind'); if (ind) ind.textContent = next === 'asc' ? '▲' : '▼';
|
||
|
||
const allRows = Array.from(tbody.querySelectorAll('tr'));
|
||
const sortable = allRows.filter(r => !r.classList.contains('section-divider-row'));
|
||
const getVal = (tr) => {
|
||
const td = tr.children[idx]; if (!td) return '';
|
||
if (td.dataset.sortValue !== undefined) return td.dataset.sortValue;
|
||
return td.innerText.trim();
|
||
};
|
||
const forced = th.dataset.sortType;
|
||
const sample = sortable.length ? getVal(sortable[0]) : '';
|
||
const numeric = forced === 'num' || (forced !== 'text' && /^-?\d/.test(String(sample).replace(/[%,\s]/g, '')));
|
||
sortable.sort((a, b) => {
|
||
let av = getVal(a), bv = getVal(b);
|
||
if (numeric) {
|
||
av = parseFloat(String(av).replace(/[%,\s]/g, '')); if (Number.isNaN(av)) av = 0;
|
||
bv = parseFloat(String(bv).replace(/[%,\s]/g, '')); if (Number.isNaN(bv)) bv = 0;
|
||
return next === 'asc' ? av - bv : bv - av;
|
||
}
|
||
return next === 'asc' ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
|
||
});
|
||
// Rebuild: preserve dividers where they are by anchoring sorted
|
||
// rows between them (simplest: if no dividers, just append).
|
||
if (allRows.some(r => r.classList.contains('section-divider-row'))) {
|
||
// Keep dividers; within each segment, replace with sorted subset filtered to original segment rows.
|
||
const segments = [];
|
||
let current = [];
|
||
allRows.forEach(r => {
|
||
if (r.classList.contains('section-divider-row')) {
|
||
segments.push({ divider: r, rows: current });
|
||
current = [];
|
||
} else current.push(r);
|
||
});
|
||
segments.push({ divider: null, rows: current });
|
||
tbody.innerHTML = '';
|
||
for (const seg of segments) {
|
||
if (seg.divider) tbody.appendChild(seg.divider);
|
||
const segSet = new Set(seg.rows);
|
||
sortable.filter(r => segSet.has(r)).forEach(r => tbody.appendChild(r));
|
||
}
|
||
} else {
|
||
sortable.forEach(r => tbody.appendChild(r));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Returns a function that maps a value to a 0–100 bar width based on
|
||
// the min and max of the input set, so tightly-bunched values (e.g. all
|
||
// K/D in 8.0–10.0) still produce visibly distinct bar lengths.
|
||
function relativeBarScaler(values) {
|
||
const lo = Math.min(...values);
|
||
const hi = Math.max(...values);
|
||
const span = hi - lo;
|
||
const FLOOR = 8;
|
||
return (v) => {
|
||
if (span <= 0.001) return 50;
|
||
const pct = ((v - lo) / span) * (100 - FLOOR) + FLOOR;
|
||
return Math.max(0, Math.min(100, pct));
|
||
};
|
||
}
|
||
|
||
const VEHICLE_VIEWS = [
|
||
{ key: 'players', label: 'analytics.tabTopPlayers', icon: 'user' },
|
||
{ key: 'squadrons', label: 'analytics.tabTopSquadrons', icon: 'shield-halved' },
|
||
{ key: 'maps', label: 'analytics.tabMaps', icon: 'map' },
|
||
];
|
||
|
||
function renderVehiclePanel(name) {
|
||
panelMode = 'vehicle';
|
||
activeSquadron = null;
|
||
activePlayerUid = null;
|
||
activeView = 'players';
|
||
dateFilter = { type: 'all', startTs: 0, endTs: 0 };
|
||
// pickVehicle is the only legitimate caller; activeVehicleInternal
|
||
// is already set by the time we get here.
|
||
resultsRoot.innerHTML = `
|
||
${entityHeaderHtml('vehicle', name, null)}
|
||
<div id="vehicleStatStrip" class="player-stat-strip" style="margin-bottom:1rem;"></div>
|
||
<div class="leaderboard-nav" id="analyticsTabs">
|
||
${VEHICLE_VIEWS.map(v => `
|
||
<button class="leaderboard-tab ${v.key === 'players' ? 'active' : ''}" data-view="${v.key}">
|
||
<i class="fas fa-${v.icon}"></i> ${escapeHtml(T(v.label))}
|
||
</button>`).join('')}
|
||
</div>
|
||
<div class="leaderboard-content" id="analyticsViewSlot"></div>`;
|
||
document.querySelectorAll('#analyticsTabs .leaderboard-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => vehicleSwitchView(btn.dataset.view));
|
||
});
|
||
wireDateFilter();
|
||
// vehicleSwitchView → loadVehicleView fires loadVehicleStats() itself,
|
||
// so don't double-fire the /vehicle/stats query.
|
||
vehicleSwitchView('players');
|
||
}
|
||
|
||
function vehicleSwitchView(view) {
|
||
activeView = view;
|
||
document.querySelectorAll('#analyticsTabs .leaderboard-tab').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.view === view);
|
||
});
|
||
loadVehicleView(view);
|
||
}
|
||
|
||
async function loadVehicleStats() {
|
||
const strip = document.getElementById('vehicleStatStrip');
|
||
if (!strip || !activeVehicleInternal) return;
|
||
const cacheKey = `vehicle:${activeVehicleInternal}|stats|${filterKey()}`;
|
||
try {
|
||
let s = cache.get(cacheKey);
|
||
if (!s) {
|
||
s = await window.apiClient.request(`/api/analytics/vehicle/stats/${encodeURIComponent(activeVehicleInternal)}${filterQueryString()}`);
|
||
cache.set(cacheKey, s);
|
||
}
|
||
strip.innerHTML = `
|
||
<div><span class="ps-label">Spawns</span><span class="ps-val">${s.spawns}</span></div>
|
||
<div><span class="ps-label">Players</span><span class="ps-val">${s.unique_players}</span></div>
|
||
<div><span class="ps-label">Sessions</span><span class="ps-val">${s.sessions}</span></div>
|
||
<div><span class="ps-label">K/D</span><span class="ps-val">${s.kd.toFixed(2)}</span></div>
|
||
<div><span class="ps-label">K/Spawn</span><span class="ps-val">${s.ks.toFixed(2)}</span></div>
|
||
<div><span class="ps-label">Win Rate</span><span class="ps-val">${s.win_rate}%</span></div>`;
|
||
} catch (e) {
|
||
console.error('vehicle stats failed', e);
|
||
strip.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
async function loadVehicleView(view) {
|
||
const slot = document.getElementById('analyticsViewSlot');
|
||
if (!activeVehicleInternal || !slot) return;
|
||
const cacheKey = `vehicle:${activeVehicleInternal}|${view}|${filterKey()}`;
|
||
// Stat strip refreshes alongside whichever tab the user lands on.
|
||
loadVehicleStats();
|
||
|
||
if (cache.has(cacheKey)) {
|
||
renderVehicleView(view, cache.get(cacheKey), slot);
|
||
return;
|
||
}
|
||
slot.innerHTML = `<div class="loading-state"><div class="loading-spinner"></div>${escapeHtml(T('analytics.loading'))}</div>`;
|
||
try {
|
||
const data = await window.apiClient.request(`/api/analytics/vehicle/${view}/${encodeURIComponent(activeVehicleInternal)}${filterQueryString()}`);
|
||
cache.set(cacheKey, data);
|
||
if (activeView === view) renderVehicleView(view, data, slot);
|
||
} catch (e) {
|
||
console.error(e);
|
||
slot.innerHTML = `<div class="error-state"><i class="fas fa-exclamation-triangle"></i> ${escapeHtml(T('analytics.loadError'))}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderVehicleView(view, data, slot) {
|
||
if (!Array.isArray(data) || !data.length) {
|
||
slot.innerHTML = `<div class="empty-state"><i class="fas fa-inbox" style="font-size:1.5rem; display:block; margin-bottom:0.5rem;"></i>${escapeHtml(T('analytics.noData'))}</div>`;
|
||
return;
|
||
}
|
||
switch (view) {
|
||
case 'players': return renderVehiclePlayers(slot, data);
|
||
case 'squadrons': return renderVehicleSquadrons(slot, data);
|
||
case 'maps': return renderMaps(slot, data);
|
||
}
|
||
}
|
||
|
||
function renderVehiclePlayers(slot, data) {
|
||
const kdScale = relativeBarScaler(data.map(r => r.kd));
|
||
const rows = data.map((r, i) => `
|
||
<tr>
|
||
<td>${i + 1}</td>
|
||
<td data-sort-value="${escapeHtml((r.nick || '').toLowerCase())}">
|
||
${r.squadron ? `<a href="/squadrons/${encodeURIComponent(r.squadron)}" class="sq-tag" style="text-decoration:none; opacity:0.85; margin-right:0.45rem;">${escapeHtml(squadronTagDisplay(r.squadron))}</a>` : ''}
|
||
<a href="/players/${encodeURIComponent(r.uid)}" style="color:#A8E6CF; text-decoration:none;">${escapeHtml(r.nick || '')}</a>
|
||
</td>
|
||
<td data-sort-value="${r.games}">${r.games}</td>
|
||
<td data-sort-value="${r.kd}" style="white-space:nowrap;">${r.kd.toFixed(2)}<span class="micro-bar kd"><span style="width:${kdScale(r.kd)}%"></span></span></td>
|
||
<td data-sort-value="${r.win_rate}" style="white-space:nowrap;">${r.win_rate}%<span class="micro-bar"><span style="width:${Math.min(100, r.win_rate)}%"></span></span></td>
|
||
</tr>`).join('');
|
||
slot.innerHTML = `
|
||
<div class="consistency-table-wrap">
|
||
<table class="analytics-table">
|
||
<thead><tr>
|
||
<th data-sortable="false">#</th>
|
||
<th data-sort-type="text">${escapeHtml(T('analytics.colPlayer'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colGames'))}</th>
|
||
<th data-sort-type="num">K/D</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colWinRate'))}</th>
|
||
</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>`;
|
||
makeTableSortable(slot.querySelector('table'));
|
||
}
|
||
|
||
function renderVehicleSquadrons(slot, data) {
|
||
const kdScale = relativeBarScaler(data.map(r => r.kd));
|
||
const rows = data.map((r, i) => `
|
||
<tr>
|
||
<td>${i + 1}</td>
|
||
<td data-sort-value="${escapeHtml((r.squadron || '').toLowerCase())}"><a href="/squadrons/${encodeURIComponent(r.squadron)}" class="sq-tag" style="text-decoration:none;">${escapeHtml(squadronTagDisplay(r.squadron))}</a></td>
|
||
<td data-sort-value="${r.games}">${r.games}</td>
|
||
<td data-sort-value="${r.kd}" style="white-space:nowrap;">${r.kd.toFixed(2)}<span class="micro-bar kd"><span style="width:${kdScale(r.kd)}%"></span></span></td>
|
||
<td data-sort-value="${r.win_rate}" style="white-space:nowrap;">${r.win_rate}%<span class="micro-bar"><span style="width:${Math.min(100, r.win_rate)}%"></span></span></td>
|
||
</tr>`).join('');
|
||
slot.innerHTML = `
|
||
<div class="consistency-table-wrap">
|
||
<table class="analytics-table">
|
||
<thead><tr>
|
||
<th data-sortable="false">#</th>
|
||
<th data-sort-type="text">${escapeHtml(T('analytics.colSquadron'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colGames'))}</th>
|
||
<th data-sort-type="num">K/D</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colWinRate'))}</th>
|
||
</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>`;
|
||
makeTableSortable(slot.querySelector('table'));
|
||
}
|
||
|
||
function playerSwitchView(view) {
|
||
activeView = view;
|
||
document.querySelectorAll('#analyticsTabs .leaderboard-tab').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.view === view);
|
||
});
|
||
loadPlayerView(view);
|
||
}
|
||
|
||
async function loadPlayerView(view) {
|
||
const slot = document.getElementById('analyticsViewSlot');
|
||
if (!activePlayerUid || !slot) return;
|
||
const cacheKey = `player:${activePlayerUid}|${view}|${filterKey()}`;
|
||
|
||
const renderShell = (inner) => { slot.innerHTML = inner; };
|
||
|
||
if (cache.has(cacheKey)) {
|
||
const data = cache.get(cacheKey);
|
||
renderShell('<div id="playerViewBody"></div>');
|
||
renderPlayerView(view, data, document.getElementById('playerViewBody'));
|
||
return;
|
||
}
|
||
|
||
renderShell(`<div class="loading-state"><div class="loading-spinner"></div>${escapeHtml(T('analytics.loading'))}</div>`);
|
||
try {
|
||
const data = await window.apiClient.request(`/api/analytics/player/${view}/${encodeURIComponent(activePlayerUid)}${filterQueryString()}`);
|
||
cache.set(cacheKey, data);
|
||
if (activeView === view) {
|
||
renderShell('<div id="playerViewBody"></div>');
|
||
renderPlayerView(view, data, document.getElementById('playerViewBody'));
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
renderShell(`<div class="error-state"><i class="fas fa-exclamation-triangle"></i> ${escapeHtml(T('analytics.loadError'))}</div>`);
|
||
}
|
||
}
|
||
|
||
function renderPlayerView(view, data, slot) {
|
||
// Player time endpoint returns { hourly, daily }; everything else
|
||
// matches the squadron-view shape.
|
||
const empty = view === 'time'
|
||
? (!data || !data.hourly || Object.keys(data.hourly).length === 0)
|
||
: Array.isArray(data) ? data.length === 0
|
||
: (view === 'matchup' ? (!data.won_against || (!data.won_against.length && !data.lost_against.length))
|
||
: false);
|
||
if (empty) {
|
||
slot.innerHTML = `<div class="empty-state"><i class="fas fa-inbox" style="font-size:1.5rem; display:block; margin-bottom:0.5rem;"></i>${escapeHtml(T('analytics.noData'))}</div>`;
|
||
return;
|
||
}
|
||
switch (view) {
|
||
case 'maps': return renderMaps(slot, data);
|
||
case 'squadmates': return renderPlayerSquadmates(slot, data);
|
||
case 'time': return renderTime(slot, data);
|
||
case 'matchup': return renderMatchup(slot, data);
|
||
case 'timeline': return renderPlayerTimeline(slot, data);
|
||
}
|
||
}
|
||
|
||
let playerTimelineLineChart = null;
|
||
let playerVehicleBarChart = null;
|
||
let playerSquadmatesRadarChart = null;
|
||
|
||
function renderPlayerSquadmates(slot, data) {
|
||
if (playerSquadmatesRadarChart) {
|
||
try { playerSquadmatesRadarChart.destroy(); } catch (e) {}
|
||
playerSquadmatesRadarChart = null;
|
||
}
|
||
|
||
const RADAR_MIN_SHARED = (dateFilter.type === 'week' || dateFilter.type === 'date') ? 1 : 2;
|
||
const RADAR_MAX_AXES = 20;
|
||
const eligible = data.filter(r => r.shared >= RADAR_MIN_SHARED);
|
||
const radarData = eligible
|
||
.slice()
|
||
.sort((a, b) => b.shared - a.shared)
|
||
.slice(0, RADAR_MAX_AXES)
|
||
.sort((a, b) => (a.nick || a.uid || '').localeCompare(b.nick || b.uid || ''));
|
||
const sharedScale = relativeBarScaler(data.map(r => r.shared || 0));
|
||
const maxShared = radarData.reduce((m, r) => Math.max(m, r.shared || 0), 0) || 1;
|
||
const truncate = (s, n) => s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s;
|
||
const rows = data.map((r, i) => `
|
||
<tr>
|
||
<td class="col-map" data-sort-value="${escapeHtml((r.nick || r.uid || '').toLowerCase())}">
|
||
<a href="/players/${encodeURIComponent(r.uid)}" class="sqmate-link">${escapeHtml(r.nick || r.uid || '')}</a>
|
||
<span class="sqmate-meta">${escapeHtml(T('analytics.colUid'))}: ${escapeHtml(r.uid)}</span>
|
||
</td>
|
||
<td class="col-num" data-sort-value="${r.shared}">${r.shared}</td>
|
||
<td class="col-num" data-sort-value="${r.wins}" style="color:#90EE90">${r.wins}</td>
|
||
<td class="col-num" data-sort-value="${r.losses}" style="color:#ff9b9b">${r.losses}</td>
|
||
<td class="col-num" data-sort-value="${r.win_rate}">${r.win_rate}%</td>
|
||
<td class="col-bar"><div class="bar-track"><div class="bar-fill" style="width:${sharedScale(r.shared)}%"></div></div></td>
|
||
</tr>`).join('');
|
||
const excludedCount = data.length - radarData.length;
|
||
|
||
slot.innerHTML = `
|
||
<div class="maps-layout">
|
||
<div class="maps-table-wrap">
|
||
<table class="analytics-table maps-table squadmates-table">
|
||
<thead><tr>
|
||
<th class="col-map" data-sort-type="text">${escapeHtml(T('analytics.colPlayer'))}</th>
|
||
<th class="col-num" data-sort-type="num">${escapeHtml(T('analytics.colShared'))}</th>
|
||
<th class="col-num" data-sort-type="num">${escapeHtml(T('analytics.colWins'))}</th>
|
||
<th class="col-num" data-sort-type="num">${escapeHtml(T('analytics.colLosses'))}</th>
|
||
<th class="col-num" data-sort-type="num">${escapeHtml(T('analytics.colWinRate'))}</th>
|
||
<th class="col-bar" data-sortable="false">${escapeHtml(T('analytics.colBar'))}</th>
|
||
</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="radar-card">
|
||
<div class="radar-card-header">
|
||
<span class="radar-card-title">${escapeHtml(T('analytics.tabSquadmates'))}</span>
|
||
<span class="radar-card-meta">${escapeHtml(T('analytics.radarMetaSquadmates', { shown: radarData.length, total: data.length, min: RADAR_MIN_SHARED }))}</span>
|
||
</div>
|
||
${radarData.length < 3 ? `
|
||
<div class="radar-canvas-wrap" style="display:flex; align-items:center; justify-content:center; text-align:center; color:rgba(245,245,220,0.55); padding:1.5rem;">
|
||
${escapeHtml(T('analytics.radarTooFewSquadmates', { min: RADAR_MIN_SHARED }))}
|
||
</div>` : `
|
||
<div class="radar-canvas-wrap">
|
||
<canvas id="playerSquadmatesRadarCanvas" style="cursor:pointer;"></canvas>
|
||
</div>
|
||
<div class="radar-legend">
|
||
<span><span class="swatch" style="background:#90EE90"></span>${escapeHtml(T('analytics.colShared'))}</span>
|
||
<span><span class="swatch" style="background:#F5C16C"></span>${escapeHtml(T('analytics.colWinRate'))}</span>
|
||
</div>`}
|
||
${excludedCount > 0 ? `<div class="radar-footnote">${escapeHtml(T('analytics.radarFootnoteSquadmates', { count: excludedCount }))}</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
|
||
makeTableSortable(slot.querySelector('.squadmates-table'));
|
||
|
||
if (radarData.length < 3 || typeof Chart === 'undefined') return;
|
||
|
||
const labels = radarData.map(r => truncate(r.nick || r.uid || '', 16));
|
||
const sharedSeries = radarData.map(r => Math.round((r.shared / maxShared) * 100));
|
||
const wrSeries = radarData.map(r => r.win_rate);
|
||
|
||
const ctx = document.getElementById('playerSquadmatesRadarCanvas').getContext('2d');
|
||
playerSquadmatesRadarChart = new Chart(ctx, {
|
||
type: 'radar',
|
||
data: {
|
||
labels,
|
||
datasets: [
|
||
{
|
||
label: T('analytics.colShared'),
|
||
data: sharedSeries,
|
||
backgroundColor: 'rgba(144, 238, 144, 0.22)',
|
||
borderColor: 'rgba(144, 238, 144, 0.95)',
|
||
borderWidth: 2,
|
||
pointBackgroundColor: '#90EE90',
|
||
pointBorderColor: '#1b1b1b',
|
||
pointRadius: 3,
|
||
pointHoverRadius: 5,
|
||
},
|
||
{
|
||
label: T('analytics.colWinRate'),
|
||
data: wrSeries,
|
||
backgroundColor: 'rgba(245, 193, 108, 0.10)',
|
||
borderColor: 'rgba(245, 193, 108, 0.85)',
|
||
borderWidth: 1.5,
|
||
borderDash: [4, 4],
|
||
pointBackgroundColor: '#F5C16C',
|
||
pointBorderColor: '#1b1b1b',
|
||
pointRadius: 2,
|
||
pointHoverRadius: 4,
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: { duration: 600 },
|
||
onClick: (event, activeElements, chart) => {
|
||
const item = (activeElements && activeElements[0]) || (chart && typeof chart.getElementsAtEventForMode === 'function'
|
||
? chart.getElementsAtEventForMode(event, 'nearest', { intersect: true }, true)[0]
|
||
: null);
|
||
if (!item) return;
|
||
const target = radarData[item.index];
|
||
if (target && target.uid) {
|
||
window.location.href = `/players/${encodeURIComponent(target.uid)}`;
|
||
}
|
||
},
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
backgroundColor: 'rgba(15, 20, 15, 0.95)',
|
||
borderColor: 'rgba(144, 238, 144, 0.3)',
|
||
borderWidth: 1,
|
||
titleColor: '#F5F5DC',
|
||
bodyColor: '#fff',
|
||
padding: 10,
|
||
callbacks: {
|
||
title: (items) => {
|
||
const r = radarData[items[0]?.dataIndex ?? 0];
|
||
return r ? `${r.nick || r.uid} · UID ${r.uid}` : '';
|
||
},
|
||
label: (item) => {
|
||
const r = radarData[item.dataIndex];
|
||
if (item.datasetIndex === 0) {
|
||
return `${T('analytics.colShared')}: ${r.shared}`;
|
||
}
|
||
return `${T('analytics.colWinRate')}: ${r.win_rate}% (${r.wins}W / ${r.losses}L)`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
r: {
|
||
min: 0,
|
||
max: 100,
|
||
ticks: {
|
||
stepSize: 25,
|
||
color: 'rgba(255,255,255,0.4)',
|
||
backdropColor: 'transparent',
|
||
font: { size: 9 },
|
||
callback: (v) => v + '%',
|
||
},
|
||
angleLines: { color: 'rgba(255,255,255,0.1)' },
|
||
grid: { color: 'rgba(255,255,255,0.08)' },
|
||
pointLabels: {
|
||
color: '#F5F5DC',
|
||
font: { size: 10, weight: '600' },
|
||
callback: (label) => label,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function renderPlayerTimeline(slot, rawRows) {
|
||
if (playerTimelineLineChart) { try { playerTimelineLineChart.destroy(); } catch (e) {} playerTimelineLineChart = null; }
|
||
if (playerVehicleBarChart) { try { playerVehicleBarChart.destroy(); } catch (e) {} playerVehicleBarChart = null; }
|
||
|
||
// ── Aggregate per session for K/D + W/L lines ────────────────
|
||
const sessionMap = new Map();
|
||
for (const r of rawRows) {
|
||
let s = sessionMap.get(r.session_id);
|
||
if (!s) {
|
||
s = { session_id: r.session_id, t: r.endtime_unix * 1000, kills: 0, deaths: 0, won: r.won };
|
||
sessionMap.set(r.session_id, s);
|
||
}
|
||
s.kills += r.kills || 0;
|
||
s.deaths += r.deaths || 0;
|
||
if (r.won !== null && r.won !== undefined) s.won = r.won;
|
||
s.t = Math.max(s.t, r.endtime_unix * 1000);
|
||
}
|
||
const sessions = [...sessionMap.values()].sort((a, b) => a.t - b.t);
|
||
|
||
// ── K/D & WR line chart still uses an adaptive bucket so the
|
||
// ── line doesn't become noise on long ranges. Vehicle bars are
|
||
// ── always daily (handled separately below).
|
||
const tMin = sessions.length ? sessions[0].t : Date.now();
|
||
const tMax = sessions.length ? sessions[sessions.length - 1].t : Date.now();
|
||
const spanMs = Math.max(1, tMax - tMin);
|
||
const day = 86400000;
|
||
let bucketMs;
|
||
if (spanMs <= 14 * day) bucketMs = day; // daily
|
||
else if (spanMs <= 90 * day) bucketMs = 7 * day; // weekly
|
||
else if (spanMs <= 540 * day) bucketMs = 14 * day; // bi-weekly
|
||
else bucketMs = 30 * day; // monthly
|
||
const bucketStart = (t) => Math.floor((t - tMin) / bucketMs) * bucketMs + tMin;
|
||
const dayStart = (t) => {
|
||
const d = new Date(t);
|
||
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
||
};
|
||
|
||
// ── Per-bucket aggregates: K/D, WR, K/S (kills per spawn) ────
|
||
// Each row in rawRows == one vehicle == one spawn.
|
||
const bucketAgg = new Map();
|
||
for (const s of sessions) {
|
||
const b = bucketStart(s.t);
|
||
let agg = bucketAgg.get(b);
|
||
if (!agg) { agg = { t: b, k: 0, d: 0, w: 0, l: 0, spawns: 0 }; bucketAgg.set(b, agg); }
|
||
agg.k += s.kills; agg.d += s.deaths;
|
||
if (s.won === 1) agg.w++; else if (s.won === 0) agg.l++;
|
||
}
|
||
for (const r of rawRows) {
|
||
const b = bucketStart(r.endtime_unix * 1000);
|
||
const agg = bucketAgg.get(b);
|
||
if (agg) agg.spawns += 1;
|
||
}
|
||
const bucketRows = [...bucketAgg.values()].sort((a, b) => a.t - b.t);
|
||
const kdSeries = bucketRows.map(b => ({ x: b.t, y: b.d > 0 ? Math.round((b.k / b.d) * 100) / 100 : b.k }));
|
||
const ksSeries = bucketRows.map(b => ({ x: b.t, y: b.spawns > 0 ? Math.round((b.k / b.spawns) * 100) / 100 : 0 }));
|
||
const wrSeries = bucketRows.map(b => {
|
||
const tot = b.w + b.l;
|
||
return { x: b.t, y: tot > 0 ? Math.round((b.w / tot) * 1000) / 10 : null };
|
||
});
|
||
|
||
// ── Daily vehicle stack: top 10 vehicles PER DAY ─────────────
|
||
// First aggregate raw counts per (day, vehicle), then for each day
|
||
// keep only that day's top 10. The dataset list is the union of
|
||
// any vehicle that made any day's top-10 cut.
|
||
const PER_DAY_LIMIT = 10;
|
||
const dayRaw = new Map(); // day -> { vehName: count }
|
||
for (const r of rawRows) {
|
||
if (!r.vehicle) continue;
|
||
const b = dayStart(r.endtime_unix * 1000);
|
||
let m = dayRaw.get(b);
|
||
if (!m) { m = {}; dayRaw.set(b, m); }
|
||
m[r.vehicle] = (m[r.vehicle] || 0) + 1;
|
||
}
|
||
const bucketVeh = new Map(); // day -> { vehName: count } (capped)
|
||
const keptVehicles = new Set();
|
||
for (const [day, counts] of dayRaw) {
|
||
const ranked = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, PER_DAY_LIMIT);
|
||
const kept = {};
|
||
for (const [veh, c] of ranked) { kept[veh] = c; keptVehicles.add(veh); }
|
||
bucketVeh.set(day, kept);
|
||
}
|
||
const vehBuckets = [...bucketVeh.keys()].sort((a, b) => a - b);
|
||
|
||
// Order legend by overall usage across the kept set.
|
||
const keptTotals = new Map();
|
||
for (const counts of bucketVeh.values()) {
|
||
for (const [v, c] of Object.entries(counts)) {
|
||
keptTotals.set(v, (keptTotals.get(v) || 0) + c);
|
||
}
|
||
}
|
||
const vehLabels = [...keptTotals.entries()].sort((a, b) => b[1] - a[1]).map(e => e[0]);
|
||
|
||
// Pre-compute hue per vehicle. Colors are resolved at draw time
|
||
// via scriptable callbacks below — toggling highlight only flips
|
||
// a single state variable instead of rewriting N×M dataset arrays.
|
||
const vehHue = new Map(vehLabels.map((v, i) => [v, Math.round((i * 360) / Math.max(1, vehLabels.length))]));
|
||
const vehDatasets = vehLabels.map((veh) => ({
|
||
label: veh,
|
||
data: vehBuckets.map(b => (bucketVeh.get(b)[veh] || 0)),
|
||
borderWidth: 0,
|
||
stack: 'veh',
|
||
}));
|
||
|
||
// ── Stat strip totals ────────────────────────────────────────
|
||
const wins = sessions.filter(s => s.won === 1).length;
|
||
const losses = sessions.filter(s => s.won === 0).length;
|
||
const wr = (wins + losses) > 0 ? Math.round(wins / (wins + losses) * 1000) / 10 : 0;
|
||
const totalK = sessions.reduce((s, x) => s + x.kills, 0);
|
||
const totalD = sessions.reduce((s, x) => s + x.deaths, 0);
|
||
const overallKd = totalD > 0 ? Math.round((totalK / totalD) * 100) / 100 : totalK;
|
||
const bucketLabel = bucketMs === day ? 'daily' : bucketMs === 7 * day ? 'weekly' : bucketMs === 14 * day ? 'bi-weekly' : 'monthly';
|
||
|
||
slot.innerHTML = `
|
||
<div class="player-stat-strip">
|
||
<div><span class="ps-label">Sessions</span><span class="ps-val">${sessions.length}</span></div>
|
||
<div><span class="ps-label">Wins</span><span class="ps-val" style="color:#90EE90">${wins}</span></div>
|
||
<div><span class="ps-label">Losses</span><span class="ps-val" style="color:#ff9b9b">${losses}</span></div>
|
||
<div><span class="ps-label">Win Rate</span><span class="ps-val">${wr}%</span></div>
|
||
<div><span class="ps-label">Total K</span><span class="ps-val">${totalK}</span></div>
|
||
<div><span class="ps-label">Total D</span><span class="ps-val">${totalD}</span></div>
|
||
<div><span class="ps-label">Overall K/D</span><span class="ps-val">${overallKd.toFixed(2)}</span></div>
|
||
</div>
|
||
<div class="timeline-card">
|
||
<div class="timeline-card-header">
|
||
<span class="timeline-card-title">K/D · K/S · Win Rate</span>
|
||
<span class="timeline-card-meta">${bucketLabel} buckets</span>
|
||
</div>
|
||
<div class="timeline-canvas-wrap"><canvas id="playerKdWrCanvas"></canvas></div>
|
||
</div>
|
||
<div class="timeline-card" style="margin-top:1rem;">
|
||
<div class="timeline-card-header">
|
||
<span class="timeline-card-title">Vehicle performance</span>
|
||
<span class="timeline-card-meta">x = matches · y = K/D · size = WR</span>
|
||
</div>
|
||
<div class="timeline-canvas-wrap"><canvas id="playerVehicleBubbleCanvas"></canvas></div>
|
||
</div>
|
||
<div class="timeline-card" style="margin-top:1rem;">
|
||
<div class="timeline-card-header">
|
||
<span class="timeline-card-title">Vehicle usage</span>
|
||
<span class="timeline-card-meta">daily · top 10 per day · ${vehLabels.length} unique · click legend to highlight</span>
|
||
</div>
|
||
<div class="timeline-canvas-wrap"><canvas id="playerVehicleCanvas"></canvas></div>
|
||
</div>
|
||
`;
|
||
|
||
if (typeof Chart === 'undefined' || !sessions.length) return;
|
||
|
||
// ── Dual-axis line chart ─────────────────────────────────────
|
||
const lineCtx = document.getElementById('playerKdWrCanvas').getContext('2d');
|
||
playerTimelineLineChart = new Chart(lineCtx, {
|
||
type: 'line',
|
||
data: {
|
||
datasets: [
|
||
{
|
||
label: 'K/D',
|
||
data: kdSeries,
|
||
borderColor: 'rgba(144, 238, 144, 0.95)',
|
||
backgroundColor: 'rgba(144, 238, 144, 0.12)',
|
||
borderWidth: 2,
|
||
pointRadius: 2.5,
|
||
pointHoverRadius: 5,
|
||
tension: 0.3,
|
||
yAxisID: 'yKd',
|
||
fill: false,
|
||
},
|
||
{
|
||
label: 'K/S',
|
||
data: ksSeries,
|
||
borderColor: 'rgba(168, 200, 230, 0.95)',
|
||
backgroundColor: 'rgba(168, 200, 230, 0.0)',
|
||
borderWidth: 2,
|
||
borderDash: [2, 3],
|
||
pointRadius: 2,
|
||
pointHoverRadius: 4,
|
||
tension: 0.3,
|
||
yAxisID: 'yKd',
|
||
fill: false,
|
||
},
|
||
{
|
||
label: 'Win Rate %',
|
||
data: wrSeries,
|
||
borderColor: 'rgba(245, 193, 108, 0.95)',
|
||
backgroundColor: 'rgba(245, 193, 108, 0.0)',
|
||
borderWidth: 2,
|
||
borderDash: [5, 4],
|
||
pointRadius: 2.5,
|
||
pointHoverRadius: 5,
|
||
tension: 0.3,
|
||
yAxisID: 'yWr',
|
||
spanGaps: true,
|
||
fill: false,
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: false,
|
||
// Only fire when the cursor is actually on a data point.
|
||
interaction: { mode: 'nearest', intersect: true, axis: 'xy' },
|
||
hover: { mode: 'nearest', intersect: true },
|
||
plugins: {
|
||
legend: { display: true, labels: { color: 'rgba(245,245,220,0.85)', font: { size: 11 } } },
|
||
tooltip: {
|
||
animation: false,
|
||
mode: 'nearest',
|
||
intersect: true,
|
||
backgroundColor: 'rgba(15,20,15,0.95)', borderColor: 'rgba(144,238,144,0.3)',
|
||
borderWidth: 1, titleColor: '#F5F5DC', bodyColor: '#fff', padding: 10,
|
||
callbacks: {
|
||
title: (items) => new Date(items[0].parsed.x).toISOString().slice(0, 10),
|
||
label: (item) => {
|
||
const v = item.parsed.y;
|
||
if (v === null) return null;
|
||
if (item.dataset.label === 'Win Rate %') return `WR: ${v}%`;
|
||
return `${item.dataset.label}: ${v.toFixed(2)}`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
type: 'linear',
|
||
ticks: { color: 'rgba(255,255,255,0.5)', callback: (v) => new Date(v).toISOString().slice(0, 10) },
|
||
grid: { color: 'rgba(144,238,144,0.06)' },
|
||
},
|
||
yKd: {
|
||
type: 'linear',
|
||
position: 'left',
|
||
// Lock the K/D axis bounds so toggling the dataset
|
||
// doesn't rescale. Span K/D and K/S together.
|
||
min: 0,
|
||
max: Math.max(1, Math.ceil(Math.max(0, ...kdSeries.map(p => p.y), ...ksSeries.map(p => p.y)) * 1.15 * 10) / 10),
|
||
title: { display: true, text: 'K/D · K/S', color: 'rgba(144,238,144,0.85)' },
|
||
ticks: { color: 'rgba(144,238,144,0.7)' },
|
||
grid: { color: 'rgba(144,238,144,0.08)' },
|
||
},
|
||
yWr: {
|
||
type: 'linear',
|
||
position: 'right',
|
||
min: 0, max: 100,
|
||
title: { display: true, text: 'WR %', color: 'rgba(245,193,108,0.85)' },
|
||
ticks: { color: 'rgba(245,193,108,0.7)', stepSize: 25, callback: (v) => v + '%' },
|
||
grid: { drawOnChartArea: false },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
// ── Stacked daily vehicle bar ────────────────────────────────
|
||
if (vehBuckets.length) {
|
||
// Single shared state — scriptable color callbacks read it on
|
||
// each draw, so toggling is one var write + one redraw.
|
||
const highlightState = { veh: null };
|
||
const bgFor = (ctx) => {
|
||
const ds = ctx.dataset; if (!ds) return 'hsla(0,0%,50%,0)';
|
||
const hue = vehHue.get(ds.label) ?? 0;
|
||
if (highlightState.veh === null) return `hsla(${hue},65%,60%,0.78)`;
|
||
if (ds.label === highlightState.veh) return `hsla(${hue},65%,60%,0.92)`;
|
||
return `hsla(${hue},65%,60%,0.06)`;
|
||
};
|
||
const bdFor = (ctx) => {
|
||
const ds = ctx.dataset; if (!ds) return 'transparent';
|
||
if (highlightState.veh && ds.label === highlightState.veh) return '#ffffff';
|
||
return 'transparent';
|
||
};
|
||
const bwFor = (ctx) => {
|
||
const ds = ctx.dataset; if (!ds) return 0;
|
||
return (highlightState.veh && ds.label === highlightState.veh) ? 1.5 : 0;
|
||
};
|
||
|
||
// Apply scriptable callbacks once on the dataset config.
|
||
vehDatasets.forEach(ds => {
|
||
ds.backgroundColor = bgFor;
|
||
ds.borderColor = bdFor;
|
||
ds.borderWidth = bwFor;
|
||
});
|
||
|
||
const applyHighlight = (chart) => {
|
||
// Scriptable callbacks resolve from highlightState on draw,
|
||
// so just trigger a paint — no per-dataset mutation needed.
|
||
chart.update('none');
|
||
};
|
||
|
||
const barCtx = document.getElementById('playerVehicleCanvas').getContext('2d');
|
||
playerVehicleBarChart = new Chart(barCtx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: vehBuckets.map(b => new Date(b).toISOString().slice(0, 10)),
|
||
datasets: vehDatasets,
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: false,
|
||
// Cursor must intersect a real bar segment, but tooltip
|
||
// shows the full stack for that day (index mode).
|
||
interaction: { mode: 'index', intersect: true, axis: 'x' },
|
||
hover: { mode: 'index', intersect: true, axis: 'x' },
|
||
// Pack bars edge-to-edge so we can show as many days as fit.
|
||
categoryPercentage: 1.0,
|
||
barPercentage: 0.95,
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'bottom',
|
||
labels: { color: 'rgba(245,245,220,0.85)', font: { size: 10, family: "'skyquakesymbols', 'Inter', sans-serif" }, boxWidth: 12 },
|
||
onClick: (_e, item, legend) => {
|
||
const chart = legend && legend.chart ? legend.chart : playerVehicleBarChart;
|
||
highlightState.veh = (highlightState.veh === item.text) ? null : item.text;
|
||
applyHighlight(chart);
|
||
},
|
||
},
|
||
tooltip: {
|
||
enabled: true,
|
||
mode: 'index',
|
||
intersect: true,
|
||
animation: false,
|
||
backgroundColor: 'rgba(15,20,15,0.95)', borderColor: 'rgba(144,238,144,0.3)',
|
||
borderWidth: 1, titleColor: '#F5F5DC', bodyColor: '#fff', padding: 10,
|
||
bodyFont: { family: "'skyquakesymbols', 'Inter', sans-serif" },
|
||
// Drop the zero-value layers so we only show
|
||
// vehicles actually used on this day.
|
||
filter: (item) => item.parsed.y > 0,
|
||
itemSort: (a, b) => b.parsed.y - a.parsed.y,
|
||
callbacks: {
|
||
title: (items) => {
|
||
const i = items[0]?.dataIndex;
|
||
return i != null ? new Date(vehBuckets[i]).toISOString().slice(0, 10) : '';
|
||
},
|
||
label: (item) => `${item.dataset.label}: ${item.parsed.y}`,
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
stacked: true,
|
||
ticks: {
|
||
color: 'rgba(255,255,255,0.5)',
|
||
autoSkip: true,
|
||
maxRotation: 0,
|
||
autoSkipPadding: 10,
|
||
},
|
||
grid: { display: false },
|
||
},
|
||
y: {
|
||
stacked: true,
|
||
title: { display: true, text: 'Matches', color: 'rgba(245,245,220,0.7)' },
|
||
ticks: { color: 'rgba(255,255,255,0.5)', precision: 0 },
|
||
grid: { color: 'rgba(144,238,144,0.06)' },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
// ── Vehicle performance bubble (X=matches, Y=KD, size=WR) ────
|
||
const sessionWon = new Map(sessions.map(s => [s.session_id, s.won]));
|
||
const vehStats = new Map();
|
||
for (const r of rawRows) {
|
||
if (!r.vehicle) continue;
|
||
let v = vehStats.get(r.vehicle);
|
||
if (!v) { v = { name: r.vehicle, k: 0, d: 0, sessions: new Set() }; vehStats.set(r.vehicle, v); }
|
||
v.k += r.kills || 0;
|
||
v.d += r.deaths || 0;
|
||
v.sessions.add(r.session_id);
|
||
}
|
||
const vehBubbleRows = [];
|
||
for (const v of vehStats.values()) {
|
||
let w = 0, l = 0;
|
||
for (const sid of v.sessions) {
|
||
const won = sessionWon.get(sid);
|
||
if (won === 1) w++; else if (won === 0) l++;
|
||
}
|
||
const games = v.sessions.size;
|
||
if (games < 3) continue; // hide noise from one-off vehicles
|
||
vehBubbleRows.push({
|
||
name: v.name,
|
||
games,
|
||
kd: v.d > 0 ? Math.round((v.k / v.d) * 100) / 100 : v.k,
|
||
wr: (w + l) > 0 ? Math.round((w / (w + l)) * 1000) / 10 : 0,
|
||
});
|
||
}
|
||
|
||
const bubbleCanvas = document.getElementById('playerVehicleBubbleCanvas');
|
||
if (bubbleCanvas && vehBubbleRows.length) {
|
||
const maxGames = Math.max(...vehBubbleRows.map(v => v.games));
|
||
const maxKd = Math.max(0, ...vehBubbleRows.map(v => v.kd));
|
||
const points = vehBubbleRows.map(v => ({
|
||
x: v.games,
|
||
y: v.kd,
|
||
r: 4 + (v.games / maxGames) * 12,
|
||
_meta: v,
|
||
}));
|
||
const ctx = bubbleCanvas.getContext('2d');
|
||
new Chart(ctx, {
|
||
type: 'bubble',
|
||
data: { datasets: [{
|
||
data: points,
|
||
backgroundColor: points.map(p => `hsla(${Math.round(p._meta.wr * 1.2)}, 65%, 55%, 0.7)`),
|
||
borderColor: points.map(p => `hsla(${Math.round(p._meta.wr * 1.2)}, 65%, 70%, 1)`),
|
||
borderWidth: 1,
|
||
}] },
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: false,
|
||
layout: { padding: 18 },
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
animation: false,
|
||
backgroundColor: 'rgba(15,20,15,0.95)', borderColor: 'rgba(144,238,144,0.3)',
|
||
borderWidth: 1, titleColor: '#F5F5DC', bodyColor: '#fff', padding: 10,
|
||
titleFont: { family: "'skyquakesymbols', 'Inter', sans-serif" },
|
||
callbacks: {
|
||
title: (items) => points[items[0].dataIndex]._meta.name,
|
||
label: (item) => {
|
||
const m = points[item.dataIndex]._meta;
|
||
return [`Matches: ${m.games}`, `K/D: ${m.kd.toFixed(2)}`, `WR: ${m.wr}%`];
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
title: { display: true, text: 'Matches played', color: 'rgba(245,245,220,0.7)' },
|
||
min: 0,
|
||
suggestedMax: Math.ceil(maxGames * 1.1),
|
||
ticks: { color: 'rgba(255,255,255,0.5)', precision: 0 },
|
||
grid: { color: 'rgba(144,238,144,0.06)' },
|
||
},
|
||
y: {
|
||
title: { display: true, text: 'K/D', color: 'rgba(245,245,220,0.7)' },
|
||
min: 0,
|
||
suggestedMax: Math.max(1, Math.ceil(maxKd * 1.15 * 10) / 10),
|
||
ticks: { color: 'rgba(255,255,255,0.5)' },
|
||
grid: { color: 'rgba(144,238,144,0.08)' },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
}
|
||
|
||
function renderActivityCalendar(mount, dailyArr) {
|
||
// dailyArr: [{day: utcMs, count: int}, ...] sorted ascending
|
||
if (!dailyArr || !dailyArr.length) { mount.innerHTML = '<div class="empty-state" style="padding:1.5rem;">No activity</div>'; return; }
|
||
const counts = new Map();
|
||
for (const r of dailyArr) counts.set(r.day, r.count);
|
||
|
||
// Always render entire calendar years. We span min(firstDataYear,
|
||
// currentYear) → max(lastDataYear, currentYear) so the grid stays
|
||
// the same size when filters narrow the data — filters change
|
||
// cell intensity, never crop the calendar.
|
||
const nowYear = new Date().getUTCFullYear();
|
||
const dataFirst = new Date(dailyArr[0].day).getUTCFullYear();
|
||
const dataLast = new Date(dailyArr[dailyArr.length - 1].day).getUTCFullYear();
|
||
const firstYear = Math.min(dataFirst, nowYear);
|
||
const lastYear = Math.max(dataLast, nowYear);
|
||
const yearStart = Date.UTC(firstYear, 0, 1);
|
||
const yearEnd = Date.UTC(lastYear, 11, 31);
|
||
const startDow = new Date(yearStart).getUTCDay();
|
||
const start = yearStart - startDow * 86400000;
|
||
const endDow = new Date(yearEnd).getUTCDay();
|
||
const end = yearEnd + (6 - endDow) * 86400000;
|
||
const weeks = Math.round((end - start) / 86400000 / 7) + 1;
|
||
|
||
// Bucket → color (5 levels)
|
||
const max = Math.max(0, ...counts.values());
|
||
const lvl = (n) => {
|
||
if (!n) return 0;
|
||
if (max <= 4) return Math.min(4, n);
|
||
if (n >= max * 0.75) return 4;
|
||
if (n >= max * 0.50) return 3;
|
||
if (n >= max * 0.25) return 2;
|
||
return 1;
|
||
};
|
||
const palette = ['rgba(144,238,144,0.06)', 'rgba(144,238,144,0.25)', 'rgba(144,238,144,0.5)', 'rgba(144,238,144,0.75)', 'rgba(144,238,144,1)'];
|
||
|
||
const cell = 12, gap = 3, leftPad = 28, topPad = 18, bottomPad = 4;
|
||
const w = leftPad + weeks * (cell + gap);
|
||
const h = topPad + 7 * (cell + gap) + bottomPad;
|
||
const dowLabels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
|
||
const monthLabels = [];
|
||
let prevMonth = -1;
|
||
|
||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" style="display:block;">`;
|
||
for (let r = 0; r < 7; r++) {
|
||
if (dowLabels[r]) {
|
||
svg += `<text x="0" y="${topPad + r * (cell + gap) + cell - 2}" fill="rgba(255,255,255,0.45)" font-size="9" font-family="Inter">${dowLabels[r]}</text>`;
|
||
}
|
||
}
|
||
for (let wIdx = 0; wIdx < weeks; wIdx++) {
|
||
for (let dRow = 0; dRow < 7; dRow++) {
|
||
const dayMs = start + (wIdx * 7 + dRow) * 86400000;
|
||
if (dayMs < yearStart || dayMs > yearEnd) continue;
|
||
const c = counts.get(dayMs) || 0;
|
||
const x = leftPad + wIdx * (cell + gap);
|
||
const y = topPad + dRow * (cell + gap);
|
||
const dateStr = new Date(dayMs).toISOString().slice(0, 10);
|
||
svg += `<rect x="${x}" y="${y}" width="${cell}" height="${cell}" rx="2" fill="${palette[lvl(c)]}"><title>${dateStr}: ${c} game${c === 1 ? '' : 's'}</title></rect>`;
|
||
const m = new Date(dayMs).getUTCMonth();
|
||
if (dRow === 0 && m !== prevMonth) {
|
||
const mLabel = new Date(dayMs).toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
|
||
monthLabels.push({ x, label: mLabel });
|
||
prevMonth = m;
|
||
}
|
||
}
|
||
}
|
||
for (const m of monthLabels) {
|
||
svg += `<text x="${m.x}" y="${topPad - 5}" fill="rgba(255,255,255,0.55)" font-size="9" font-family="Inter">${m.label}</text>`;
|
||
}
|
||
svg += `</svg>`;
|
||
mount.innerHTML = svg;
|
||
}
|
||
|
||
async function ensureSeasonsLoaded() {
|
||
if (!window.seasonsFilter) return;
|
||
try {
|
||
if (!window.seasonsFilter.loaded) await window.seasonsFilter.loadSeasons();
|
||
window.seasonsFilter.populateSeasonSelect(document.getElementById('af-season'));
|
||
window.seasonsFilter.populateSeasonSelect(document.getElementById('af-week-season-select'));
|
||
} catch (e) { console.error('seasonsFilter load failed', e); }
|
||
}
|
||
|
||
function wireDateFilter() {
|
||
const $ = (id) => document.getElementById(id);
|
||
const cat = $('af-filter-category');
|
||
const dateType = $('af-date-filter-type');
|
||
const startEl = $('af-start-date');
|
||
const endEl = $('af-end-date');
|
||
const singleDaySlotWrap = $('af-single-day-timeslot-filter');
|
||
const singleDaySlotEl = $('af-single-day-timeslot');
|
||
const seasonEl = $('af-season');
|
||
const weekSeasonEl = $('af-week-season-select');
|
||
const weekEl = $('af-week');
|
||
const resetGroup = $('af-reset-btn-group');
|
||
const resetBtn = $('af-reset-btn');
|
||
ensureSeasonsLoaded();
|
||
|
||
const refreshSingleDaySlot = () => {
|
||
const pair = getCustomRangeDayPair();
|
||
const singleDay = !!pair && pair.startDay === pair.endDay;
|
||
const show = cat.value === 'date' && dateType.value === 'custom' && singleDay;
|
||
singleDaySlotWrap.style.display = show ? 'flex' : 'none';
|
||
if (!show) singleDaySlotEl.value = 'all';
|
||
};
|
||
|
||
const showCat = (c) => {
|
||
$('af-date-type-filter').style.display = 'none';
|
||
$('af-custom-range-filters').style.display = 'none';
|
||
$('af-season-select-filter').style.display = 'none';
|
||
$('af-week-season-filter').style.display = 'none';
|
||
$('af-week-select-filter').style.display = 'none';
|
||
if (c === 'date') $('af-date-type-filter').style.display = 'flex';
|
||
else if (c === 'season') $('af-season-select-filter').style.display = 'flex';
|
||
else if (c === 'week') {
|
||
$('af-week-season-filter').style.display = 'flex';
|
||
$('af-week-select-filter').style.display = 'flex';
|
||
}
|
||
refreshSingleDaySlot();
|
||
};
|
||
const refreshReset = () => {
|
||
resetGroup.style.display = cat.value !== 'all' ? 'flex' : 'none';
|
||
};
|
||
const applyAndReload = () => {
|
||
let startTs = 0, endTs = 0;
|
||
const c = cat.value;
|
||
if (c === 'date') {
|
||
const t = dateType.value;
|
||
const now = Date.now();
|
||
const day = 86400000;
|
||
if (t === 'last7') { startTs = Math.floor((now - 7 * day) / 1000); endTs = Math.floor(now / 1000); }
|
||
else if (t === 'last30') { startTs = Math.floor((now - 30 * day) / 1000); endTs = Math.floor(now / 1000); }
|
||
else if (t === 'last90') { startTs = Math.floor((now - 90 * day) / 1000); endTs = Math.floor(now / 1000); }
|
||
else if (t === 'custom') {
|
||
const pair = getCustomRangeDayPair();
|
||
if (!pair) return;
|
||
const slot = pair.startDay === pair.endDay
|
||
? (singleDaySlotEl.value || 'all')
|
||
: 'all';
|
||
const s = buildTimeslotBoundary(pair.startDay, slot, 'start');
|
||
const e = buildTimeslotBoundary(pair.endDay, slot, 'end');
|
||
if (!s || !e) return;
|
||
startTs = Math.floor(s.getTime() / 1000);
|
||
endTs = Math.floor(e.getTime() / 1000);
|
||
} else return;
|
||
} else if (c === 'season') {
|
||
const name = seasonEl.value;
|
||
// Empty value (Please select / All seasons) → drop the filter.
|
||
if (!name) {
|
||
dateFilter = { type: 'all', startTs: 0, endTs: 0 };
|
||
refreshReset();
|
||
reloadActiveView();
|
||
return;
|
||
}
|
||
if (!window.seasonsFilter) return;
|
||
const range = window.seasonsFilter.getSeasonDateRange(name);
|
||
if (!range) return;
|
||
startTs = Math.floor(range.startDate.getTime() / 1000);
|
||
endTs = Math.floor(range.endDate.getTime() / 1000);
|
||
} else if (c === 'week') {
|
||
const name = weekSeasonEl.value, w = weekEl.value;
|
||
if (!name || !w || !window.seasonsFilter) return;
|
||
let range;
|
||
if (w === 'all') range = window.seasonsFilter.getSeasonDateRange(name);
|
||
else {
|
||
const wn = (w === 'final') ? null : parseInt(w);
|
||
range = window.seasonsFilter.getWeekDateRange(name, wn);
|
||
}
|
||
if (!range) return;
|
||
startTs = Math.floor(range.startDate.getTime() / 1000);
|
||
endTs = Math.floor(range.endDate.getTime() / 1000);
|
||
}
|
||
dateFilter = { type: c, startTs, endTs };
|
||
refreshReset();
|
||
reloadActiveView();
|
||
};
|
||
|
||
cat.addEventListener('change', () => {
|
||
showCat(cat.value);
|
||
refreshReset();
|
||
if (cat.value === 'all') {
|
||
dateFilter = { type: 'all', startTs: 0, endTs: 0 };
|
||
reloadActiveView();
|
||
} else if (cat.value === 'date') {
|
||
// pick a sensible default once the user opens the date sub-controls
|
||
dateType.dispatchEvent(new Event('change'));
|
||
}
|
||
});
|
||
dateType.addEventListener('change', () => {
|
||
$('af-custom-range-filters').style.display = dateType.value === 'custom' ? 'flex' : 'none';
|
||
refreshSingleDaySlot();
|
||
if (dateType.value !== 'custom') applyAndReload();
|
||
});
|
||
startEl.addEventListener('change', () => { refreshSingleDaySlot(); applyAndReload(); });
|
||
endEl.addEventListener('change', () => { refreshSingleDaySlot(); applyAndReload(); });
|
||
singleDaySlotEl.addEventListener('change', applyAndReload);
|
||
seasonEl.addEventListener('change', applyAndReload);
|
||
weekSeasonEl.addEventListener('change', () => {
|
||
if (weekSeasonEl.value && window.seasonsFilter) {
|
||
window.seasonsFilter.populateWeekSelect(weekEl, weekSeasonEl.value, true);
|
||
} else {
|
||
weekEl.innerHTML = `<option value="">${escapeHtml(T('player.pleaseSelect'))}</option>`;
|
||
}
|
||
});
|
||
weekEl.addEventListener('change', applyAndReload);
|
||
resetBtn.addEventListener('click', () => {
|
||
cat.value = 'all'; dateType.value = 'last7';
|
||
startEl.value = ''; endEl.value = '';
|
||
singleDaySlotEl.value = 'all';
|
||
seasonEl.value = ''; weekSeasonEl.value = '';
|
||
weekEl.innerHTML = `<option value="">${escapeHtml(T('player.pleaseSelect'))}</option>`;
|
||
showCat('all'); refreshReset();
|
||
dateFilter = { type: 'all', startTs: 0, endTs: 0 };
|
||
reloadActiveView();
|
||
});
|
||
}
|
||
|
||
function switchView(view) {
|
||
activeView = view;
|
||
document.querySelectorAll('#analyticsTabs .leaderboard-tab').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.view === view);
|
||
});
|
||
loadView(view);
|
||
}
|
||
|
||
async function loadView(view) {
|
||
const slot = document.getElementById('analyticsViewSlot');
|
||
if (!activeSquadron) return;
|
||
|
||
const cacheKey = `${activeSquadron}|${view}|${filterKey()}`;
|
||
if (cache.has(cacheKey)) {
|
||
renderView(view, cache.get(cacheKey));
|
||
return;
|
||
}
|
||
|
||
slot.innerHTML = `<div class="loading-state"><div class="loading-spinner"></div>${escapeHtml(T('analytics.loading'))}</div>`;
|
||
try {
|
||
const data = await window.apiClient.request(`/api/analytics/${view}/${encodeURIComponent(activeSquadron)}${filterQueryString()}`);
|
||
cache.set(cacheKey, data);
|
||
if (activeView === view) renderView(view, data);
|
||
} catch (e) {
|
||
console.error(e);
|
||
slot.innerHTML = `<div class="error-state"><i class="fas fa-exclamation-triangle"></i> ${escapeHtml(T('analytics.loadError'))}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderView(view, data) {
|
||
const slot = document.getElementById('analyticsViewSlot');
|
||
const isEmpty = Array.isArray(data)
|
||
? data.length === 0
|
||
: (view === 'time' ? (!data || !data.hourly || Object.keys(data.hourly).length === 0)
|
||
: view === 'matchup' ? (!data.won_against || (!data.won_against.length && !data.lost_against.length))
|
||
: view === 'comps' ? (!data || !data.top_vehicles || data.top_vehicles.length === 0)
|
||
: false);
|
||
if (isEmpty) {
|
||
slot.innerHTML = `<div class="empty-state"><i class="fas fa-inbox" style="font-size:1.5rem; display:block; margin-bottom:0.5rem;"></i>${escapeHtml(T('analytics.noData'))}</div>`;
|
||
return;
|
||
}
|
||
switch (view) {
|
||
case 'maps': return renderMaps(slot, data);
|
||
case 'consistency': return renderConsistency(slot, data);
|
||
case 'time': return renderTime(slot, data);
|
||
case 'matchup': return renderMatchup(slot, data);
|
||
case 'comps': return renderComps(slot, data);
|
||
}
|
||
}
|
||
|
||
let mapsRadarChart = null;
|
||
|
||
function renderMaps(slot, data) {
|
||
if (mapsRadarChart) { try { mapsRadarChart.destroy(); } catch (e) {} mapsRadarChart = null; }
|
||
|
||
const rows = data.map(r => `
|
||
<tr>
|
||
<td class="col-map" data-sort-value="${escapeHtml((r.map_name || '').toLowerCase())}">${escapeHtml(r.map_name)}</td>
|
||
<td class="col-num" data-sort-value="${r.wins}" style="color:#90EE90">${r.wins}</td>
|
||
<td class="col-num" data-sort-value="${r.losses}" style="color:#ff9b9b">${r.losses}</td>
|
||
<td class="col-num" data-sort-value="${r.win_rate}">${r.win_rate}%</td>
|
||
<td class="col-bar"><div class="bar-track"><div class="bar-fill" style="width:${Math.min(100, r.win_rate)}%"></div></div></td>
|
||
<td class="col-num" data-sort-value="${r.total}">${r.total}</td>
|
||
</tr>`).join('');
|
||
|
||
const RADAR_MIN_GAMES = (dateFilter.type === 'week' || dateFilter.type === 'date') ? 2 : 5;
|
||
const RADAR_MAX_AXES = 20;
|
||
const eligible = data.filter(r => r.total >= RADAR_MIN_GAMES);
|
||
// Pick top N by games-played, then sort alphabetically for stable layout.
|
||
const radarData = eligible
|
||
.slice()
|
||
.sort((a, b) => b.total - a.total)
|
||
.slice(0, RADAR_MAX_AXES)
|
||
.sort((a, b) => a.map_name.localeCompare(b.map_name));
|
||
const excludedCount = data.length - radarData.length;
|
||
const maxGames = radarData.reduce((m, r) => Math.max(m, r.total), 0) || 1;
|
||
const truncate = (s, n) => s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s;
|
||
|
||
slot.innerHTML = `
|
||
<div class="maps-layout">
|
||
<div class="maps-table-wrap">
|
||
<table class="analytics-table maps-table">
|
||
<thead><tr>
|
||
<th class="col-map" data-sort-type="text">${escapeHtml(T('analytics.colMap'))}</th>
|
||
<th class="col-num" data-sort-type="num">${escapeHtml(T('analytics.colWins'))}</th>
|
||
<th class="col-num" data-sort-type="num">${escapeHtml(T('analytics.colLosses'))}</th>
|
||
<th class="col-num" data-sort-type="num">${escapeHtml(T('analytics.colWinRate'))}</th>
|
||
<th class="col-bar" data-sortable="false">${escapeHtml(T('analytics.colBar'))}</th>
|
||
<th class="col-num" data-sort-type="num">${escapeHtml(T('analytics.colGames'))}</th>
|
||
</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="radar-card">
|
||
<div class="radar-card-header">
|
||
<span class="radar-card-title">${escapeHtml(T('analytics.tabMaps'))}</span>
|
||
<span class="radar-card-meta">${escapeHtml(T('analytics.radarMetaMaps', { shown: radarData.length, total: data.length, min: RADAR_MIN_GAMES }))}</span>
|
||
</div>
|
||
${radarData.length < 3 ? `
|
||
<div class="radar-canvas-wrap" style="display:flex; align-items:center; justify-content:center; text-align:center; color:rgba(245,245,220,0.55); padding:1.5rem;">
|
||
${escapeHtml(T('analytics.radarTooFewMaps', { min: RADAR_MIN_GAMES }))}
|
||
</div>` : `
|
||
<div class="radar-canvas-wrap">
|
||
<canvas id="mapsRadarCanvas"></canvas>
|
||
</div>
|
||
<div class="radar-legend">
|
||
<span><span class="swatch" style="background:#90EE90"></span>${escapeHtml(T('analytics.colWinRate'))}</span>
|
||
<span><span class="swatch" style="background:#F5C16C"></span>${escapeHtml(T('analytics.colGames'))}</span>
|
||
</div>`}
|
||
${excludedCount > 0 ? `<div class="radar-footnote">${escapeHtml(T('analytics.radarFootnoteMaps', { count: excludedCount }))}</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
|
||
makeTableSortable(slot.querySelector('.maps-table'));
|
||
|
||
if (radarData.length < 3 || typeof Chart === 'undefined') return;
|
||
|
||
const labels = radarData.map(r => truncate(r.map_name, 16));
|
||
const wrSeries = radarData.map(r => r.win_rate);
|
||
const gamesSeries = radarData.map(r => Math.round((r.total / maxGames) * 100));
|
||
|
||
const ctx = document.getElementById('mapsRadarCanvas').getContext('2d');
|
||
mapsRadarChart = new Chart(ctx, {
|
||
type: 'radar',
|
||
data: {
|
||
labels,
|
||
datasets: [
|
||
{
|
||
label: T('analytics.colWinRate'),
|
||
data: wrSeries,
|
||
backgroundColor: 'rgba(144, 238, 144, 0.22)',
|
||
borderColor: 'rgba(144, 238, 144, 0.95)',
|
||
borderWidth: 2,
|
||
pointBackgroundColor: '#90EE90',
|
||
pointBorderColor: '#1b1b1b',
|
||
pointRadius: 3,
|
||
pointHoverRadius: 5,
|
||
},
|
||
{
|
||
label: T('analytics.colGames'),
|
||
data: gamesSeries,
|
||
backgroundColor: 'rgba(245, 193, 108, 0.10)',
|
||
borderColor: 'rgba(245, 193, 108, 0.85)',
|
||
borderWidth: 1.5,
|
||
borderDash: [4, 4],
|
||
pointBackgroundColor: '#F5C16C',
|
||
pointBorderColor: '#1b1b1b',
|
||
pointRadius: 2,
|
||
pointHoverRadius: 4,
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: { duration: 600 },
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
backgroundColor: 'rgba(15, 20, 15, 0.95)',
|
||
borderColor: 'rgba(144, 238, 144, 0.3)',
|
||
borderWidth: 1,
|
||
titleColor: '#F5F5DC',
|
||
bodyColor: '#fff',
|
||
padding: 10,
|
||
callbacks: {
|
||
title: (items) => items[0]?.label ?? '',
|
||
label: (item) => {
|
||
const r = radarData[item.dataIndex];
|
||
if (item.datasetIndex === 0) {
|
||
return `${T('analytics.colWinRate')}: ${r.win_rate}% (${r.wins}W / ${r.losses}L)`;
|
||
}
|
||
return `${T('analytics.colGames')}: ${r.total}`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
r: {
|
||
min: 0,
|
||
max: 100,
|
||
ticks: {
|
||
stepSize: 25,
|
||
color: 'rgba(255,255,255,0.4)',
|
||
backdropColor: 'transparent',
|
||
font: { size: 9 },
|
||
callback: (v) => v + '%',
|
||
},
|
||
grid: { color: 'rgba(144, 238, 144, 0.12)' },
|
||
angleLines: { color: 'rgba(144, 238, 144, 0.12)' },
|
||
pointLabels: {
|
||
color: 'rgba(245, 245, 220, 0.85)',
|
||
font: { size: 10, family: 'Inter, sans-serif' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
let consistencyScatter = null;
|
||
let consistencySelectedUid = null;
|
||
|
||
function renderConsistency(slot, data) {
|
||
if (consistencyScatter) { try { consistencyScatter.destroy(); } catch (e) {} consistencyScatter = null; }
|
||
consistencySelectedUid = null;
|
||
|
||
const maxKd = Math.max(1, ...data.map(r => r.kd));
|
||
const rows = data.map((r, i) => `
|
||
<tr class="consistency-row row-link" data-uid="${escapeHtml(r.uid)}">
|
||
<td>${i + 1}</td>
|
||
<td data-sort-value="${escapeHtml((r.nick || '').toLowerCase())}">
|
||
<a href="/players/${escapeHtml(r.uid)}" class="row-link-overlay" onclick="event.preventDefault();" aria-label="View ${escapeHtml(r.nick)}"></a>
|
||
<a href="/players/${escapeHtml(r.uid)}" style="color:#A8E6CF; text-decoration:none;" onclick="event.stopPropagation();">${escapeHtml(r.nick)}</a>
|
||
</td>
|
||
<td data-sort-value="${r.games}">${r.games}</td>
|
||
<td data-sort-value="${r.kd}" style="white-space:nowrap;">${r.kd.toFixed(2)}<span class="micro-bar kd"><span style="width:${Math.min(100, (r.kd / maxKd) * 100)}%"></span></span></td>
|
||
<td data-sort-value="${r.win_rate}" style="white-space:nowrap;">${r.win_rate}%<span class="micro-bar"><span style="width:${Math.min(100, r.win_rate)}%"></span></span></td>
|
||
</tr>`).join('');
|
||
|
||
slot.innerHTML = `
|
||
<div class="consistency-layout">
|
||
<div class="consistency-table-wrap">
|
||
<table class="analytics-table consistency-table">
|
||
<thead><tr>
|
||
<th data-sortable="false">#</th>
|
||
<th data-sort-type="text">${escapeHtml(T('analytics.colPlayer'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colGames'))}</th>
|
||
<th data-sort-type="num">K/D</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colWinRate'))}</th>
|
||
</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="scatter-card">
|
||
<div class="scatter-card-header">
|
||
<span class="scatter-card-title">${escapeHtml(T('analytics.tabConsistency'))}</span>
|
||
<button class="scatter-reset" id="scatterReset" style="visibility:hidden;">Reset</button>
|
||
</div>
|
||
<div class="scatter-canvas-wrap">
|
||
<canvas id="consistencyScatterCanvas"></canvas>
|
||
</div>
|
||
<div class="radar-footnote">${data.length} players · K/D vs Win Rate · dot size = games</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
makeTableSortable(slot.querySelector('.consistency-table'));
|
||
|
||
const tableRows = slot.querySelectorAll('.consistency-row');
|
||
const resetBtn = document.getElementById('scatterReset');
|
||
tableRows.forEach(tr => {
|
||
tr.addEventListener('click', () => {
|
||
const uid = tr.dataset.uid;
|
||
if (consistencySelectedUid === uid) {
|
||
consistencySelectedUid = null;
|
||
tableRows.forEach(r => r.classList.remove('active'));
|
||
resetBtn.style.visibility = 'hidden';
|
||
} else {
|
||
consistencySelectedUid = uid;
|
||
tableRows.forEach(r => r.classList.toggle('active', r.dataset.uid === uid));
|
||
resetBtn.style.visibility = 'visible';
|
||
}
|
||
applyScatterFocus();
|
||
});
|
||
});
|
||
resetBtn.addEventListener('click', () => {
|
||
consistencySelectedUid = null;
|
||
tableRows.forEach(r => r.classList.remove('active'));
|
||
resetBtn.style.visibility = 'hidden';
|
||
applyScatterFocus();
|
||
});
|
||
|
||
if (data.length === 0 || typeof Chart === 'undefined') return;
|
||
|
||
const maxGames = Math.max(...data.map(r => r.games));
|
||
const points = data.map(r => ({
|
||
x: r.kd,
|
||
y: r.win_rate,
|
||
r: 4 + (r.games / maxGames) * 9,
|
||
_meta: r,
|
||
}));
|
||
|
||
const ctx = document.getElementById('consistencyScatterCanvas').getContext('2d');
|
||
consistencyScatter = new Chart(ctx, {
|
||
type: 'bubble',
|
||
data: {
|
||
datasets: [{
|
||
label: 'Players',
|
||
data: points,
|
||
backgroundColor: points.map(() => 'rgba(144, 238, 144, 0.55)'),
|
||
borderColor: points.map(() => 'rgba(144, 238, 144, 0.95)'),
|
||
borderWidth: 1,
|
||
hoverBorderWidth: 2,
|
||
}],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: { duration: 400 },
|
||
onClick: (evt, elements) => {
|
||
if (!elements.length) return;
|
||
const uid = points[elements[0].index]._meta.uid;
|
||
const tr = slot.querySelector(`.consistency-row[data-uid="${CSS.escape(uid)}"]`);
|
||
if (tr) tr.click();
|
||
},
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
backgroundColor: 'rgba(15, 20, 15, 0.95)',
|
||
borderColor: 'rgba(144, 238, 144, 0.3)',
|
||
borderWidth: 1,
|
||
titleColor: '#F5F5DC',
|
||
bodyColor: '#fff',
|
||
padding: 10,
|
||
callbacks: {
|
||
title: (items) => points[items[0].dataIndex]._meta.nick,
|
||
label: (item) => {
|
||
const m = points[item.dataIndex]._meta;
|
||
return [`Games: ${m.games}`, `K/D: ${m.kd.toFixed(2)}`, `WR: ${m.win_rate}% (${m.wins}W / ${m.losses}L)`];
|
||
},
|
||
},
|
||
},
|
||
},
|
||
layout: { padding: 18 },
|
||
scales: {
|
||
x: {
|
||
title: { display: true, text: 'K/D', color: 'rgba(245,245,220,0.7)' },
|
||
min: 0,
|
||
suggestedMax: Math.max(1, Math.ceil(Math.max(...points.map(p => p.x)) * 1.05 * 10) / 10),
|
||
ticks: { color: 'rgba(255,255,255,0.5)' },
|
||
grid: { color: 'rgba(144, 238, 144, 0.08)' },
|
||
},
|
||
y: {
|
||
title: { display: true, text: 'Win Rate %', color: 'rgba(245,245,220,0.7)' },
|
||
min: -5, max: 105,
|
||
ticks: { color: 'rgba(255,255,255,0.5)', stepSize: 25, callback: v => (v >= 0 && v <= 100) ? v + '%' : '' },
|
||
grid: { color: 'rgba(144, 238, 144, 0.08)' },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function applyScatterFocus() {
|
||
if (!consistencyScatter) return;
|
||
const points = consistencyScatter.data.datasets[0].data;
|
||
const bg = points.map(p => {
|
||
if (!consistencySelectedUid) return 'rgba(144, 238, 144, 0.55)';
|
||
return p._meta.uid === consistencySelectedUid ? 'rgba(245, 193, 108, 0.85)' : 'rgba(144, 238, 144, 0.08)';
|
||
});
|
||
const bd = points.map(p => {
|
||
if (!consistencySelectedUid) return 'rgba(144, 238, 144, 0.95)';
|
||
return p._meta.uid === consistencySelectedUid ? 'rgba(245, 193, 108, 1)' : 'rgba(144, 238, 144, 0.15)';
|
||
});
|
||
consistencyScatter.data.datasets[0].backgroundColor = bg;
|
||
consistencyScatter.data.datasets[0].borderColor = bd;
|
||
consistencyScatter.update('none');
|
||
}
|
||
|
||
function renderTime(slot, data) {
|
||
// Accepts either the legacy shape (hour-keyed object) or the new
|
||
// shape { hourly: {...}, daily: [{day, count}] }.
|
||
const hourly = (data && data.hourly) ? data.hourly : data;
|
||
const daily = (data && data.daily) ? data.daily : null;
|
||
|
||
// SQB only runs in NA 01-07 UTC and EU 14-22 UTC. Render those 16
|
||
// hours in chronological order with NA first (matches BOT/botscript.py).
|
||
const slots = [
|
||
{ region: 'na', hours: [1,2,3,4,5,6,7] },
|
||
{ region: 'eu', hours: [14,15,16,17,18,19,20,21,22] },
|
||
];
|
||
const out = [];
|
||
for (const { region, hours } of slots) {
|
||
const label = region === 'eu' ? T('analytics.euTimeslot') : T('analytics.naTimeslot');
|
||
out.push(`<tr class="section-divider-row"><td colspan="4" class="section-divider">${escapeHtml(label)}</td></tr>`);
|
||
for (const h of hours) {
|
||
const s = (hourly && hourly[h]) || { wins: 0, losses: 0, win_rate: 0 };
|
||
const empty = (s.wins + s.losses) === 0;
|
||
const rowStyle = empty ? ' style="opacity:0.35"' : '';
|
||
out.push(`
|
||
<tr${rowStyle}>
|
||
<td class="time-cell">
|
||
<span class="time-label">${String(h).padStart(2, '0')}:00 UTC</span>
|
||
<div class="bar-track time-bar"><div class="bar-fill" style="width:${Math.min(100, s.win_rate)}%"></div></div>
|
||
</td>
|
||
<td style="color:#90EE90">${s.wins}</td>
|
||
<td style="color:#ff9b9b">${s.losses}</td>
|
||
<td>${empty ? '—' : s.win_rate + '%'}</td>
|
||
</tr>`);
|
||
}
|
||
}
|
||
slot.innerHTML = `
|
||
<table class="analytics-table time-table">
|
||
<thead><tr>
|
||
<th>${escapeHtml(T('analytics.colHour'))}</th>
|
||
<th>${escapeHtml(T('analytics.colWins'))}</th>
|
||
<th>${escapeHtml(T('analytics.colLosses'))}</th>
|
||
<th>${escapeHtml(T('analytics.colWinRate'))}</th>
|
||
</tr></thead>
|
||
<tbody>${out.join('')}</tbody>
|
||
</table>
|
||
${daily ? `
|
||
<div class="timeline-card" style="margin-top:1rem;">
|
||
<div class="timeline-card-header">
|
||
<span class="timeline-card-title">Activity calendar</span>
|
||
<span class="timeline-card-meta">games per day · UTC</span>
|
||
</div>
|
||
<div class="calendar-wrap"><div class="activity-calendar-mount"></div></div>
|
||
</div>` : ''}`;
|
||
if (daily) {
|
||
renderActivityCalendar(slot.querySelector('.activity-calendar-mount'), daily);
|
||
}
|
||
}
|
||
|
||
function renderMatchup(slot, data) {
|
||
const makeTable = (entries) => {
|
||
if (!entries || !entries.length) return `<div class="empty-state" style="padding:2rem;">—</div>`;
|
||
return `
|
||
<table class="analytics-table matchup-table">
|
||
<thead><tr>
|
||
<th data-sort-type="text">${escapeHtml(T('analytics.colSquadron'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colWins'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colLosses'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colTotal'))}</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${entries.map(e => `
|
||
<tr>
|
||
<td data-sort-value="${escapeHtml((e.opponent || '').toLowerCase())}"><a href="/squadrons/${encodeURIComponent(e.opponent)}" class="sq-tag" style="text-decoration:none;">${escapeHtml(e.opponent)}</a></td>
|
||
<td data-sort-value="${e.wins}" style="color:#90EE90">${e.wins}</td>
|
||
<td data-sort-value="${e.losses}" style="color:#ff9b9b">${e.losses}</td>
|
||
<td data-sort-value="${e.total}">${e.total}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>`;
|
||
};
|
||
slot.innerHTML = `
|
||
<div class="matchup-grid">
|
||
<div class="matchup-col">
|
||
<h3><i class="fas fa-trophy" style="color:#90EE90; margin-right:0.5rem;"></i>${escapeHtml(T('analytics.matchupsWonHeader'))}</h3>
|
||
${makeTable(data.won_against)}
|
||
</div>
|
||
<div class="matchup-col">
|
||
<h3><i class="fas fa-skull" style="color:#ff9b9b; margin-right:0.5rem;"></i>${escapeHtml(T('analytics.matchupsLostHeader'))}</h3>
|
||
${makeTable(data.lost_against)}
|
||
</div>
|
||
</div>
|
||
<div style="text-align:center; margin-top:1rem; color:rgba(144,238,144,0.5); font-size:0.85rem;">
|
||
${data.total_opponents} ${escapeHtml(T('analytics.uniqueOpponents'))}
|
||
</div>`;
|
||
slot.querySelectorAll('.matchup-table').forEach(makeTableSortable);
|
||
}
|
||
|
||
// Type abbreviations the backend uses, in the same display order as
|
||
// /comp on Discord. Vehicle classes the bot recognises:
|
||
// F=Fighter, B=Bomber, H=Helicopter, L=Light, T=Tank, AA=SPAA, ?=unknown.
|
||
const COMP_TYPE_ORDER = ['F', 'B', 'H', 'L', 'T', 'AA', '?'];
|
||
const COMP_TYPE_COLORS = {
|
||
F: '#9DD9F3', B: '#C7B6E5', H: '#F5C16C',
|
||
L: '#A8E6CF', T: '#90EE90', AA: '#F58C8C', '?': '#888',
|
||
};
|
||
|
||
function tVeh(v) {
|
||
// Translate via the WT internal id when we have it; fall back to
|
||
// whatever display string the API gave us. The vehicle-i18n module
|
||
// has already pre-loaded the map by the time this runs in the
|
||
// common case, but we tolerate a missing map gracefully.
|
||
if (window.vehicleI18n && v && v.vehicle_internal) {
|
||
return window.vehicleI18n.translate(v.vehicle_internal, v.vehicle);
|
||
}
|
||
return v && v.vehicle ? v.vehicle : '';
|
||
}
|
||
|
||
function renderTypeBadges(types) {
|
||
// Compact badges like [3 F] [4 T] [1 AA] in canonical /comp order.
|
||
const out = [];
|
||
for (const code of COMP_TYPE_ORDER) {
|
||
const n = (types && types[code]) || 0;
|
||
if (n <= 0) continue;
|
||
const color = COMP_TYPE_COLORS[code] || '#fff';
|
||
out.push(`<span class="comp-type-badge" style="color:${color}; border-color:${color}55;">${n}<span class="comp-type-badge-letter">${escapeHtml(code)}</span></span>`);
|
||
}
|
||
return `<span class="comp-type-badges">${out.join('')}</span>`;
|
||
}
|
||
|
||
function renderCompLineup(vehicles) {
|
||
// Group same-type vehicles into one row each so the lineup reads
|
||
// like a roster sheet: "Fighters: 2× F-16C, BV-238", "Tanks: 2× T-80BVM …".
|
||
const byType = {};
|
||
for (const v of vehicles) {
|
||
const t = v.type || '?';
|
||
if (!byType[t]) byType[t] = [];
|
||
byType[t].push(v);
|
||
}
|
||
const TYPE_LABEL = {
|
||
F: T('analytics.compTypeFighters'),
|
||
B: T('analytics.compTypeBombers'),
|
||
H: T('analytics.compTypeHelicopters'),
|
||
L: T('analytics.compTypeLight'),
|
||
T: T('analytics.compTypeTanks'),
|
||
AA: T('analytics.compTypeSPAA'),
|
||
'?': T('analytics.compTypeUnknown'),
|
||
};
|
||
const lines = [];
|
||
for (const code of COMP_TYPE_ORDER) {
|
||
const list = byType[code];
|
||
if (!list || !list.length) continue;
|
||
const color = COMP_TYPE_COLORS[code] || '#fff';
|
||
const items = list
|
||
.slice()
|
||
.sort((a, b) => (b.count - a.count) || tVeh(a).localeCompare(tVeh(b)))
|
||
.map(v => v.count > 1 ? `${v.count}× ${escapeHtml(tVeh(v))}` : escapeHtml(tVeh(v)))
|
||
.join(', ');
|
||
lines.push(`<div class="comp-lineup-row">
|
||
<span class="comp-lineup-label" style="color:${color};">${escapeHtml(TYPE_LABEL[code] || code)}</span>
|
||
<span class="comp-lineup-items">${items}</span>
|
||
</div>`);
|
||
}
|
||
return `<div class="comp-lineup">${lines.join('')}</div>`;
|
||
}
|
||
|
||
function compMatchesQuery(comp, query) {
|
||
// query: {
|
||
// notation: "3F 4T 1AA", // exact match against comp.notation
|
||
// types: { F:3, T:4, ... }, // each comp.types[code] >= n
|
||
// vehicles: [internal, ...] // multiset check: comp must contain
|
||
// // each requested id at least the
|
||
// // number of times it appears in
|
||
// // the query list.
|
||
// }
|
||
// Empty query → matches everything.
|
||
if (!query) return true;
|
||
if (query.notation && comp.notation !== query.notation) return false;
|
||
if (query.types) {
|
||
for (const [code, n] of Object.entries(query.types)) {
|
||
if (!n) continue;
|
||
if ((comp.types && comp.types[code] || 0) < n) return false;
|
||
}
|
||
}
|
||
if (query.vehicles && query.vehicles.length) {
|
||
const compCounts = {};
|
||
for (const v of comp.vehicles) compCounts[v.vehicle_internal] = (compCounts[v.vehicle_internal] || 0) + v.count;
|
||
const requested = {};
|
||
for (const id of query.vehicles) requested[id] = (requested[id] || 0) + 1;
|
||
for (const [id, n] of Object.entries(requested)) {
|
||
if ((compCounts[id] || 0) < n) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
let _compsState = null; // Holds last-rendered comps for re-filtering by search bar.
|
||
|
||
// If the i18n map shows up after the comps view first painted (cold
|
||
// load, no localStorage cache yet), repaint so the names switch from
|
||
// the English fallback to the user's language without a full reload.
|
||
document.addEventListener('vehicle-i18n-ready', () => {
|
||
if (panelMode === 'squadron' && activeView === 'comps' && _compsState) {
|
||
const slot = document.getElementById('analyticsViewSlot');
|
||
if (slot) renderComps(slot, {
|
||
top_vehicles: _compsState.topVehicles,
|
||
compositions: _compsState.compositions,
|
||
notations: _compsState.notations,
|
||
total_sessions: _compsState.totalSessions || 0,
|
||
min_size: _compsState.minSize || 8,
|
||
});
|
||
}
|
||
});
|
||
|
||
function renderComps(slot, data) {
|
||
const topVehicles = data.top_vehicles || [];
|
||
const compositions = data.compositions || [];
|
||
const notations = data.notations || [];
|
||
const totalSessions = data.total_sessions || 0;
|
||
const minSize = data.min_size || 8;
|
||
|
||
_compsState = { compositions, notations, topVehicles, totalSessions, minSize };
|
||
|
||
const maxSpawns = Math.max(1, ...topVehicles.map(v => v.spawns));
|
||
const maxKd = Math.max(1, ...topVehicles.map(v => v.kd));
|
||
|
||
const vehRows = topVehicles.map((v, i) => `
|
||
<tr>
|
||
<td data-sortable="false">${i + 1}</td>
|
||
<td data-sort-value="${escapeHtml((tVeh(v) || '').toLowerCase())}">
|
||
<a href="#" class="vehicle-name comp-veh-link" data-vehicle="${escapeHtml(v.vehicle)}" data-vehicle-internal="${escapeHtml(v.vehicle_internal || '')}" style="color:#A8E6CF; text-decoration:none; cursor:pointer;">${escapeHtml(tVeh(v))}</a>
|
||
<span class="comp-type-tag" style="color:${COMP_TYPE_COLORS[v.type] || '#888'};">${escapeHtml(v.type || '?')}</span>
|
||
</td>
|
||
<td data-sort-value="${v.spawns}" style="white-space:nowrap;">
|
||
${v.spawns}<span class="micro-bar"><span style="width:${Math.min(100, (v.spawns / maxSpawns) * 100)}%"></span></span>
|
||
</td>
|
||
<td data-sort-value="${v.sessions}">${v.sessions}</td>
|
||
<td data-sort-value="${v.share_pct}">${v.share_pct}%</td>
|
||
<td data-sort-value="${v.kd}" style="white-space:nowrap;">
|
||
${v.kd.toFixed(2)}<span class="micro-bar kd"><span style="width:${Math.min(100, (v.kd / maxKd) * 100)}%"></span></span>
|
||
</td>
|
||
<td data-sort-value="${v.win_rate}" style="white-space:nowrap;">
|
||
${v.win_rate}%<span class="micro-bar"><span style="width:${Math.min(100, v.win_rate)}%"></span></span>
|
||
</td>
|
||
</tr>`).join('');
|
||
|
||
// Search bar: presets are the squadron's actual notations sorted by
|
||
// match count (top 25), plus a custom builder that lets the user
|
||
// add type counts and specific vehicles.
|
||
const presetOptions = notations.slice(0, 25).map(n =>
|
||
`<option value="${escapeHtml(n.notation)}">${escapeHtml(n.notation)} — ${n.games} ${escapeHtml(T('analytics.compSearchGamesShort'))}</option>`
|
||
).join('');
|
||
|
||
// Tooltip names — same as the lineup labels except AA uses the
|
||
// expanded "Anti-Aircraft/SPAA" form so users seeing the chip
|
||
// for the first time know what the abbreviation stands for.
|
||
const TYPE_FULL_NAME = {
|
||
F: T('analytics.compTypeFighters'),
|
||
B: T('analytics.compTypeBombers'),
|
||
H: T('analytics.compTypeHelicopters'),
|
||
L: T('analytics.compTypeLight'),
|
||
T: T('analytics.compTypeTanks'),
|
||
AA: T('analytics.compTypeSPAATooltip'),
|
||
};
|
||
const customTypeRows = COMP_TYPE_ORDER.filter(c => c !== '?').map(code => {
|
||
const color = COMP_TYPE_COLORS[code] || '#fff';
|
||
const fullName = TYPE_FULL_NAME[code] || code;
|
||
return `<label class="comp-custom-type" title="${escapeHtml(fullName)}">
|
||
<span class="comp-type-badge" style="color:${color}; border-color:${color}55;">
|
||
<span class="comp-type-badge-letter">${escapeHtml(code)}</span>
|
||
</span>
|
||
<input type="number" min="0" max="16" step="1" value="0" data-comp-type="${escapeHtml(code)}" aria-label="${escapeHtml(fullName)}" />
|
||
</label>`;
|
||
}).join('');
|
||
|
||
const searchSection = `
|
||
<div class="comp-search">
|
||
<div class="comp-search-row">
|
||
<label class="comp-search-label" for="compPresetSelect">${escapeHtml(T('analytics.compSearchPresetLabel'))}</label>
|
||
<select id="compPresetSelect" class="comp-search-select">
|
||
<option value="">${escapeHtml(T('analytics.compSearchPresetAll'))}</option>
|
||
${presetOptions}
|
||
</select>
|
||
<span class="comp-search-hint">${escapeHtml(T('analytics.compSearchPresetHint'))}</span>
|
||
</div>
|
||
<div class="comp-search-row">
|
||
<label class="comp-search-label">${escapeHtml(T('analytics.compSearchTypesLabel'))}</label>
|
||
<div class="comp-custom-types">${customTypeRows}</div>
|
||
<span class="comp-search-hint">${escapeHtml(T('analytics.compTypeCapsHint'))}</span>
|
||
</div>
|
||
<div class="comp-search-row comp-search-row-block">
|
||
<label class="comp-search-label">${escapeHtml(T('analytics.compSearchRefineLabel'))}</label>
|
||
<div id="compRefineContainer" class="comp-refine-container"></div>
|
||
</div>
|
||
<div class="comp-search-row" style="justify-content:flex-end;">
|
||
<button id="compSearchReset" class="comp-search-btn comp-search-btn-ghost">${escapeHtml(T('analytics.compSearchReset'))}</button>
|
||
<button id="compSearchApply" class="comp-search-btn comp-search-btn-primary">${escapeHtml(T('analytics.compSearchApply'))}</button>
|
||
</div>
|
||
<div class="comp-search-meta" id="compSearchMeta"></div>
|
||
</div>`;
|
||
|
||
const compSection = compositions.length ? `
|
||
<div class="comps-section">
|
||
<div class="comps-section-header">
|
||
<span class="comps-section-title">${escapeHtml(T('analytics.compCompositionsTitle'))}</span>
|
||
<span class="comps-section-meta">${escapeHtml(T('analytics.compCompositionsMeta', { min: minSize }))}</span>
|
||
</div>
|
||
${searchSection}
|
||
<div class="comps-table-wrap" id="compsTableWrap"></div>
|
||
</div>` : `
|
||
<div class="comps-section">
|
||
<div class="comps-section-header">
|
||
<span class="comps-section-title">${escapeHtml(T('analytics.compCompositionsTitle'))}</span>
|
||
</div>
|
||
<div class="empty-state" style="padding:1.5rem;">${escapeHtml(T('analytics.compNoRepeats'))}</div>
|
||
</div>`;
|
||
|
||
slot.innerHTML = `
|
||
<div class="comps-layout">
|
||
<div class="comps-section">
|
||
<div class="comps-section-header">
|
||
<span class="comps-section-title">${escapeHtml(T('analytics.compTopVehiclesTitle'))}</span>
|
||
<span class="comps-section-meta">${totalSessions} ${escapeHtml(T('analytics.compMatchesAnalyzed'))}</span>
|
||
</div>
|
||
<div class="comps-table-wrap">
|
||
<table class="analytics-table comps-veh-table">
|
||
<thead><tr>
|
||
<th data-sortable="false">#</th>
|
||
<th data-sort-type="text">${escapeHtml(T('analytics.compColVehicle'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.compColSpawns'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.compColMatches'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.compColShare'))}</th>
|
||
<th data-sort-type="num">K/D</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colWinRate'))}</th>
|
||
</tr></thead>
|
||
<tbody>${vehRows}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
${compSection}
|
||
</div>`;
|
||
|
||
slot.querySelectorAll('.comps-veh-table').forEach(makeTableSortable);
|
||
|
||
slot.querySelectorAll('.comp-veh-link').forEach(el => {
|
||
el.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const internal = (el.dataset.vehicleInternal || '').toLowerCase() || null;
|
||
if (!internal) return;
|
||
// Resolve the localized display from the translation map
|
||
// so the inline header matches what the search dropdown
|
||
// would have shown.
|
||
const display = vehicleDisplay(internal) || el.textContent || internal;
|
||
setMode('vehicle');
|
||
pickVehicle(display, internal);
|
||
});
|
||
});
|
||
|
||
if (compositions.length) {
|
||
renderCompsFiltered(null);
|
||
wireCompSearch();
|
||
}
|
||
}
|
||
|
||
function renderCompsFiltered(query) {
|
||
const wrap = document.getElementById('compsTableWrap');
|
||
const meta = document.getElementById('compSearchMeta');
|
||
if (!wrap || !_compsState) return;
|
||
|
||
const filtered = _compsState.compositions.filter(c => compMatchesQuery(c, query));
|
||
if (meta) {
|
||
meta.textContent = T('analytics.compSearchMatches')
|
||
.replace('{shown}', filtered.length)
|
||
.replace('{total}', _compsState.compositions.length);
|
||
}
|
||
|
||
if (!filtered.length) {
|
||
wrap.innerHTML = `<div class="empty-state" style="padding:1.5rem;">${escapeHtml(T('analytics.compSearchNoMatches'))}</div>`;
|
||
return;
|
||
}
|
||
|
||
const compRows = filtered.map((c, i) => `
|
||
<tr>
|
||
<td data-sortable="false">${i + 1}</td>
|
||
<td data-sort-value="${escapeHtml(c.notation)}">${renderTypeBadges(c.types)}</td>
|
||
<td>${renderCompLineup(c.vehicles)}</td>
|
||
<td data-sort-value="${c.games}">${c.games}</td>
|
||
<td data-sort-value="${c.wins}" style="color:#90EE90">${c.wins}</td>
|
||
<td data-sort-value="${c.losses}" style="color:#ff9b9b">${c.losses}</td>
|
||
<td data-sort-value="${c.win_rate}" style="white-space:nowrap;">
|
||
${c.win_rate}%<span class="micro-bar"><span style="width:${Math.min(100, c.win_rate)}%"></span></span>
|
||
</td>
|
||
</tr>`).join('');
|
||
|
||
wrap.innerHTML = `
|
||
<table class="analytics-table comps-comp-table">
|
||
<thead><tr>
|
||
<th data-sortable="false">#</th>
|
||
<th data-sortable="false">${escapeHtml(T('analytics.compColTypes'))}</th>
|
||
<th data-sortable="false">${escapeHtml(T('analytics.compColLineup'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colGames'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colWins'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colLosses'))}</th>
|
||
<th data-sort-type="num">${escapeHtml(T('analytics.colWinRate'))}</th>
|
||
</tr></thead>
|
||
<tbody>${compRows}</tbody>
|
||
</table>`;
|
||
makeTableSortable(wrap.querySelector('.comps-comp-table'));
|
||
}
|
||
|
||
function parseNotation(n) {
|
||
// "3F 4T 1AA" → { F:3, T:4, AA:1 }
|
||
const out = {};
|
||
if (!n) return out;
|
||
const re = /(\d+)\s*(AA|F|B|H|L|T|\?)/gi;
|
||
let m;
|
||
while ((m = re.exec(n)) !== null) {
|
||
const code = m[2].toUpperCase();
|
||
out[code] = (out[code] || 0) + parseInt(m[1]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function readTypeCounts() {
|
||
const counts = {};
|
||
document.querySelectorAll('.comp-custom-type input[type="number"]').forEach(input => {
|
||
const n = parseInt(input.value || '0');
|
||
const code = input.dataset.compType;
|
||
if (n > 0 && code) counts[code] = n;
|
||
});
|
||
return counts;
|
||
}
|
||
|
||
// SQB lineups are capped at 8 vehicles per team with at most 4 aviation
|
||
// (fighters + bombers + helicopters). Clamp the just-changed input back
|
||
// to whatever value still satisfies both rules so the user can't build
|
||
// a search that no real comp could ever match.
|
||
const COMP_AVIATION_CODES = new Set(['F', 'B', 'H']);
|
||
const COMP_HARD_CAP = 8;
|
||
const COMP_AVIATION_CAP = 4;
|
||
|
||
function clampTypeInput(changedInput) {
|
||
const code = changedInput.dataset.compType;
|
||
if (!code) return;
|
||
let aviationOther = 0, totalOther = 0;
|
||
document.querySelectorAll('.comp-custom-type input[type="number"]').forEach(input => {
|
||
if (input === changedInput) return;
|
||
const n = Math.max(0, parseInt(input.value || '0'));
|
||
totalOther += n;
|
||
if (COMP_AVIATION_CODES.has(input.dataset.compType)) aviationOther += n;
|
||
});
|
||
const aviationCap = COMP_AVIATION_CODES.has(code)
|
||
? Math.max(0, COMP_AVIATION_CAP - aviationOther)
|
||
: Infinity;
|
||
const totalCap = Math.max(0, COMP_HARD_CAP - totalOther);
|
||
const cap = Math.min(aviationCap, totalCap);
|
||
const wanted = Math.max(0, parseInt(changedInput.value || '0') || 0);
|
||
if (wanted > cap) changedInput.value = cap;
|
||
else if (changedInput.value !== String(wanted)) changedInput.value = wanted;
|
||
}
|
||
|
||
function readCustomQuery() {
|
||
const types = readTypeCounts();
|
||
const vehicles = [];
|
||
document.querySelectorAll('.comp-refine-select').forEach(sel => {
|
||
const v = sel.dataset.value;
|
||
if (v) vehicles.push(v);
|
||
});
|
||
const empty = !Object.keys(types).length && !vehicles.length;
|
||
return empty ? null : { types, vehicles };
|
||
}
|
||
|
||
// Build one row per type (in canonical order) with `count` vehicle
|
||
// slots. Each slot is a dropdown of vehicles of that type, defaulting
|
||
// to "Any <type>" so the user can leave slots loose. We pull the
|
||
// vehicle list from the squadron's actual top vehicles — listing
|
||
// every WT vehicle would dwarf the page and most wouldn't match
|
||
// anyway.
|
||
function rebuildRefineSection() {
|
||
const container = document.getElementById('compRefineContainer');
|
||
if (!container) return;
|
||
const counts = readTypeCounts();
|
||
|
||
if (!Object.keys(counts).length) {
|
||
container.innerHTML = `<span class="comp-refine-hint">${escapeHtml(T('analytics.compRefineHint'))}</span>`;
|
||
return;
|
||
}
|
||
|
||
const TYPE_LABEL = {
|
||
F: T('analytics.compTypeFighters'),
|
||
B: T('analytics.compTypeBombers'),
|
||
H: T('analytics.compTypeHelicopters'),
|
||
L: T('analytics.compTypeLight'),
|
||
T: T('analytics.compTypeTanks'),
|
||
AA: T('analytics.compTypeSPAA'),
|
||
};
|
||
|
||
// Gather every vehicle that appears either in the top-vehicles
|
||
// table or anywhere in the displayed comps. Top vehicles alone
|
||
// (capped at 50 by spawn count) miss long-tail picks that still
|
||
// show up in a low-game comp — and the user expects to be able
|
||
// to pick anything they can see in the comp list.
|
||
const vehiclesByType = {};
|
||
const seenInternal = new Set();
|
||
const addVehicle = (v) => {
|
||
if (!v || !v.vehicle_internal || seenInternal.has(v.vehicle_internal)) return;
|
||
seenInternal.add(v.vehicle_internal);
|
||
if (!vehiclesByType[v.type]) vehiclesByType[v.type] = [];
|
||
vehiclesByType[v.type].push(v);
|
||
};
|
||
for (const v of (_compsState ? _compsState.topVehicles : [])) addVehicle(v);
|
||
for (const c of (_compsState ? _compsState.compositions : [])) {
|
||
for (const v of c.vehicles) addVehicle(v);
|
||
}
|
||
|
||
const rows = [];
|
||
for (const code of COMP_TYPE_ORDER) {
|
||
const n = counts[code];
|
||
if (!n) continue;
|
||
const color = COMP_TYPE_COLORS[code] || '#fff';
|
||
const list = (vehiclesByType[code] || []).slice()
|
||
.sort((a, b) => tVeh(a).localeCompare(tVeh(b)));
|
||
const anyLabel = T('analytics.compRefineAny').replace('{type}', TYPE_LABEL[code] || code);
|
||
// Custom dropdown — matches the site's existing search-drop
|
||
// pattern. Native <select> won't pick up the skyquake font
|
||
// for option text in Firefox / on the OS-rendered dropdown.
|
||
const optsHtml = `<div class="comp-refine-select-opt is-placeholder is-selected" data-value="">${escapeHtml(anyLabel)}</div>` +
|
||
list.map(v => `<div class="comp-refine-select-opt" data-value="${escapeHtml(v.vehicle_internal)}" data-label="${escapeHtml(tVeh(v))}">${escapeHtml(tVeh(v))}</div>`).join('');
|
||
const slots = Array.from({ length: n }, () => `
|
||
<div class="comp-refine-select" data-comp-type="${escapeHtml(code)}" data-value="" data-placeholder="${escapeHtml(anyLabel)}">
|
||
<button type="button" class="comp-refine-select-btn">
|
||
<span class="comp-refine-select-btn-label is-placeholder">${escapeHtml(anyLabel)}</span>
|
||
<i class="fas fa-chevron-down"></i>
|
||
</button>
|
||
<div class="comp-refine-select-drop">${optsHtml}</div>
|
||
</div>`).join('');
|
||
rows.push(`<div class="comp-refine-row">
|
||
<span class="comp-refine-label" style="color:${color};">${escapeHtml(TYPE_LABEL[code] || code)} (${n})</span>
|
||
<div class="comp-refine-slots">${slots}</div>
|
||
</div>`);
|
||
}
|
||
container.innerHTML = rows.join('');
|
||
wireCustomDropdowns(container);
|
||
}
|
||
|
||
// Single document-level handler closes any open custom dropdown when
|
||
// the click lands outside it. Installed once on first refine render.
|
||
let _customDropdownOutsideWired = false;
|
||
function ensureCustomDropdownOutsideHandler() {
|
||
if (_customDropdownOutsideWired) return;
|
||
_customDropdownOutsideWired = true;
|
||
document.addEventListener('click', (e) => {
|
||
document.querySelectorAll('.comp-refine-select.is-open').forEach(el => {
|
||
if (!el.contains(e.target)) el.classList.remove('is-open');
|
||
});
|
||
});
|
||
}
|
||
|
||
function wireCustomDropdowns(root) {
|
||
ensureCustomDropdownOutsideHandler();
|
||
root.querySelectorAll('.comp-refine-select').forEach(wrap => {
|
||
const btn = wrap.querySelector('.comp-refine-select-btn');
|
||
const labelEl = wrap.querySelector('.comp-refine-select-btn-label');
|
||
const drop = wrap.querySelector('.comp-refine-select-drop');
|
||
if (!btn || !labelEl || !drop) return;
|
||
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const wasOpen = wrap.classList.contains('is-open');
|
||
// Close any other open dropdown so only one is visible at a time.
|
||
document.querySelectorAll('.comp-refine-select.is-open').forEach(el => el.classList.remove('is-open'));
|
||
if (!wasOpen) wrap.classList.add('is-open');
|
||
});
|
||
|
||
drop.addEventListener('click', (e) => {
|
||
const opt = e.target.closest('.comp-refine-select-opt');
|
||
if (!opt) return;
|
||
const value = opt.dataset.value || '';
|
||
const label = opt.dataset.label || opt.textContent || '';
|
||
wrap.dataset.value = value;
|
||
if (value) {
|
||
labelEl.textContent = label;
|
||
labelEl.classList.remove('is-placeholder');
|
||
} else {
|
||
labelEl.textContent = wrap.dataset.placeholder || '';
|
||
labelEl.classList.add('is-placeholder');
|
||
}
|
||
drop.querySelectorAll('.comp-refine-select-opt').forEach(o => o.classList.toggle('is-selected', o === opt));
|
||
wrap.classList.remove('is-open');
|
||
});
|
||
});
|
||
}
|
||
|
||
function wireCompSearch() {
|
||
const presetSel = document.getElementById('compPresetSelect');
|
||
const applyBtn = document.getElementById('compSearchApply');
|
||
const resetBtn = document.getElementById('compSearchReset');
|
||
|
||
if (presetSel) {
|
||
presetSel.addEventListener('change', () => {
|
||
if (!presetSel.value) { renderCompsFiltered(null); return; }
|
||
// Sync the type inputs and refine section so the user can
|
||
// see (and edit) what the preset translates to.
|
||
const types = parseNotation(presetSel.value);
|
||
document.querySelectorAll('.comp-custom-type input[type="number"]').forEach(input => {
|
||
input.value = types[input.dataset.compType] || 0;
|
||
});
|
||
rebuildRefineSection();
|
||
// Preset filters by exact notation, regardless of what the
|
||
// refine slots contain (those are previewed for editing).
|
||
renderCompsFiltered({ notation: presetSel.value });
|
||
});
|
||
}
|
||
// Type-count changes clamp to SQB lineup caps (8 total, 4 aviation)
|
||
// and regenerate the refine section.
|
||
document.querySelectorAll('.comp-custom-type input[type="number"]').forEach(input => {
|
||
input.addEventListener('input', () => {
|
||
clampTypeInput(input);
|
||
rebuildRefineSection();
|
||
});
|
||
});
|
||
if (applyBtn) applyBtn.addEventListener('click', () => {
|
||
if (presetSel) presetSel.value = '';
|
||
renderCompsFiltered(readCustomQuery());
|
||
});
|
||
if (resetBtn) resetBtn.addEventListener('click', () => {
|
||
if (presetSel) presetSel.value = '';
|
||
document.querySelectorAll('.comp-custom-type input[type="number"]').forEach(i => i.value = 0);
|
||
rebuildRefineSection();
|
||
renderCompsFiltered(null);
|
||
});
|
||
rebuildRefineSection();
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|