Files
SREBOT/web/views/analytics.ejs
T

3466 lines
179 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="<%= lang %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= 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>&nbsp;</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 0100 bar width based on
// the min and max of the input set, so tightly-bunched values (e.g. all
// K/D in 8.010.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>