Auto merge dev → main (#1258)
* meow * updated it * meow * add avg TTL footer to /comp + extend freshness window to 90m when servers slow --------- Co-authored-by: Clippii <clippii@protonmail.com>
This commit is contained in:
@@ -1,362 +0,0 @@
|
||||
// API Client with automatic token handling and caching
|
||||
class APIClient {
|
||||
constructor() {
|
||||
this.apiKey = null;
|
||||
this.apiKeyExpiresAt = 0;
|
||||
this.apiKeyPromise = null;
|
||||
this.baseURL = '';
|
||||
this.cache = new Map();
|
||||
this.pendingRequests = new Map();
|
||||
this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes client-side cache for general requests
|
||||
this.SEARCH_CACHE_DURATION = 15 * 60 * 1000; // 15 minutes for search results (longer because less frequently changing)
|
||||
}
|
||||
|
||||
// Initialize the API client by fetching the API key
|
||||
async init(forceRefresh = false) {
|
||||
if (!forceRefresh && this.apiKey && this.apiKeyExpiresAt && Date.now() < this.apiKeyExpiresAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.apiKeyPromise) {
|
||||
return this.apiKeyPromise;
|
||||
}
|
||||
|
||||
this.apiKeyPromise = (async () => {
|
||||
const response = await fetch('/api-key', {
|
||||
method: 'GET',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch API key: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.apiKey = data.apiKey;
|
||||
const expiresAt = Date.parse(data.expires);
|
||||
this.apiKeyExpiresAt = Number.isFinite(expiresAt) ? expiresAt : Date.now() + 24 * 60 * 60 * 1000;
|
||||
return this.apiKey;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await this.apiKeyPromise;
|
||||
} finally {
|
||||
this.apiKeyPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureValidApiKey() {
|
||||
if (!this.apiKey || !this.apiKeyExpiresAt || Date.now() >= this.apiKeyExpiresAt) {
|
||||
await this.init(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate signature for request (matches server-side algorithm)
|
||||
generateSignature(method, path, queryString, timestamp, apiSecret) {
|
||||
const data = `${method}${path}${queryString}`;
|
||||
// Match server-side simple hashing algorithm exactly
|
||||
let hash = 0;
|
||||
const combined = `${data}-${timestamp}-${apiSecret}`;
|
||||
for (let i = 0; i < combined.length; i++) {
|
||||
const char = combined.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
// Make authenticated API requests with caching and in-flight dedup
|
||||
async request(endpoint, options = {}) {
|
||||
const method = options.method || 'GET';
|
||||
const authEnabled = options.auth !== false;
|
||||
const cacheKey = `${method}:${endpoint}`;
|
||||
const useCache = options.cache !== false && method === 'GET'; // Only cache GET requests
|
||||
|
||||
// Determine cache duration based on endpoint type
|
||||
const isSearchRequest = endpoint.includes('/api/search/');
|
||||
const cacheDuration = isSearchRequest ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION;
|
||||
|
||||
// Check cache for GET requests
|
||||
if (useCache && this.cache.has(cacheKey)) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
const age = Date.now() - cached.timestamp;
|
||||
|
||||
if (age < cacheDuration) {
|
||||
console.log(`[API Client] Cache hit for ${endpoint} (${Math.floor(age / 1000)}s old)`);
|
||||
return cached.data;
|
||||
} else {
|
||||
console.log(`[API Client] Cache expired for ${endpoint}`);
|
||||
this.cache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// In-flight dedup: if an identical GET is already in flight, share its promise
|
||||
if (useCache && this.pendingRequests.has(cacheKey)) {
|
||||
console.log(`[API Client] In-flight dedup hit for ${endpoint}`);
|
||||
return this.pendingRequests.get(cacheKey);
|
||||
}
|
||||
|
||||
const promise = this._performRequest(endpoint, options, { method, authEnabled, cacheKey, useCache });
|
||||
|
||||
if (useCache) {
|
||||
this.pendingRequests.set(cacheKey, promise);
|
||||
promise.finally(() => {
|
||||
if (this.pendingRequests.get(cacheKey) === promise) {
|
||||
this.pendingRequests.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async _performRequest(endpoint, options, ctx) {
|
||||
const { method, authEnabled, cacheKey, useCache } = ctx;
|
||||
|
||||
console.log('[API Client] request called for:', endpoint);
|
||||
|
||||
// Auto-initialize if needed
|
||||
if (authEnabled) {
|
||||
await this.ensureValidApiKey();
|
||||
}
|
||||
|
||||
if (authEnabled && !this.apiKey) {
|
||||
throw new Error('Failed to get API key after initialization');
|
||||
}
|
||||
|
||||
const url = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
const timestamp = Date.now().toString();
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
if (authEnabled) {
|
||||
headers['X-API-Key'] = this.apiKey;
|
||||
headers['X-Request-Timestamp'] = timestamp;
|
||||
}
|
||||
|
||||
const finalOptions = {
|
||||
...options,
|
||||
method,
|
||||
headers
|
||||
};
|
||||
|
||||
console.log('[API Client] Making request to:', url);
|
||||
|
||||
let response = await fetch(url, finalOptions);
|
||||
console.log('[API Client] Response status:', response.status);
|
||||
|
||||
if (authEnabled && (response.status === 401 || response.status === 403)) {
|
||||
console.warn(`[API Client] Auth failed for ${endpoint}; refreshing API key and retrying once`);
|
||||
this.apiKey = null;
|
||||
this.apiKeyExpiresAt = 0;
|
||||
await this.init(true);
|
||||
finalOptions.headers['X-API-Key'] = this.apiKey;
|
||||
finalOptions.headers['X-Request-Timestamp'] = Date.now().toString();
|
||||
response = await fetch(url, finalOptions);
|
||||
console.log('[API Client] Retry response status:', response.status);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[API Client] Request failed:', response.status, errorText);
|
||||
throw new Error(`API request failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Cache successful GET responses
|
||||
if (useCache) {
|
||||
this.cache.set(cacheKey, {
|
||||
data: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
console.log(`[API Client] Cached response for ${endpoint}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Clear cache manually if needed
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
this.pendingRequests.clear();
|
||||
console.log('[API Client] Cache cleared');
|
||||
}
|
||||
|
||||
// Get cache stats
|
||||
getCacheStats() {
|
||||
const now = Date.now();
|
||||
const stats = {
|
||||
total: this.cache.size,
|
||||
fresh: 0,
|
||||
stale: 0,
|
||||
searchEntries: 0
|
||||
};
|
||||
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
const age = now - value.timestamp;
|
||||
const isSearch = key.includes('/api/search/');
|
||||
const maxAge = isSearch ? this.SEARCH_CACHE_DURATION : this.CACHE_DURATION;
|
||||
|
||||
if (isSearch) stats.searchEntries++;
|
||||
|
||||
if (age < maxAge) {
|
||||
stats.fresh++;
|
||||
} else {
|
||||
stats.stale++;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async searchPlayers(nickname) {
|
||||
return this.request(`/api/search/${encodeURIComponent(nickname)}`);
|
||||
}
|
||||
|
||||
async getPlayer(uid) {
|
||||
return this.request(`/api/player/${uid}`);
|
||||
}
|
||||
|
||||
async getPlayerGames(uid) {
|
||||
return this.request(`/api/player/${uid}/games`);
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
return this.request('/api/stats', { auth: false });
|
||||
}
|
||||
|
||||
// Leaderboard methods with stale-while-revalidate option
|
||||
async getPlayerLeaderboard(useStaleWhileRevalidate = true) {
|
||||
return this.requestWithSWR('/api/leaderboard/players', useStaleWhileRevalidate);
|
||||
}
|
||||
|
||||
async getVehicleLeaderboard(vehicle = null, useStaleWhileRevalidate = true) {
|
||||
const endpoint = vehicle
|
||||
? `/api/leaderboard/vehicles?vehicle=${encodeURIComponent(vehicle)}`
|
||||
: '/api/leaderboard/vehicles';
|
||||
return this.requestWithSWR(endpoint, useStaleWhileRevalidate);
|
||||
}
|
||||
|
||||
async getSquadronLeaderboard(useStaleWhileRevalidate = true) {
|
||||
return this.requestWithSWR('/api/leaderboard/squadrons', useStaleWhileRevalidate);
|
||||
}
|
||||
|
||||
async getSquadronDetails(squadronName, startDate, endDate) {
|
||||
let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}`;
|
||||
if (startDate || endDate) {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate.toISOString());
|
||||
if (endDate) params.append('end_date', endDate.toISOString());
|
||||
endpoint += '?' + params.toString();
|
||||
}
|
||||
return this.request(endpoint);
|
||||
}
|
||||
|
||||
async getSquadronGames(squadronName, startDate, endDate) {
|
||||
let endpoint = `/api/squadrons/${encodeURIComponent(squadronName)}/games`;
|
||||
if (startDate || endDate) {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('start_date', startDate.toISOString());
|
||||
if (endDate) params.append('end_date', endDate.toISOString());
|
||||
endpoint += '?' + params.toString();
|
||||
}
|
||||
return this.request(endpoint);
|
||||
}
|
||||
|
||||
async getLeaderboardStats(useStaleWhileRevalidate = true) {
|
||||
return this.requestWithSWR('/api/leaderboard/stats', useStaleWhileRevalidate);
|
||||
}
|
||||
|
||||
// Stale-While-Revalidate: Return cached data immediately, fetch fresh data in background
|
||||
async requestWithSWR(endpoint, useSWR = true) {
|
||||
const cacheKey = `GET:${endpoint}`;
|
||||
|
||||
if (useSWR && this.cache.has(cacheKey)) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
const age = Date.now() - cached.timestamp;
|
||||
|
||||
// If data is stale (older than cache duration) but not ancient (< 10 minutes)
|
||||
if (age >= this.CACHE_DURATION && age < 10 * 60 * 1000) {
|
||||
console.log(`[API Client] SWR: Returning stale data and revalidating ${endpoint}`);
|
||||
|
||||
// Fetch fresh data in background (don't await)
|
||||
this.request(endpoint).catch(err => {
|
||||
console.error('[API Client] SWR background fetch failed:', err);
|
||||
});
|
||||
|
||||
// Return stale data immediately
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal request (will use cache if fresh)
|
||||
return this.request(endpoint);
|
||||
}
|
||||
|
||||
async getVehicleIcons() {
|
||||
return this.request('/api/vehicle-icons');
|
||||
}
|
||||
|
||||
async getMatch(sessionId) {
|
||||
return this.request(`/api/match/${sessionId}`);
|
||||
}
|
||||
|
||||
async getMatchReplay(sessionId) {
|
||||
return this.request(`/api/match/${sessionId}/replay`);
|
||||
}
|
||||
|
||||
async getReplayCanvas(sessionId) {
|
||||
return this.request(`/api/match/${sessionId}/replay-canvas`);
|
||||
}
|
||||
|
||||
async searchGames(params = {}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.player) queryParams.append('player', params.player);
|
||||
if (params.map) queryParams.append('map', params.map);
|
||||
if (params.squadron) queryParams.append('squadron', params.squadron);
|
||||
if (params.time_from) queryParams.append('time_from', params.time_from);
|
||||
if (params.time_to) queryParams.append('time_to', params.time_to);
|
||||
if (params.limit) queryParams.append('limit', params.limit);
|
||||
return this.request(`/api/games/search?${queryParams}`);
|
||||
}
|
||||
|
||||
async getMaps() {
|
||||
return this.request('/api/maps');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Global API client instance
|
||||
window.apiClient = new APIClient();
|
||||
|
||||
// Pre-initialize when DOM is ready (but don't block)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize in background without blocking
|
||||
window.apiClient.init().catch(() => {
|
||||
// Silently fail, will retry on first request
|
||||
});
|
||||
});
|
||||
|
||||
// Quick init function that doesn't throw
|
||||
window.ensureAPIClient = async () => {
|
||||
try {
|
||||
if (!window.apiClient) {
|
||||
console.error('[API Client] window.apiClient is not defined');
|
||||
return false;
|
||||
}
|
||||
if (!window.apiClient.apiKey) {
|
||||
await window.apiClient.init();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[API Client] Error in ensureAPIClient:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Vendored
-20
File diff suppressed because one or more lines are too long
@@ -1,513 +0,0 @@
|
||||
/**
|
||||
* Date Filter Component
|
||||
* Provides a reusable date filtering UI using seasons data
|
||||
*/
|
||||
|
||||
class DateFilter {
|
||||
constructor(containerId, options = {}) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) {
|
||||
console.error(`[Date Filter] Container #${containerId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.options = {
|
||||
onFilterChange: options.onFilterChange || (() => {}),
|
||||
includeCustomRange: options.includeCustomRange !== false,
|
||||
includeCumulative: options.includeCumulative !== false,
|
||||
defaultFilter: options.defaultFilter || 'all-time',
|
||||
...options
|
||||
};
|
||||
|
||||
this.seasonsFilter = window.seasonsFilter;
|
||||
this.currentFilter = {
|
||||
type: 'all-time', // 'all-time', 'season', 'week', 'custom', 'cumulative'
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
season: null,
|
||||
week: null
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
t(key, params = {}) {
|
||||
let value = (window.__t && window.__t(key)) || key;
|
||||
Object.entries(params).forEach(([name, replacement]) => {
|
||||
value = value.replace(`{${name}}`, replacement);
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Load seasons data
|
||||
await this.seasonsFilter.loadSeasons();
|
||||
|
||||
// Render the UI
|
||||
this.render();
|
||||
|
||||
// Apply default filter
|
||||
if (this.options.defaultFilter !== 'all-time') {
|
||||
this.applyPresetFilter(this.options.defaultFilter);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const html = `
|
||||
<div class="date-filter-container">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<!-- Filter Type Tabs -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="filter-button filter-button-active" data-filter-type="all-time">
|
||||
<i class="fas fa-infinity mr-2"></i>${this.t('dateFilter.allTime')}
|
||||
</button>
|
||||
<button class="filter-button" data-filter-type="current-season">
|
||||
<i class="fas fa-calendar-alt mr-2"></i>${this.t('dateFilter.currentSeason')}
|
||||
</button>
|
||||
<button class="filter-button" data-filter-type="season">
|
||||
<i class="fas fa-calendar mr-2"></i>${this.t('dateFilter.bySeason')}
|
||||
</button>
|
||||
${this.options.includeCumulative ? `
|
||||
<button class="filter-button" data-filter-type="cumulative">
|
||||
<i class="fas fa-chart-line mr-2"></i>${this.t('dateFilter.cumulative')}
|
||||
</button>
|
||||
` : ''}
|
||||
${this.options.includeCustomRange ? `
|
||||
<button class="filter-button" data-filter-type="custom">
|
||||
<i class="fas fa-calendar-day mr-2"></i>${this.t('dateFilter.customRange')}
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Season Selection (hidden by default) -->
|
||||
<div id="season-selector" class="hidden space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
<i class="fas fa-trophy mr-1 text-primary-400"></i>${this.t('dateFilter.selectSeason')}
|
||||
</label>
|
||||
<select id="season-select" class="select-field">
|
||||
<option value="">${this.t('dateFilter.selectSeasonDots')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
<i class="fas fa-calendar-week mr-1 text-primary-400"></i>${this.t('dateFilter.selectWeek')}
|
||||
</label>
|
||||
<select id="week-select" class="select-field" disabled>
|
||||
<option value="all">${this.t('dateFilter.entireSeason')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="apply-season-filter" class="btn-primary w-full md:w-auto">
|
||||
<i class="fas fa-check mr-2"></i>${this.t('dateFilter.applyFilter')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cumulative Selection (hidden by default) -->
|
||||
<div id="cumulative-selector" class="hidden space-y-3">
|
||||
<div class="bg-primary-400/10 border border-primary-400/30 rounded-lg p-4 mb-3">
|
||||
<p class="text-sm text-white/80">
|
||||
<i class="fas fa-info-circle mr-2 text-primary-400"></i>
|
||||
${this.t('dateFilter.cumulativeHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">${this.t('dateFilter.season')}</label>
|
||||
<select id="cumulative-season-select" class="select-field">
|
||||
<option value="">${this.t('dateFilter.selectSeasonDots')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">${this.t('dateFilter.upToWeek')}</label>
|
||||
<select id="cumulative-week-select" class="select-field" disabled>
|
||||
<option value="">${this.t('dateFilter.selectWeekDots')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="apply-cumulative-filter" class="btn-primary w-full md:w-auto">
|
||||
<i class="fas fa-chart-line mr-2"></i>${this.t('dateFilter.applyCumulativeFilter')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom Range Selection (hidden by default) -->
|
||||
<div id="custom-range-selector" class="hidden space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
<i class="fas fa-calendar-plus mr-1 text-primary-400"></i>${this.t('dateFilter.startDate')}
|
||||
</label>
|
||||
<input type="date" id="start-date" class="input-field">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">
|
||||
<i class="fas fa-calendar-minus mr-1 text-primary-400"></i>${this.t('dateFilter.endDate')}
|
||||
</label>
|
||||
<input type="date" id="end-date" class="input-field">
|
||||
</div>
|
||||
</div>
|
||||
<button id="apply-custom-filter" class="btn-primary w-full md:w-auto">
|
||||
<i class="fas fa-filter mr-2"></i>${this.t('dateFilter.applyCustomRange')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current Filter Display -->
|
||||
<div id="current-filter-display" class="hidden">
|
||||
<div class="bg-primary-400/10 border border-primary-400/30 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-filter text-primary-400 text-xl"></i>
|
||||
<div>
|
||||
<p class="text-sm text-white/60">${this.t('dateFilter.activeFilter')}</p>
|
||||
<p id="filter-description" class="text-white font-semibold"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="clear-filter" class="text-red-400 hover:text-red-300 transition-colors">
|
||||
<i class="fas fa-times-circle mr-1"></i>${this.t('dateFilter.clear')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.container.innerHTML = html;
|
||||
this.attachEventListeners();
|
||||
this.populateSelectors();
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
// Filter type buttons
|
||||
const filterButtons = this.container.querySelectorAll('[data-filter-type]');
|
||||
filterButtons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const filterType = e.currentTarget.dataset.filterType;
|
||||
this.showFilterSection(filterType);
|
||||
});
|
||||
});
|
||||
|
||||
// Season selection
|
||||
const seasonSelect = this.container.querySelector('#season-select');
|
||||
if (seasonSelect) {
|
||||
seasonSelect.addEventListener('change', (e) => {
|
||||
const weekSelect = this.container.querySelector('#week-select');
|
||||
if (e.target.value) {
|
||||
this.seasonsFilter.populateWeekSelect(weekSelect, e.target.value, true);
|
||||
weekSelect.disabled = false;
|
||||
} else {
|
||||
weekSelect.disabled = true;
|
||||
weekSelect.innerHTML = `<option value="all">${this.t('dateFilter.entireSeason')}</option>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cumulative season selection
|
||||
const cumulativeSeasonSelect = this.container.querySelector('#cumulative-season-select');
|
||||
if (cumulativeSeasonSelect) {
|
||||
cumulativeSeasonSelect.addEventListener('change', (e) => {
|
||||
const weekSelect = this.container.querySelector('#cumulative-week-select');
|
||||
if (e.target.value) {
|
||||
this.seasonsFilter.populateWeekSelect(weekSelect, e.target.value, false);
|
||||
weekSelect.disabled = false;
|
||||
} else {
|
||||
weekSelect.disabled = true;
|
||||
weekSelect.innerHTML = `<option value="">${this.t('dateFilter.selectWeekDots')}</option>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply buttons
|
||||
const applySeasonBtn = this.container.querySelector('#apply-season-filter');
|
||||
if (applySeasonBtn) {
|
||||
applySeasonBtn.addEventListener('click', () => this.applySeasonFilter());
|
||||
}
|
||||
|
||||
const applyCumulativeBtn = this.container.querySelector('#apply-cumulative-filter');
|
||||
if (applyCumulativeBtn) {
|
||||
applyCumulativeBtn.addEventListener('click', () => this.applyCumulativeFilter());
|
||||
}
|
||||
|
||||
const applyCustomBtn = this.container.querySelector('#apply-custom-filter');
|
||||
if (applyCustomBtn) {
|
||||
applyCustomBtn.addEventListener('click', () => this.applyCustomFilter());
|
||||
}
|
||||
|
||||
// Clear filter
|
||||
const clearBtn = this.container.querySelector('#clear-filter');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearFilter());
|
||||
}
|
||||
}
|
||||
|
||||
populateSelectors() {
|
||||
const seasonSelect = this.container.querySelector('#season-select');
|
||||
const cumulativeSeasonSelect = this.container.querySelector('#cumulative-season-select');
|
||||
|
||||
if (seasonSelect) {
|
||||
this.seasonsFilter.populateSeasonSelect(seasonSelect, false);
|
||||
}
|
||||
|
||||
if (cumulativeSeasonSelect) {
|
||||
this.seasonsFilter.populateSeasonSelect(cumulativeSeasonSelect, false);
|
||||
}
|
||||
}
|
||||
|
||||
showFilterSection(filterType) {
|
||||
// Update button states
|
||||
const buttons = this.container.querySelectorAll('[data-filter-type]');
|
||||
buttons.forEach(btn => {
|
||||
if (btn.dataset.filterType === filterType) {
|
||||
btn.classList.add('filter-button-active');
|
||||
btn.classList.remove('filter-button');
|
||||
} else {
|
||||
btn.classList.remove('filter-button-active');
|
||||
btn.classList.add('filter-button');
|
||||
}
|
||||
});
|
||||
|
||||
// Hide all sections
|
||||
this.container.querySelector('#season-selector')?.classList.add('hidden');
|
||||
this.container.querySelector('#cumulative-selector')?.classList.add('hidden');
|
||||
this.container.querySelector('#custom-range-selector')?.classList.add('hidden');
|
||||
|
||||
// Show appropriate section
|
||||
switch (filterType) {
|
||||
case 'all-time':
|
||||
this.applyAllTimeFilter();
|
||||
break;
|
||||
case 'current-season':
|
||||
this.applyCurrentSeasonFilter();
|
||||
break;
|
||||
case 'season':
|
||||
this.container.querySelector('#season-selector')?.classList.remove('hidden');
|
||||
break;
|
||||
case 'cumulative':
|
||||
this.container.querySelector('#cumulative-selector')?.classList.remove('hidden');
|
||||
break;
|
||||
case 'custom':
|
||||
this.container.querySelector('#custom-range-selector')?.classList.remove('hidden');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
applyAllTimeFilter() {
|
||||
this.currentFilter = {
|
||||
type: 'all-time',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
season: null,
|
||||
week: null
|
||||
};
|
||||
this.updateFilterDisplay(this.t('dateFilter.allTimeStatistics'));
|
||||
this.options.onFilterChange(this.currentFilter);
|
||||
}
|
||||
|
||||
applyCurrentSeasonFilter() {
|
||||
const currentSeasonInfo = this.seasonsFilter.getCurrentSeason();
|
||||
if (!currentSeasonInfo) {
|
||||
console.error('[Date Filter] No current season found');
|
||||
return;
|
||||
}
|
||||
|
||||
const dateRange = this.seasonsFilter.getSeasonDateRange(currentSeasonInfo.name);
|
||||
if (!dateRange) return;
|
||||
|
||||
this.currentFilter = {
|
||||
type: 'season',
|
||||
startDate: dateRange.startDate,
|
||||
endDate: dateRange.endDate,
|
||||
season: currentSeasonInfo.name,
|
||||
week: null
|
||||
};
|
||||
|
||||
this.updateFilterDisplay(this.t('dateFilter.currentSeasonValue', { season: currentSeasonInfo.name }));
|
||||
this.options.onFilterChange(this.currentFilter);
|
||||
}
|
||||
|
||||
applySeasonFilter() {
|
||||
const seasonSelect = this.container.querySelector('#season-select');
|
||||
const weekSelect = this.container.querySelector('#week-select');
|
||||
|
||||
const seasonName = seasonSelect.value;
|
||||
if (!seasonName) {
|
||||
alert(this.t('dateFilter.alertSelectSeason'));
|
||||
return;
|
||||
}
|
||||
|
||||
const weekValue = weekSelect.value;
|
||||
let dateRange;
|
||||
let description;
|
||||
|
||||
if (weekValue === 'all') {
|
||||
// Entire season
|
||||
dateRange = this.seasonsFilter.getSeasonDateRange(seasonName);
|
||||
description = this.t('dateFilter.seasonValue', { season: seasonName });
|
||||
this.currentFilter = {
|
||||
type: 'season',
|
||||
startDate: dateRange.startDate,
|
||||
endDate: dateRange.endDate,
|
||||
season: seasonName,
|
||||
week: null
|
||||
};
|
||||
} else {
|
||||
// Specific week
|
||||
const weekNumber = weekValue === 'final' ? null : parseInt(weekValue);
|
||||
dateRange = this.seasonsFilter.getWeekDateRange(seasonName, weekNumber);
|
||||
const season = this.seasonsFilter.getSeason(seasonName);
|
||||
const week = season.weeks.find(w =>
|
||||
(w.weekNumber === weekNumber) || (weekValue === 'final' && w.weekNumber === null)
|
||||
);
|
||||
description = `${seasonName} - ${week.displayName}`;
|
||||
this.currentFilter = {
|
||||
type: 'week',
|
||||
startDate: dateRange.startDate,
|
||||
endDate: dateRange.endDate,
|
||||
season: seasonName,
|
||||
week: weekNumber
|
||||
};
|
||||
}
|
||||
|
||||
this.updateFilterDisplay(description);
|
||||
this.options.onFilterChange(this.currentFilter);
|
||||
}
|
||||
|
||||
applyCumulativeFilter() {
|
||||
const seasonSelect = this.container.querySelector('#cumulative-season-select');
|
||||
const weekSelect = this.container.querySelector('#cumulative-week-select');
|
||||
|
||||
const seasonName = seasonSelect.value;
|
||||
const weekValue = weekSelect.value;
|
||||
|
||||
if (!seasonName || !weekValue) {
|
||||
alert(this.t('dateFilter.alertSelectSeasonWeek'));
|
||||
return;
|
||||
}
|
||||
|
||||
const weekNumber = weekValue === 'final' ? null : parseInt(weekValue);
|
||||
const dateRange = this.seasonsFilter.getWeekDateRange(seasonName, weekNumber);
|
||||
|
||||
if (!dateRange) return;
|
||||
|
||||
const season = this.seasonsFilter.getSeason(seasonName);
|
||||
const week = season.weeks.find(w =>
|
||||
(w.weekNumber === weekNumber) || (weekValue === 'final' && w.weekNumber === null)
|
||||
);
|
||||
|
||||
this.currentFilter = {
|
||||
type: 'cumulative',
|
||||
startDate: null, // No start date for cumulative
|
||||
endDate: dateRange.endDate,
|
||||
season: seasonName,
|
||||
week: weekNumber
|
||||
};
|
||||
|
||||
this.updateFilterDisplay(this.t('dateFilter.cumulativeValue', { season: seasonName, week: week.displayName }));
|
||||
this.options.onFilterChange(this.currentFilter);
|
||||
}
|
||||
|
||||
applyCustomFilter() {
|
||||
const startDateInput = this.container.querySelector('#start-date');
|
||||
const endDateInput = this.container.querySelector('#end-date');
|
||||
|
||||
const startDate = startDateInput.value ? new Date(startDateInput.value) : null;
|
||||
const endDate = endDateInput.value ? new Date(endDateInput.value) : null;
|
||||
|
||||
if (!startDate && !endDate) {
|
||||
alert(this.t('dateFilter.alertSelectDate'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (startDate && endDate && startDate > endDate) {
|
||||
alert(this.t('dateFilter.alertStartBeforeEnd'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentFilter = {
|
||||
type: 'custom',
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
season: null,
|
||||
week: null
|
||||
};
|
||||
|
||||
let description = this.t('dateFilter.customRangePrefix') + ' ';
|
||||
if (startDate && endDate) {
|
||||
description += `${startDate.toLocaleDateString()} - ${endDate.toLocaleDateString()}`;
|
||||
} else if (startDate) {
|
||||
description += this.t('dateFilter.fromDate', { date: startDate.toLocaleDateString() });
|
||||
} else {
|
||||
description += this.t('dateFilter.upToDate', { date: endDate.toLocaleDateString() });
|
||||
}
|
||||
|
||||
this.updateFilterDisplay(description);
|
||||
this.options.onFilterChange(this.currentFilter);
|
||||
}
|
||||
|
||||
clearFilter() {
|
||||
this.applyAllTimeFilter();
|
||||
}
|
||||
|
||||
updateFilterDisplay(description) {
|
||||
const filterDisplay = this.container.querySelector('#current-filter-display');
|
||||
const filterDescription = this.container.querySelector('#filter-description');
|
||||
|
||||
if (this.currentFilter.type === 'all-time') {
|
||||
filterDisplay?.classList.add('hidden');
|
||||
} else {
|
||||
filterDisplay?.classList.remove('hidden');
|
||||
if (filterDescription) {
|
||||
filterDescription.textContent = description;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applyPresetFilter(preset) {
|
||||
// Apply a preset filter (e.g., 'current-season', 'all-time')
|
||||
this.showFilterSection(preset);
|
||||
}
|
||||
|
||||
getCurrentFilter() {
|
||||
return this.currentFilter;
|
||||
}
|
||||
|
||||
getAPIQueryParams() {
|
||||
// Generate query parameters for API requests
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (this.currentFilter.type === 'all-time') {
|
||||
// No params needed for all-time
|
||||
return '';
|
||||
}
|
||||
|
||||
if (this.currentFilter.startDate) {
|
||||
const startDateISO = this.currentFilter.startDate.toISOString();
|
||||
params.append('start_date', startDateISO);
|
||||
console.log('[Date Filter] Start Date:', this.currentFilter.startDate, '→', startDateISO);
|
||||
}
|
||||
|
||||
if (this.currentFilter.endDate) {
|
||||
const endDate = new Date(this.currentFilter.endDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
const endDateISO = endDate.toISOString();
|
||||
params.append('end_date', endDateISO);
|
||||
console.log('[Date Filter] End Date:', endDate, '→', endDateISO);
|
||||
}
|
||||
|
||||
if (this.currentFilter.season) {
|
||||
params.append('season', this.currentFilter.season);
|
||||
}
|
||||
|
||||
if (this.currentFilter.week !== null) {
|
||||
params.append('week', this.currentFilter.week);
|
||||
}
|
||||
|
||||
const queryString = params.toString() ? '?' + params.toString() : '';
|
||||
console.log('[Date Filter] API Query Params:', queryString);
|
||||
return queryString;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
window.DateFilter = DateFilter;
|
||||
@@ -1,82 +0,0 @@
|
||||
// Universal Header Search Functionality
|
||||
let headerSearchTimeout;
|
||||
|
||||
async function headerSearchPlayers() {
|
||||
const searchTerm = document.getElementById('headerPlayerSearch').value.trim();
|
||||
const resultsDiv = document.getElementById('headerSearchResults');
|
||||
|
||||
// Clear previous timeout
|
||||
clearTimeout(headerSearchTimeout);
|
||||
|
||||
if (searchTerm.length < 2) {
|
||||
resultsDiv.classList.remove('show');
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search (increased to 500ms for better performance)
|
||||
headerSearchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await window.apiClient.searchPlayers(searchTerm);
|
||||
displayHeaderSearchResults(response.results, resultsDiv);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
resultsDiv.innerHTML = '<div class="header-search-result-item">' + (window.__t ? __t('js.searchError') : 'Search error. Please try again.') + '</div>';
|
||||
resultsDiv.classList.add('show');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function displayHeaderSearchResults(players, resultsDiv) {
|
||||
if (!players || players.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="header-search-result-item">' + (window.__t ? __t('js.noPlayersFound') : 'No players found') + '</div>';
|
||||
} else {
|
||||
resultsDiv.innerHTML = players.slice(0, 8).map(player => {
|
||||
const squadronTag = player.squadron_name ? `<span class="player-squadron" style="font-family: 'skyquakesymbols', 'Inter', sans-serif !important; color: rgba(255, 255, 255, 0.4); font-size: 0.85rem; margin-right: 0.4rem;">${escapeHtml(player.squadron_name)}</span>` : '';
|
||||
return `
|
||||
<div class="header-search-result-item" onclick="navigateToPlayer(${player.uid})">
|
||||
<div class="header-result-name">${squadronTag}${escapeHtml(player.nick)}</div>
|
||||
<div class="header-result-stats">${formatHeaderNumber(player.total_kills || 0)} ${window.__t ? __t('js.killsSuffix') : 'kills'} • ${(player.win_rate || 0).toFixed(1)}% ${window.__t ? __t('js.winRateSuffix') : 'win rate'}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
resultsDiv.classList.add('show');
|
||||
}
|
||||
|
||||
function navigateToPlayer(uid) {
|
||||
window.location.href = `/players/${uid}`;
|
||||
}
|
||||
|
||||
function hideHeaderSearchResults() {
|
||||
const el = document.getElementById('headerSearchResults');
|
||||
if (el) el.classList.remove('show');
|
||||
}
|
||||
|
||||
function handleHeaderKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
hideHeaderSearchResults();
|
||||
document.getElementById('headerPlayerSearch').blur();
|
||||
}
|
||||
}
|
||||
|
||||
function formatHeaderNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Hide search results when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!event.target.closest('.nav-search')) {
|
||||
hideHeaderSearchResults();
|
||||
}
|
||||
});
|
||||
@@ -1,483 +0,0 @@
|
||||
// DOM Content Loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Mobile Navigation
|
||||
const hamburger = document.querySelector('.hamburger');
|
||||
const navMenu = document.querySelector('.nav-menu');
|
||||
const navLinks = document.querySelectorAll('.nav-link');
|
||||
|
||||
if (hamburger && navMenu) {
|
||||
hamburger.addEventListener('click', function() {
|
||||
hamburger.classList.toggle('active');
|
||||
navMenu.classList.toggle('active');
|
||||
|
||||
// Prevent body scroll when menu is open
|
||||
if (navMenu.classList.contains('active')) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation support
|
||||
hamburger.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
hamburger.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking on nav links
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
hamburger.classList.remove('active');
|
||||
navMenu.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (hamburger && navMenu && !hamburger.contains(e.target) && !navMenu.contains(e.target)) {
|
||||
hamburger.classList.remove('active');
|
||||
navMenu.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu on window resize if desktop size
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth > 768) {
|
||||
hamburger.classList.remove('active');
|
||||
navMenu.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Smooth Scrolling for Navigation Links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Button Click Handlers
|
||||
const inviteButtons = [
|
||||
'inviteBtn',
|
||||
'inviteBtnMobile',
|
||||
'heroInviteBtn',
|
||||
'ctaInviteBtn',
|
||||
'footerInviteBtn',
|
||||
'freePlanInviteBtn'
|
||||
];
|
||||
|
||||
const supportButtons = [
|
||||
'supportBtn',
|
||||
'heroSupportBtn',
|
||||
'footerSupportBtn'
|
||||
];
|
||||
|
||||
// Handle Invite Button Clicks
|
||||
inviteButtons.forEach(buttonId => {
|
||||
const button = document.getElementById(buttonId);
|
||||
if (button) {
|
||||
button.addEventListener('click', function() {
|
||||
handleInviteClick();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Support Button Clicks
|
||||
supportButtons.forEach(buttonId => {
|
||||
const button = document.getElementById(buttonId);
|
||||
if (button) {
|
||||
button.addEventListener('click', function() {
|
||||
handleSupportClick();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch and Update Stats
|
||||
updateStats();
|
||||
|
||||
// Update stats every 30 seconds
|
||||
setInterval(updateStats, 30000);
|
||||
|
||||
// New nav mobile menu toggle
|
||||
const mobileMenuBtn = document.getElementById('mobileMenuBtn');
|
||||
const mobileMenu = document.getElementById('mobileMenu');
|
||||
if (mobileMenuBtn && mobileMenu) {
|
||||
mobileMenuBtn.addEventListener('click', () => {
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
const navbar = document.querySelector('.navbar');
|
||||
if (navbar) {
|
||||
window.addEventListener('scroll', function() {
|
||||
if (window.scrollY > 100) {
|
||||
navbar.style.background = 'rgba(13, 14, 15, 0.98)';
|
||||
} else {
|
||||
navbar.style.background = 'rgba(13, 14, 15, 1)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Animate Numbers on Stats Section Intersection
|
||||
const statsSection = document.querySelector('#stats');
|
||||
if (statsSection) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
animateNumbers();
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.5 });
|
||||
|
||||
observer.observe(statsSection);
|
||||
}
|
||||
|
||||
// Handle touch events for better mobile experience
|
||||
let touchStartY = 0;
|
||||
let touchEndY = 0;
|
||||
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
touchStartY = e.changedTouches[0].screenY;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', function(e) {
|
||||
touchEndY = e.changedTouches[0].screenY;
|
||||
handleSwipe();
|
||||
}, { passive: true });
|
||||
|
||||
function handleSwipe() {
|
||||
const swipeThreshold = 50;
|
||||
const diff = touchStartY - touchEndY;
|
||||
|
||||
// Close mobile menu on upward swipe when menu is open
|
||||
if (navMenu && hamburger && navMenu.classList.contains('active') && diff > swipeThreshold) {
|
||||
hamburger.classList.remove('active');
|
||||
navMenu.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Invite Button Click
|
||||
async function handleInviteClick() {
|
||||
try {
|
||||
// Direct invite URL for Toothless SQB Bot
|
||||
const inviteUrl = 'https://discord.com/oauth2/authorize?client_id=1254679514466877540&permissions=2048&scope=bot%20applications.commands';
|
||||
|
||||
window.open(inviteUrl, '_blank');
|
||||
showNotification(window.__t ? __t('js.openingDiscordInvite') : 'Opening Discord invite!', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error opening invite link:', error);
|
||||
showNotification(window.__t ? __t('js.errorOpeningInvite') : 'Error opening invite link. Please try again later.', 'error');
|
||||
|
||||
// Fallback - same URL but ensure it opens
|
||||
const fallbackUrl = 'https://discord.com/oauth2/authorize?client_id=1254679514466877540&permissions=2048&scope=bot%20applications.commands';
|
||||
window.open(fallbackUrl, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Support Button Click
|
||||
async function handleSupportClick() {
|
||||
try {
|
||||
showNotification(window.__t ? __t('js.gettingSupportLink') : 'Getting support server link...', 'info');
|
||||
|
||||
const response = await fetch('/api/support');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.supportUrl) {
|
||||
window.open(data.supportUrl, '_blank');
|
||||
showNotification(window.__t ? __t('js.openingSupportServer') : 'Opening support server!', 'success');
|
||||
} else {
|
||||
throw new Error('No support URL received');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting support link:', error);
|
||||
showNotification(window.__t ? __t('js.errorGettingSupport') : 'Error getting support link. Please try again later.', 'error');
|
||||
|
||||
// Fallback - Real support server invite
|
||||
const fallbackUrl = 'https://discord.gg/BCvkK8JhPe';
|
||||
window.open(fallbackUrl, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
// Update Stats from API
|
||||
async function updateStats() {
|
||||
try {
|
||||
let stats;
|
||||
|
||||
// Check if API client is available, if not use fallback
|
||||
if (window.apiClient && window.apiClient.getStats) {
|
||||
stats = await window.apiClient.getStats();
|
||||
} else {
|
||||
// Fallback for pages without API client
|
||||
const response = await fetch('/api/stats');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
stats = await response.json();
|
||||
}
|
||||
|
||||
// Update server count
|
||||
const serverCountEl = document.getElementById('serverCount');
|
||||
if (serverCountEl && stats.servers) {
|
||||
serverCountEl.textContent = formatNumber(stats.servers);
|
||||
}
|
||||
|
||||
// Update user count
|
||||
const userCountEl = document.getElementById('userCount');
|
||||
if (userCountEl && stats.users) {
|
||||
userCountEl.textContent = formatNumber(stats.users) + '+';
|
||||
}
|
||||
|
||||
// Update command count
|
||||
const commandCountEl = document.getElementById('commandCount');
|
||||
if (commandCountEl && stats.commands) {
|
||||
commandCountEl.textContent = stats.commands + '+';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Silently ignore — stat counters are non-critical and failures flash an annoying banner
|
||||
}
|
||||
}
|
||||
|
||||
// Format numbers with commas
|
||||
function formatNumber(num) {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
}
|
||||
|
||||
// Animate numbers when stats section comes into view
|
||||
function animateNumbers() {
|
||||
const numbers = document.querySelectorAll('.stat-number');
|
||||
|
||||
numbers.forEach(number => {
|
||||
const target = parseInt(number.textContent.replace(/[^0-9]/g, ''));
|
||||
const duration = 2000;
|
||||
const start = performance.now();
|
||||
|
||||
function updateNumber(currentTime) {
|
||||
const elapsed = currentTime - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
const current = Math.floor(target * easeOutQuart);
|
||||
|
||||
if (number.textContent.includes('+')) {
|
||||
number.textContent = formatNumber(current) + '+';
|
||||
} else {
|
||||
number.textContent = formatNumber(current);
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateNumber);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateNumber);
|
||||
});
|
||||
}
|
||||
|
||||
// Show notification system
|
||||
function showNotification(message, type = 'info') {
|
||||
// Remove existing notifications
|
||||
const existingNotification = document.querySelector('.notification');
|
||||
if (existingNotification) {
|
||||
existingNotification.remove();
|
||||
}
|
||||
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
|
||||
// Style the notification
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 100px;
|
||||
right: 20px;
|
||||
background: ${type === 'success' ? '#4caf50' : type === 'error' ? '#f44336' : '#2196f3'};
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
z-index: 10000;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transition: all 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Auto remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Easter egg - Konami code
|
||||
let konamiCode = [];
|
||||
const konamiSequence = [
|
||||
'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
|
||||
'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
|
||||
'KeyB', 'KeyA'
|
||||
];
|
||||
|
||||
function triggerKonamiEasterEgg() {
|
||||
var t = typeof __t === 'function' ? __t : null;
|
||||
|
||||
// --- 1. Screen shake ---
|
||||
document.body.style.transition = 'none';
|
||||
var shakeFrames = [
|
||||
'3px 0', '-3px 1px', '2px -1px', '-2px 2px',
|
||||
'1px -2px', '-1px 1px', '2px 0', '0 0'
|
||||
];
|
||||
var si = 0;
|
||||
var shakeInterval = setInterval(function () {
|
||||
if (si >= shakeFrames.length) { clearInterval(shakeInterval); document.body.style.transform = ''; return; }
|
||||
document.body.style.transform = 'translate(' + shakeFrames[si] + ')';
|
||||
si++;
|
||||
}, 40);
|
||||
|
||||
// --- 2. Confetti burst ---
|
||||
var colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff922b', '#cc5de8', '#20c997'];
|
||||
var confettiCount = 80;
|
||||
var container = document.createElement('div');
|
||||
container.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:99999;overflow:hidden;';
|
||||
document.body.appendChild(container);
|
||||
for (var i = 0; i < confettiCount; i++) {
|
||||
var piece = document.createElement('div');
|
||||
var size = Math.random() * 8 + 4;
|
||||
var color = colors[Math.floor(Math.random() * colors.length)];
|
||||
var startX = 50 + (Math.random() - 0.5) * 20;
|
||||
var startY = 50 + (Math.random() - 0.5) * 10;
|
||||
var dx = (Math.random() - 0.5) * 120;
|
||||
var dy = -(Math.random() * 60 + 30);
|
||||
var rot = Math.random() * 720 - 360;
|
||||
var dur = Math.random() * 1.5 + 1.5;
|
||||
piece.style.cssText =
|
||||
'position:absolute;width:' + size + 'px;height:' + (size * 0.6) + 'px;' +
|
||||
'background:' + color + ';border-radius:2px;' +
|
||||
'left:' + startX + '%;top:' + startY + '%;' +
|
||||
'opacity:1;pointer-events:none;';
|
||||
piece.animate([
|
||||
{ transform: 'translate(0,0) rotate(0deg)', opacity: 1 },
|
||||
{ transform: 'translate(' + dx + 'vw,' + dy + 'vh) rotate(' + rot + 'deg)', opacity: 0 }
|
||||
], { duration: dur * 1000, easing: 'cubic-bezier(.25,.8,.25,1)', fill: 'forwards' });
|
||||
container.appendChild(piece);
|
||||
}
|
||||
setTimeout(function () { container.remove(); }, 4000);
|
||||
|
||||
// --- 3. Barrel roll ---
|
||||
setTimeout(function () {
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '@keyframes konamiBarrelRoll{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}';
|
||||
document.head.appendChild(style);
|
||||
document.body.style.transformOrigin = 'center center';
|
||||
document.body.style.animation = 'konamiBarrelRoll 1s ease-in-out';
|
||||
document.body.addEventListener('animationend', function handler() {
|
||||
document.body.style.animation = '';
|
||||
document.body.style.transformOrigin = '';
|
||||
style.remove();
|
||||
document.body.removeEventListener('animationend', handler);
|
||||
});
|
||||
}, 350);
|
||||
|
||||
// --- 4. Themed notification ---
|
||||
var msg = t ? t('js.konamiActivated') : 'Achievement Unlocked: Secret Code!';
|
||||
var notif = document.createElement('div');
|
||||
notif.style.cssText =
|
||||
'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(0);' +
|
||||
'background:linear-gradient(135deg,rgba(30,30,30,0.95),rgba(50,50,50,0.95));' +
|
||||
'border:2px solid #ffd93d;color:#ffd93d;padding:20px 40px;border-radius:12px;' +
|
||||
'z-index:100000;font-size:1.4rem;font-weight:700;text-align:center;' +
|
||||
'box-shadow:0 0 40px rgba(255,217,61,0.3);pointer-events:none;' +
|
||||
'font-family:inherit;letter-spacing:1px;text-transform:uppercase;' +
|
||||
'transition:transform 0.4s cubic-bezier(.34,1.56,.64,1),opacity 0.3s ease;opacity:0;';
|
||||
notif.textContent = msg;
|
||||
document.body.appendChild(notif);
|
||||
setTimeout(function () {
|
||||
notif.style.transform = 'translate(-50%,-50%) scale(1)';
|
||||
notif.style.opacity = '1';
|
||||
}, 50);
|
||||
setTimeout(function () {
|
||||
notif.style.transform = 'translate(-50%,-50%) scale(0.8)';
|
||||
notif.style.opacity = '0';
|
||||
setTimeout(function () { notif.remove(); }, 400);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
konamiCode.push(e.code);
|
||||
|
||||
if (konamiCode.length > konamiSequence.length) {
|
||||
konamiCode.shift();
|
||||
}
|
||||
|
||||
if (konamiCode.join(',') === konamiSequence.join(',')) {
|
||||
triggerKonamiEasterEgg();
|
||||
konamiCode = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile menu toggle
|
||||
function toggleMobileMenu() {
|
||||
const navMenu = document.querySelector('.nav-menu');
|
||||
const hamburger = document.querySelector('.hamburger');
|
||||
|
||||
navMenu.classList.toggle('active');
|
||||
hamburger.classList.toggle('active');
|
||||
}
|
||||
|
||||
// Languages dropdown toggle
|
||||
function toggleLanguagesList() {
|
||||
const languagesList = document.getElementById('languagesList');
|
||||
const dropdownToggle = document.querySelector('.dropdown-toggle');
|
||||
|
||||
languagesList.classList.toggle('show');
|
||||
dropdownToggle.classList.toggle('active');
|
||||
}
|
||||
|
||||
// Language switcher (ENG/RUS)
|
||||
function switchLanguage(lang) {
|
||||
const next = lang || (document.documentElement.lang === 'en' ? 'ru' : 'en');
|
||||
if (next === document.documentElement.lang) return;
|
||||
document.cookie = 'lang=' + next + ';path=/;max-age=31536000;SameSite=Lax';
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Language dropdown: close on outside click
|
||||
document.addEventListener('click', function(e) {
|
||||
var dd = document.getElementById('langDropdown');
|
||||
if (dd && !dd.contains(e.target)) {
|
||||
dd.classList.remove('open');
|
||||
}
|
||||
});
|
||||
@@ -1,827 +0,0 @@
|
||||
// Player Details Modal - Quick-peek popup for player stats
|
||||
(function () {
|
||||
let modalInjected = false;
|
||||
let currentTab = 'overview';
|
||||
|
||||
function T(key) {
|
||||
return (window.__t && window.__t(key)) || key;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatNumber(n) {
|
||||
return (n || 0).toLocaleString();
|
||||
}
|
||||
|
||||
function getWinRateColor(wr) {
|
||||
if (wr >= 70) return '#90EE90';
|
||||
if (wr >= 60) return '#A8E6CF';
|
||||
if (wr >= 50) return '#FFD700';
|
||||
if (wr >= 40) return '#FFA500';
|
||||
return '#FF6B6B';
|
||||
}
|
||||
|
||||
function getKDRColor(kdr) {
|
||||
if (kdr >= 3) return '#90EE90';
|
||||
if (kdr >= 2) return '#A8E6CF';
|
||||
if (kdr >= 1.5) return '#FFD700';
|
||||
if (kdr >= 1) return '#FFA500';
|
||||
return '#FF6B6B';
|
||||
}
|
||||
|
||||
function injectModal() {
|
||||
if (modalInjected) return;
|
||||
modalInjected = true;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.pdm-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9999;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.pdm-overlay.pdm-visible { opacity: 1; }
|
||||
.pdm-modal {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid rgba(144,238,144,0.15);
|
||||
border-radius: 12px;
|
||||
max-width: 700px; width: 90%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.pdm-overlay.pdm-visible .pdm-modal { transform: scale(1); }
|
||||
.pdm-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(144,238,144,0.1);
|
||||
position: sticky; top: 0;
|
||||
background: #1e1e1e;
|
||||
z-index: 1;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.pdm-header-left {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.pdm-player-name {
|
||||
font-size: 1.1rem; font-weight: 700;
|
||||
color: #F5F5DC;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.pdm-profile-link {
|
||||
font-size: 0.75rem;
|
||||
color: #90EE90;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.pdm-profile-link:hover { opacity: 1; text-decoration: underline; }
|
||||
.pdm-close {
|
||||
background: none; border: none;
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-size: 1.4rem; cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pdm-close:hover { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
.pdm-tabs {
|
||||
display: flex; justify-content: center;
|
||||
padding: 0.75rem 1.25rem 0;
|
||||
}
|
||||
.pdm-tab-group {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 2rem;
|
||||
padding: 3px;
|
||||
border: 1px solid rgba(144,238,144,0.2);
|
||||
}
|
||||
.pdm-tab-slider {
|
||||
position: absolute;
|
||||
top: 3px; left: 3px;
|
||||
height: calc(100% - 6px);
|
||||
background: #90EE90;
|
||||
border-radius: calc(2rem - 2px);
|
||||
transition: left 0.25s cubic-bezier(0.4,0,0.2,1), width 0.25s cubic-bezier(0.4,0,0.2,1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pdm-tab {
|
||||
position: relative; z-index: 1;
|
||||
background: transparent; border: none;
|
||||
color: rgba(255,255,255,0.45);
|
||||
padding: 0.4rem 1.3rem;
|
||||
border-radius: calc(2rem - 2px);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem; font-weight: 600;
|
||||
transition: color 0.25s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pdm-tab.active { color: #1b1b1b; }
|
||||
.pdm-tab:hover:not(.active) { color: rgba(255,255,255,0.8); }
|
||||
.pdm-body { padding: 1rem 1.25rem 1.25rem; }
|
||||
.pdm-loading {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 3rem; color: rgba(255,255,255,0.5);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.pdm-spinner {
|
||||
width: 32px; height: 32px;
|
||||
border: 3px solid rgba(144,238,144,0.2);
|
||||
border-top-color: #90EE90;
|
||||
border-radius: 50%;
|
||||
animation: pdm-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes pdm-spin { to { transform: rotate(360deg); } }
|
||||
.pdm-error {
|
||||
text-align: center; padding: 2rem;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
.pdm-error i { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||
|
||||
/* Overview tab */
|
||||
.pdm-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.pdm-stats-grid-last-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.pdm-stats-grid-last-row .pdm-stat-card {
|
||||
flex: 0 1 calc(33.333% - 0.4rem);
|
||||
}
|
||||
.pdm-stat-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(144,238,144,0.08);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pdm-stat-value {
|
||||
font-size: 1.1rem; font-weight: 700;
|
||||
color: #90EE90;
|
||||
}
|
||||
.pdm-stat-toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.pdm-stat-toggle span {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.pdm-stat-toggle span.pdm-toggle-active {
|
||||
color: #90EE90;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pdm-stat-toggle span.pdm-toggle-inactive {
|
||||
color: rgba(255,255,255,0.3);
|
||||
}
|
||||
.pdm-stat-toggle span.pdm-toggle-inactive:hover {
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
.pdm-stat-toggle .pdm-toggle-sep {
|
||||
color: rgba(255,255,255,0.15);
|
||||
cursor: default;
|
||||
}
|
||||
.pdm-stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin-top: 0.15rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Mini chart card */
|
||||
.pdm-chart-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(144,238,144,0.08);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
margin-top: 0.6rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.pdm-chart-card:hover {
|
||||
border-color: rgba(144,238,144,0.25);
|
||||
}
|
||||
.pdm-chart-label {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: center;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.pdm-chart-hint {
|
||||
font-size: 0.55rem;
|
||||
color: rgba(255,255,255,0.25);
|
||||
text-align: center;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.pdm-mini-canvas-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
.pdm-mini-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Vehicles tab */
|
||||
.pdm-vehicles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pdm-vehicle-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(144,238,144,0.08);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
.pdm-vehicle-card.pdm-crown-card {
|
||||
border-color: rgba(255,215,0,0.3);
|
||||
background: rgba(255,215,0,0.03);
|
||||
}
|
||||
.pdm-vehicle-name {
|
||||
font-weight: 600; font-size: 0.85rem;
|
||||
color: #F5F5DC;
|
||||
margin-bottom: 0.4rem;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.pdm-crown {
|
||||
color: #FFD700;
|
||||
margin-right: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.pdm-vehicle-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.15rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.pdm-vehicle-stats span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pdm-vs-val { font-weight: 600; }
|
||||
|
||||
/* Sessions tab */
|
||||
.pdm-sessions-wrap {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pdm-sessions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.pdm-sessions-table th {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.6rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid rgba(144,238,144,0.1);
|
||||
white-space: nowrap;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.pdm-sessions-table td {
|
||||
padding: 0.55rem 0.6rem;
|
||||
color: rgba(255,255,255,0.75);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pdm-sessions-table tr:hover td {
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.pdm-result-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pdm-result-win { background: rgba(144,238,144,0.15); color: #90EE90; }
|
||||
.pdm-result-loss { background: rgba(255,107,107,0.15); color: #ff6b6b; }
|
||||
.pdm-no-data {
|
||||
text-align: center; padding: 2rem;
|
||||
color: rgba(255,255,255,0.4);
|
||||
}
|
||||
|
||||
/* Details button */
|
||||
.pdm-details-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(144,238,144,0.15);
|
||||
color: rgba(144,238,144,0.5);
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.65rem;
|
||||
margin-left: 0.4rem;
|
||||
vertical-align: middle;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pdm-details-btn:hover {
|
||||
background: rgba(144,238,144,0.12);
|
||||
color: #90EE90;
|
||||
border-color: rgba(144,238,144,0.4);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.pdm-modal::-webkit-scrollbar,
|
||||
.pdm-vehicles-grid::-webkit-scrollbar,
|
||||
.pdm-sessions-wrap::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.pdm-modal::-webkit-scrollbar-track,
|
||||
.pdm-vehicles-grid::-webkit-scrollbar-track,
|
||||
.pdm-sessions-wrap::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.pdm-modal::-webkit-scrollbar-thumb,
|
||||
.pdm-vehicles-grid::-webkit-scrollbar-thumb,
|
||||
.pdm-sessions-wrap::-webkit-scrollbar-thumb {
|
||||
background: rgba(144,238,144,0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pdm-stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.pdm-vehicles-grid { grid-template-columns: 1fr; }
|
||||
.pdm-sessions-table { font-size: 0.75rem; }
|
||||
.pdm-sessions-table th,
|
||||
.pdm-sessions-table td { padding: 0.45rem 0.4rem; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'pdm-overlay';
|
||||
overlay.className = 'pdm-overlay';
|
||||
overlay.style.display = 'none';
|
||||
overlay.innerHTML = `
|
||||
<div class="pdm-modal" id="pdm-modal">
|
||||
<div class="pdm-header">
|
||||
<div class="pdm-header-left">
|
||||
<span class="pdm-player-name" id="pdm-player-name"></span>
|
||||
<a class="pdm-profile-link" id="pdm-profile-link" href="#">${T('playerModal.viewFullProfile')}</a>
|
||||
</div>
|
||||
<button class="pdm-close" id="pdm-close" title="${T('playerModal.close')}">×</button>
|
||||
</div>
|
||||
<div class="pdm-tabs">
|
||||
<div class="pdm-tab-group" id="pdm-tab-group">
|
||||
<div class="pdm-tab-slider" id="pdm-tab-slider"></div>
|
||||
<button class="pdm-tab active" data-tab="overview">${T('playerModal.overview')}</button>
|
||||
<button class="pdm-tab" data-tab="vehicles">${T('playerModal.vehicles')}</button>
|
||||
<button class="pdm-tab" data-tab="sessions">${T('playerModal.sessions')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdm-body" id="pdm-body">
|
||||
<div class="pdm-loading">
|
||||
<div class="pdm-spinner"></div>
|
||||
<span>${T('playerModal.loadingPlayerData')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Events
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) closeModal();
|
||||
});
|
||||
document.getElementById('pdm-close').addEventListener('click', closeModal);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && overlay.style.display !== 'none') closeModal();
|
||||
});
|
||||
|
||||
// Tab switching
|
||||
document.getElementById('pdm-tab-group').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.pdm-tab');
|
||||
if (!btn || btn.classList.contains('active')) return;
|
||||
document.querySelectorAll('.pdm-tab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentTab = btn.dataset.tab;
|
||||
updateSlider();
|
||||
renderTab();
|
||||
});
|
||||
|
||||
// Prevent modal scroll from propagating
|
||||
document.getElementById('pdm-modal').addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
function updateSlider() {
|
||||
const group = document.getElementById('pdm-tab-group');
|
||||
const slider = document.getElementById('pdm-tab-slider');
|
||||
const active = group.querySelector('.pdm-tab.active');
|
||||
if (!active || !slider) return;
|
||||
slider.style.left = active.offsetLeft + 'px';
|
||||
slider.style.width = active.offsetWidth + 'px';
|
||||
}
|
||||
|
||||
let playerDataCache = null;
|
||||
let gamesDataCache = null;
|
||||
let historyDataCache = null;
|
||||
let miniChart = null;
|
||||
let miniChartMetric = 'kdr';
|
||||
let currentKdrKps = 'kdr';
|
||||
const miniChartMetrics = ['kdr', 'win_rate', 'battles'];
|
||||
const miniChartConfig = {
|
||||
kdr: { label: T('playerModal.kdr'), color: '#64b5f6', suffix: '' },
|
||||
win_rate: { label: T('playerModal.winRate'), color: '#90EE90', suffix: '%' },
|
||||
battles: { label: T('playerModal.battles'), color: '#ffb74d', suffix: '' }
|
||||
};
|
||||
|
||||
function closeModal() {
|
||||
const overlay = document.getElementById('pdm-overlay');
|
||||
overlay.classList.remove('pdm-visible');
|
||||
setTimeout(() => {
|
||||
overlay.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}, 200);
|
||||
// Destroy mini chart to prevent canvas reuse issues
|
||||
if (miniChart) {
|
||||
miniChart.destroy();
|
||||
miniChart = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const body = document.getElementById('pdm-body');
|
||||
if (!playerDataCache) return;
|
||||
|
||||
// Destroy old mini chart before re-rendering
|
||||
if (miniChart) {
|
||||
miniChart.destroy();
|
||||
miniChart = null;
|
||||
}
|
||||
|
||||
if (currentTab === 'overview') {
|
||||
body.innerHTML = renderOverview(playerDataCache);
|
||||
initMiniChart();
|
||||
} else if (currentTab === 'vehicles') {
|
||||
body.innerHTML = renderVehicles(playerDataCache);
|
||||
initVehicleKdrToggle();
|
||||
} else if (currentTab === 'sessions') {
|
||||
body.innerHTML = renderSessions(gamesDataCache);
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverview(data) {
|
||||
const vehicles = (data.vehicles || []).filter(v => v.vehicle !== 'DISCONNECTED');
|
||||
let totalBattles = 0, wins = 0, groundKills = 0, airKills = 0, assists = 0, deaths = 0, captures = 0;
|
||||
|
||||
vehicles.forEach(v => {
|
||||
const s = v.stats;
|
||||
totalBattles += s.total_battles || 0;
|
||||
wins += s.wins || 0;
|
||||
groundKills += s.ground_kills || 0;
|
||||
airKills += s.air_kills || 0;
|
||||
assists += s.assists || 0;
|
||||
deaths += s.deaths || 0;
|
||||
captures += s.captures || 0;
|
||||
});
|
||||
|
||||
const totalKills = groundKills + airKills;
|
||||
const winRate = totalBattles > 0 ? ((wins / totalBattles) * 100).toFixed(1) + '%' : '0.0%';
|
||||
const kdr = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills.toFixed(2);
|
||||
const kps = totalBattles > 0 ? (totalKills / totalBattles).toFixed(2) : '0.00';
|
||||
|
||||
const stats = [
|
||||
[T('playerModal.totalBattles'), formatNumber(totalBattles)],
|
||||
[T('playerModal.wins'), formatNumber(wins)],
|
||||
[T('playerModal.winRate'), winRate],
|
||||
[T('playerModal.totalKills'), formatNumber(totalKills)],
|
||||
[T('playerModal.kdr'), kdr],
|
||||
[T('playerModal.kps'), kps],
|
||||
[T('playerModal.airKills'), formatNumber(airKills)],
|
||||
[T('playerModal.groundKills'), formatNumber(groundKills)],
|
||||
[T('playerModal.assists'), formatNumber(assists)],
|
||||
[T('playerModal.deaths'), formatNumber(deaths)],
|
||||
[T('playerModal.captures'), formatNumber(captures)],
|
||||
];
|
||||
|
||||
const cfg = miniChartConfig[miniChartMetric];
|
||||
const chartCard = `<div class="pdm-chart-card" id="pdm-chart-card" title="${T('playerModal.clickToSwitchMetric')}">
|
||||
<div class="pdm-chart-label" id="pdm-chart-metric-label">${cfg.label}</div>
|
||||
<div class="pdm-mini-canvas-wrap"><canvas class="pdm-mini-canvas" id="pdm-mini-canvas"></canvas></div>
|
||||
<div class="pdm-chart-hint">${T('playerModal.clickToCycle')}</div>
|
||||
</div>`;
|
||||
|
||||
const fullRows = stats.slice(0, Math.floor(stats.length / 3) * 3);
|
||||
const remainder = stats.slice(fullRows.length);
|
||||
const cardHtml = s => `<div class="pdm-stat-card"><div class="pdm-stat-value">${s[1]}</div><div class="pdm-stat-label">${s[0]}</div></div>`;
|
||||
const lastRow = remainder.length ? `<div class="pdm-stats-grid-last-row">${remainder.map(cardHtml).join('')}</div>` : '';
|
||||
|
||||
return `<div class="pdm-stats-grid">${fullRows.map(cardHtml).join('')}</div>${lastRow}${chartCard}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function initVehicleKdrToggle() {
|
||||
const toggle = document.getElementById('pdm-veh-kdr-toggle');
|
||||
if (!toggle) return;
|
||||
toggle.addEventListener('click', function (e) {
|
||||
const span = e.target.closest('[data-mode]');
|
||||
if (!span || span.classList.contains('pdm-toggle-active')) return;
|
||||
currentKdrKps = span.dataset.mode;
|
||||
renderTab();
|
||||
});
|
||||
}
|
||||
|
||||
function initMiniChart() {
|
||||
const card = document.getElementById('pdm-chart-card');
|
||||
if (!card) return;
|
||||
|
||||
card.addEventListener('click', function () {
|
||||
const idx = miniChartMetrics.indexOf(miniChartMetric);
|
||||
miniChartMetric = miniChartMetrics[(idx + 1) % miniChartMetrics.length];
|
||||
renderMiniChart();
|
||||
});
|
||||
|
||||
// Load history data if not cached, then render
|
||||
if (historyDataCache) {
|
||||
renderMiniChart();
|
||||
} else if (currentUid) {
|
||||
window.apiClient.request('/api/player/' + currentUid + '/history').then(data => {
|
||||
historyDataCache = data;
|
||||
renderMiniChart();
|
||||
}).catch(() => {
|
||||
const canvas = document.getElementById('pdm-mini-canvas');
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(T('playerModal.noChartData'), canvas.width / 2, canvas.height / 2);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderMiniChart() {
|
||||
if (!historyDataCache || !historyDataCache.history || !historyDataCache.history.length) return;
|
||||
if (typeof Chart === 'undefined') return;
|
||||
|
||||
const canvas = document.getElementById('pdm-mini-canvas');
|
||||
const label = document.getElementById('pdm-chart-metric-label');
|
||||
if (!canvas) return;
|
||||
|
||||
const cfg = miniChartConfig[miniChartMetric];
|
||||
if (label) label.textContent = cfg.label;
|
||||
|
||||
const history = historyDataCache.history;
|
||||
const dataPoints = history
|
||||
.filter(d => d[miniChartMetric] != null)
|
||||
.map(d => ({ x: new Date(d.period + 'T00:00:00Z').getTime(), y: d[miniChartMetric] }));
|
||||
if (!dataPoints.length) return;
|
||||
|
||||
if (miniChart) {
|
||||
miniChart.data.datasets[0].data = dataPoints;
|
||||
miniChart.data.datasets[0].borderColor = cfg.color;
|
||||
miniChart.data.datasets[0].backgroundColor = cfg.color + '18';
|
||||
miniChart.data.datasets[0].pointBackgroundColor = cfg.color;
|
||||
miniChart.options.plugins.tooltip.borderColor = cfg.color;
|
||||
miniChart.options.plugins.tooltip.bodyColor = cfg.color;
|
||||
miniChart.options.plugins.tooltip.callbacks.label = ctx => `${cfg.label}: ${ctx.parsed.y}${cfg.suffix}`;
|
||||
miniChart.update();
|
||||
return;
|
||||
}
|
||||
|
||||
miniChart = new Chart(canvas.getContext('2d'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: dataPoints,
|
||||
borderColor: cfg.color,
|
||||
backgroundColor: cfg.color + '18',
|
||||
borderWidth: 1.5,
|
||||
pointBackgroundColor: cfg.color,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
tension: 0.15,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 300 },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(15,15,15,0.92)',
|
||||
borderColor: cfg.color,
|
||||
borderWidth: 1,
|
||||
titleColor: 'rgba(255,255,255,0.75)',
|
||||
bodyColor: cfg.color,
|
||||
padding: 6,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: () => '',
|
||||
label: ctx => `${cfg.label}: ${ctx.parsed.y}${cfg.suffix}`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { type: 'linear', display: false, min: (function() { var y = new Date(history[0].period + 'T00:00:00Z').getUTCFullYear(); return Date.UTC(y, 0, 1); })() },
|
||||
y: { display: false, beginAtZero: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let currentUid = null;
|
||||
|
||||
function renderVehicles(data) {
|
||||
const vehicles = (data.vehicles || []).filter(v => v.vehicle !== 'DISCONNECTED');
|
||||
if (!vehicles.length) return `<div class="pdm-no-data"><i class="fas fa-box-open" style="font-size:2rem;margin-bottom:0.5rem;display:block;"></i>${T('playerModal.noVehicleData')}</div>`;
|
||||
|
||||
// Sort by total battles desc
|
||||
vehicles.sort((a, b) => (b.stats.total_battles || 0) - (a.stats.total_battles || 0));
|
||||
|
||||
// Find best WR vehicle with >15 games
|
||||
let bestWrVehicle = null;
|
||||
let bestWr = -1;
|
||||
vehicles.forEach(v => {
|
||||
const battles = v.stats.total_battles || 0;
|
||||
if (battles > 15) {
|
||||
const wr = (v.stats.wins || 0) / battles;
|
||||
if (wr > bestWr) {
|
||||
bestWr = wr;
|
||||
bestWrVehicle = v.vehicle;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mode = currentKdrKps || 'kdr';
|
||||
const kdrCls = mode === 'kdr' ? 'pdm-toggle-active' : 'pdm-toggle-inactive';
|
||||
const kpsCls = mode === 'kps' ? 'pdm-toggle-active' : 'pdm-toggle-inactive';
|
||||
const toggleHeader = `<div class="pdm-stat-toggle" id="pdm-veh-kdr-toggle" style="margin-bottom:0.5rem;">
|
||||
<span class="${kdrCls}" data-mode="kdr">KDR</span>
|
||||
<span class="pdm-toggle-sep">/</span>
|
||||
<span class="${kpsCls}" data-mode="kps">KPS</span>
|
||||
</div>`;
|
||||
|
||||
return `${toggleHeader}<div class="pdm-vehicles-grid">${vehicles.map(v => {
|
||||
const s = v.stats;
|
||||
const totalKills = (s.ground_kills || 0) + (s.air_kills || 0);
|
||||
const battles = s.total_battles || 0;
|
||||
const wins = s.wins || 0;
|
||||
const wrNum = battles > 0 ? (wins / battles) * 100 : 0;
|
||||
const wr = wrNum.toFixed(1) + '%';
|
||||
const kdrNum = s.deaths > 0 ? totalKills / s.deaths : totalKills;
|
||||
const kdr = kdrNum.toFixed(2);
|
||||
const kpsNum = battles > 0 ? totalKills / battles : 0;
|
||||
const kps = kpsNum.toFixed(2);
|
||||
const isCrown = v.vehicle === bestWrVehicle;
|
||||
const crownClass = isCrown ? ' pdm-crown-card' : '';
|
||||
const crownIcon = isCrown ? '<i class="fas fa-crown pdm-crown"></i>' : '';
|
||||
const wrColor = getWinRateColor(wrNum);
|
||||
const activeVal = mode === 'kdr' ? kdr : kps;
|
||||
const activeNum = mode === 'kdr' ? kdrNum : kpsNum;
|
||||
const activeColor = getKDRColor(activeNum);
|
||||
const activeLabel = mode === 'kdr' ? T('playerModal.kdr') : T('playerModal.kps');
|
||||
return `<div class="pdm-vehicle-card${crownClass}">
|
||||
<div class="pdm-vehicle-name" title="${escapeHtml(v.vehicle)}">${crownIcon}${escapeHtml(v.vehicle)}</div>
|
||||
<div class="pdm-vehicle-stats">
|
||||
<span>${T('playerModal.battles')}: <span class="pdm-vs-val" style="color:#90EE90">${battles}</span></span>
|
||||
<span>${T('playerModal.wins')}: <span class="pdm-vs-val" style="color:#90EE90">${wins}</span></span>
|
||||
<span>${T('playerModal.winRate')}: <span class="pdm-vs-val" style="color:${wrColor}">${wr}</span></span>
|
||||
<span>${activeLabel}: <span class="pdm-vs-val" style="color:${activeColor}">${activeVal}</span></span>
|
||||
<span>${T('playerModal.ground')}: <span class="pdm-vs-val" style="color:#90EE90">${s.ground_kills || 0}</span></span>
|
||||
<span>${T('playerModal.air')}: <span class="pdm-vs-val" style="color:#90EE90">${s.air_kills || 0}</span></span>
|
||||
<span>${T('playerModal.assists')}: <span class="pdm-vs-val" style="color:#90EE90">${s.assists || 0}</span></span>
|
||||
<span>${T('playerModal.deaths')}: <span class="pdm-vs-val" style="color:#90EE90">${s.deaths || 0}</span></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderSessions(gamesData) {
|
||||
if (!gamesData || !gamesData.games || !gamesData.games.length) {
|
||||
return `<div class="pdm-no-data"><i class="fas fa-history" style="font-size:2rem;margin-bottom:0.5rem;display:block;"></i>${T('playerModal.noSessionData')}</div>`;
|
||||
}
|
||||
|
||||
const games = gamesData.games
|
||||
.slice()
|
||||
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
|
||||
.slice(0, 50);
|
||||
|
||||
const rows = games.map(g => {
|
||||
const date = new Date(g.timestamp * 1000);
|
||||
const fmt = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const result = g.result || T('playerModal.unknown');
|
||||
const isWin = result.toLowerCase() === 'win';
|
||||
const badgeClass = isWin ? 'pdm-result-win' : 'pdm-result-loss';
|
||||
return `<tr>
|
||||
<td>${fmt}</td>
|
||||
<td>${escapeHtml(g.vehicle || g.vehicle_internal || T('playerModal.unknown'))}</td>
|
||||
<td>${g.stats.ground_kills || 0}</td>
|
||||
<td>${g.stats.air_kills || 0}</td>
|
||||
<td>${g.stats.assists || 0}</td>
|
||||
<td>${g.stats.deaths || 0}</td>
|
||||
<td><span class="pdm-result-badge ${badgeClass}">${result}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="pdm-sessions-wrap"><table class="pdm-sessions-table">
|
||||
<thead><tr>
|
||||
<th>${T('playerModal.date')}</th><th>${T('playerModal.vehicle')}</th><th>${T('playerModal.ground')}</th><th>${T('playerModal.air')}</th><th>${T('playerModal.assists')}</th><th>${T('playerModal.deaths')}</th><th>${T('playerModal.result')}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
window.openPlayerDetailsModal = async function (uid, nick) {
|
||||
injectModal();
|
||||
|
||||
const overlay = document.getElementById('pdm-overlay');
|
||||
const body = document.getElementById('pdm-body');
|
||||
const nameEl = document.getElementById('pdm-player-name');
|
||||
const linkEl = document.getElementById('pdm-profile-link');
|
||||
|
||||
// Reset state
|
||||
currentTab = 'overview';
|
||||
miniChartMetric = 'kdr';
|
||||
currentKdrKps = 'kdr';
|
||||
playerDataCache = null;
|
||||
gamesDataCache = null;
|
||||
historyDataCache = null;
|
||||
currentUid = uid;
|
||||
if (miniChart) {
|
||||
miniChart.destroy();
|
||||
miniChart = null;
|
||||
}
|
||||
document.querySelectorAll('.pdm-tab').forEach(b => b.classList.remove('active'));
|
||||
document.querySelector('.pdm-tab[data-tab="overview"]').classList.add('active');
|
||||
|
||||
nameEl.textContent = nick || uid;
|
||||
linkEl.href = '/players/' + encodeURIComponent(uid);
|
||||
|
||||
body.innerHTML = `<div class="pdm-loading"><div class="pdm-spinner"></div><span>${T('playerModal.loadingPlayerData')}</span></div>`;
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => {
|
||||
overlay.classList.add('pdm-visible');
|
||||
updateSlider();
|
||||
});
|
||||
|
||||
try {
|
||||
const [playerData, gamesData] = await Promise.all([
|
||||
window.apiClient.getPlayer(uid),
|
||||
window.apiClient.getPlayerGames(uid)
|
||||
]);
|
||||
playerDataCache = playerData;
|
||||
gamesDataCache = gamesData;
|
||||
renderTab();
|
||||
} catch (err) {
|
||||
console.error('[Player Details Modal] Error:', err);
|
||||
body.innerHTML = `<div class="pdm-error"><i class="fas fa-exclamation-triangle" style="display:block;margin-bottom:0.5rem;"></i>${T('playerModal.failedToLoadPlayerData')}</div>`;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1,91 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const modal = document.getElementById('season-recap-modal');
|
||||
const btn = document.getElementById('season-recap-btn');
|
||||
const selectEl = document.getElementById('season-recap-select');
|
||||
const genBtn = document.getElementById('season-recap-generate');
|
||||
const img = document.getElementById('season-recap-image');
|
||||
const statusEl = document.getElementById('season-recap-status');
|
||||
|
||||
if (!modal || !btn || !selectEl || !genBtn || !img) {
|
||||
console.warn('[recap-player] modal elements not found; skipping init');
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptTag = document.currentScript;
|
||||
const uid = scriptTag && scriptTag.dataset.uid;
|
||||
if (!uid) {
|
||||
console.warn('[recap-player] no uid on script tag; disabling button');
|
||||
btn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
function closeModal() {
|
||||
modal.classList.add('hidden');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal));
|
||||
btn.addEventListener('click', async () => {
|
||||
openModal();
|
||||
if (!selectEl.options.length) await loadSeasons();
|
||||
});
|
||||
|
||||
function t(key) {
|
||||
return (window.__t && window.__t('seasonCard.' + key)) || key;
|
||||
}
|
||||
|
||||
async function loadSeasons() {
|
||||
statusEl.textContent = t('loadingSeasons');
|
||||
try {
|
||||
if (!window.apiClient) throw new Error('apiClient not available');
|
||||
const seasons = await window.apiClient.request('/api/seasons');
|
||||
const entries = Object.entries(seasons).sort((a, b) => b[1].start - a[1].start);
|
||||
selectEl.innerHTML = '';
|
||||
for (const [name, range] of entries) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = range.status === 'in_progress'
|
||||
? `${name} ${t('inProgressSuffix')}`
|
||||
: name;
|
||||
selectEl.appendChild(opt);
|
||||
}
|
||||
statusEl.textContent = '';
|
||||
} catch (err) {
|
||||
console.error('[recap-player] seasons fetch failed:', err);
|
||||
statusEl.textContent = t('failedSeasons');
|
||||
}
|
||||
}
|
||||
|
||||
function selectedTheme() {
|
||||
const checked = document.querySelector('input[name="season-recap-theme"]:checked');
|
||||
return (checked && checked.value) || 'dark';
|
||||
}
|
||||
|
||||
genBtn.addEventListener('click', () => {
|
||||
const season = selectEl.value;
|
||||
if (!season) return;
|
||||
const theme = selectedTheme();
|
||||
const lang = document.documentElement.lang || 'en';
|
||||
const base = `/players/${encodeURIComponent(uid)}/recap/${encodeURIComponent(season)}.png`;
|
||||
const params = new URLSearchParams({ theme, lang });
|
||||
statusEl.textContent = t('generating');
|
||||
img.style.display = 'none';
|
||||
img.onload = () => {
|
||||
statusEl.textContent = '';
|
||||
img.style.display = 'block';
|
||||
};
|
||||
img.onerror = () => {
|
||||
statusEl.textContent = t('failedGenerate');
|
||||
};
|
||||
const currentOpt = selectEl.options[selectEl.selectedIndex];
|
||||
const inProgressLabel = t('inProgressSuffix');
|
||||
const isInProgress = !!(currentOpt && inProgressLabel && currentOpt.textContent.indexOf(inProgressLabel) !== -1);
|
||||
if (isInProgress) params.set('t', String(Date.now()));
|
||||
img.src = `${base}?${params.toString()}`;
|
||||
});
|
||||
})();
|
||||
@@ -1,93 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const modal = document.getElementById('season-recap-modal');
|
||||
const btn = document.getElementById('season-recap-btn');
|
||||
const selectEl = document.getElementById('season-recap-select');
|
||||
const genBtn = document.getElementById('season-recap-generate');
|
||||
const img = document.getElementById('season-recap-image');
|
||||
const statusEl = document.getElementById('season-recap-status');
|
||||
|
||||
if (!modal || !btn || !selectEl || !genBtn || !img) {
|
||||
console.warn('[recap] modal elements not found; skipping init');
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptTag = document.currentScript;
|
||||
const clanId = scriptTag && scriptTag.dataset.clanId;
|
||||
if (!clanId) {
|
||||
console.warn('[recap] no clan_id on script tag; disabling button');
|
||||
btn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
function closeModal() {
|
||||
modal.classList.add('hidden');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
modal.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', closeModal));
|
||||
btn.addEventListener('click', async () => {
|
||||
openModal();
|
||||
if (!selectEl.options.length) await loadSeasons();
|
||||
});
|
||||
|
||||
function t(key) {
|
||||
return (window.__t && window.__t('seasonCard.' + key)) || key;
|
||||
}
|
||||
|
||||
async function loadSeasons() {
|
||||
statusEl.textContent = t('loadingSeasons');
|
||||
try {
|
||||
if (!window.apiClient) {
|
||||
throw new Error('apiClient not available');
|
||||
}
|
||||
const seasons = await window.apiClient.request('/api/seasons');
|
||||
const entries = Object.entries(seasons).sort((a, b) => b[1].start - a[1].start);
|
||||
selectEl.innerHTML = '';
|
||||
for (const [name, range] of entries) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = range.status === 'in_progress'
|
||||
? `${name} ${t('inProgressSuffix')}`
|
||||
: name;
|
||||
selectEl.appendChild(opt);
|
||||
}
|
||||
statusEl.textContent = '';
|
||||
} catch (err) {
|
||||
console.error('[recap] seasons fetch failed:', err);
|
||||
statusEl.textContent = t('failedSeasons');
|
||||
}
|
||||
}
|
||||
|
||||
function selectedTheme() {
|
||||
const checked = document.querySelector('input[name="season-recap-theme"]:checked');
|
||||
return (checked && checked.value) || 'dark';
|
||||
}
|
||||
|
||||
genBtn.addEventListener('click', () => {
|
||||
const season = selectEl.value;
|
||||
if (!season) return;
|
||||
const theme = selectedTheme();
|
||||
const lang = document.documentElement.lang || 'en';
|
||||
const base = `/squadron/${encodeURIComponent(clanId)}/recap/${encodeURIComponent(season)}.png`;
|
||||
const params = new URLSearchParams({ theme, lang });
|
||||
statusEl.textContent = t('generating');
|
||||
img.style.display = 'none';
|
||||
img.onload = () => {
|
||||
statusEl.textContent = '';
|
||||
img.style.display = 'block';
|
||||
};
|
||||
img.onerror = () => {
|
||||
statusEl.textContent = t('failedGenerate');
|
||||
};
|
||||
const currentOpt = selectEl.options[selectEl.selectedIndex];
|
||||
const inProgressLabel = t('inProgressSuffix');
|
||||
const isInProgress = !!(currentOpt && inProgressLabel && currentOpt.textContent.indexOf(inProgressLabel) !== -1);
|
||||
if (isInProgress) params.set('t', String(Date.now()));
|
||||
img.src = `${base}?${params.toString()}`;
|
||||
});
|
||||
})();
|
||||
@@ -1,900 +0,0 @@
|
||||
/**
|
||||
* replay-canvas.js
|
||||
*
|
||||
* Interactive HTML5 Canvas replay viewer for War Thunder GOB replays.
|
||||
*/
|
||||
|
||||
const RC = {
|
||||
TRAIL_MS: 18000, AIR_TRAIL_MS: 4000, DRONE_TRAIL_MS: 2000,
|
||||
KILL_TTL: 8000, DMG_TTL: 4000, GHOST_TTL: 3000, DEFAULT_SPEED: 4,
|
||||
WIN: '#00c800', LOSE: '#dc1e1e',
|
||||
WIN_DIM: 'rgba(0,200,0,', LOSE_DIM: 'rgba(220,30,30,',
|
||||
WIN_TRAIL: 'rgba(0,120,0,', LOSE_TRAIL: 'rgba(132,18,18,',
|
||||
DOT_R: 5, AIR_R: 4, DRONE_R: 3,
|
||||
};
|
||||
|
||||
function replayT(key) {
|
||||
return (window.__t && window.__t(key)) || key;
|
||||
}
|
||||
|
||||
class ReplayCanvas {
|
||||
constructor(containerEl, data) {
|
||||
this.container = containerEl;
|
||||
this.data = data;
|
||||
this.playing = false;
|
||||
this.speed = RC.DEFAULT_SPEED;
|
||||
this.currentTime = 0;
|
||||
this.tStart = Infinity;
|
||||
this.tEnd = -Infinity;
|
||||
this.lastFrameTime = 0;
|
||||
this.highlightedPlayerId = null;
|
||||
this.animFrameId = null;
|
||||
this.canvasSize = 720;
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
this.mapCanvas = null;
|
||||
this.mapCtx = null;
|
||||
|
||||
// Store both coordinate sets
|
||||
this._groundCoords = data.levelCoords;
|
||||
this._airCoords = data.mapCoords || null;
|
||||
this._fullMapLevel = data.fullMapLevel || null;
|
||||
this._mode = 'ground';
|
||||
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
|
||||
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
|
||||
|
||||
this.x0 = data.levelCoords.x0;
|
||||
this.z0 = data.levelCoords.z0;
|
||||
this.xRange = data.levelCoords.x1 - data.levelCoords.x0;
|
||||
this.zRange = data.levelCoords.z1 - data.levelCoords.z0;
|
||||
// Default map source rect (full image) — overwritten by _computeAutocrop
|
||||
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
|
||||
|
||||
this.players = {};
|
||||
for (const p of data.players) this.players[p.id] = p;
|
||||
|
||||
this.entities = [];
|
||||
for (const e of data.entities) {
|
||||
if (!e.path || e.path.length === 0) continue;
|
||||
const times = new Float64Array(e.path.length);
|
||||
const positions = new Float32Array(e.path.length * 2);
|
||||
for (let i = 0; i < e.path.length; i++) {
|
||||
times[i] = e.path[i].t;
|
||||
positions[i * 2] = e.path[i].x;
|
||||
positions[i * 2 + 1] = e.path[i].z;
|
||||
}
|
||||
if (times[0] < this.tStart) this.tStart = times[0];
|
||||
if (times[times.length - 1] > this.tEnd) this.tEnd = times[times.length - 1];
|
||||
const isWinner = e.playerId > 0
|
||||
? (this.players[e.playerId]?.team === data.teamWon)
|
||||
: (e.droneTeam === data.teamWon);
|
||||
this.entities.push({
|
||||
...e, times, positions, isWinner,
|
||||
deathTime: null, ghostEndTime: null, deathPos: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Zoom into the area players actually use (like the video maker autocrop)
|
||||
this._computeAutocrop();
|
||||
|
||||
// Pre-compute deaths
|
||||
this._computeDeaths();
|
||||
this.currentTime = this.tStart;
|
||||
}
|
||||
|
||||
_computeDeaths() {
|
||||
// Reset deaths so they can be recomputed after coord changes
|
||||
for (const ent of this.entities) {
|
||||
ent.deathTime = null;
|
||||
ent.ghostEndTime = null;
|
||||
ent.deathPos = null;
|
||||
}
|
||||
for (const k of this.data.kills) {
|
||||
for (const ent of this.entities) {
|
||||
const matched = (k.victimEntityIndex && ent.entityIndex === k.victimEntityIndex)
|
||||
|| (k.victimId && ent.playerId === k.victimId && ent.playerId !== 0);
|
||||
if (matched && ent.deathTime === null) {
|
||||
ent.deathTime = k.time;
|
||||
ent.ghostEndTime = k.time + RC.GHOST_TTL;
|
||||
if (k.victimPos) ent.deathPos = this.worldToPixel(k.victimPos.x, k.victimPos.z);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
worldToPixel(x, z) {
|
||||
return [
|
||||
(x - this.x0) / this.xRange * this.canvasSize,
|
||||
(this.z0 + this.zRange - z) / this.zRange * this.canvasSize
|
||||
];
|
||||
}
|
||||
|
||||
/** Recompute x0/z0/xRange/zRange to zoom into entity activity.
|
||||
* In 'ground' mode crops to ground entities, in 'air' mode crops to aircraft+drones. */
|
||||
_computeAutocrop() {
|
||||
const origX0 = this.x0, origZ0 = this.z0;
|
||||
const origXR = this.xRange, origZR = this.zRange;
|
||||
|
||||
const airMode = this._mode === 'air';
|
||||
let minX = Infinity, maxX = -Infinity;
|
||||
let minZ = Infinity, maxZ = -Infinity;
|
||||
for (const ent of this.entities) {
|
||||
if (airMode) {
|
||||
if (ent.type !== 'aircraft' && ent.type !== 'drone') continue;
|
||||
} else {
|
||||
if (ent.type !== 'ground') continue;
|
||||
}
|
||||
const { positions } = ent;
|
||||
for (let i = 0; i < positions.length; i += 2) {
|
||||
const x = positions[i], z = positions[i + 1];
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (z < minZ) minZ = z;
|
||||
if (z > maxZ) maxZ = z;
|
||||
}
|
||||
}
|
||||
if (!isFinite(minX)) return; // no relevant positions — keep full map
|
||||
|
||||
// Padding: 10% of span or 50 world units, whichever is larger
|
||||
const span = Math.max(maxX - minX, maxZ - minZ);
|
||||
const pad = Math.max(50, span * 0.10);
|
||||
minX -= pad; maxX += pad;
|
||||
minZ -= pad; maxZ += pad;
|
||||
|
||||
// Expand to square, with a minimum of 15% of the map range
|
||||
const minSide = Math.max(origXR, origZR) * 0.15;
|
||||
let side = Math.max(maxX - minX, maxZ - minZ, minSide);
|
||||
const midX = (minX + maxX) / 2, midZ = (minZ + maxZ) / 2;
|
||||
minX = midX - side / 2; maxX = midX + side / 2;
|
||||
minZ = midZ - side / 2; maxZ = midZ + side / 2;
|
||||
|
||||
// Clamp to LevelDef bounds (shift first, then hard-clamp)
|
||||
const x1 = origX0 + origXR, z1 = origZ0 + origZR;
|
||||
if (minX < origX0) { maxX += origX0 - minX; minX = origX0; }
|
||||
if (maxX > x1) { minX -= maxX - x1; maxX = x1; }
|
||||
if (minZ < origZ0) { maxZ += origZ0 - minZ; minZ = origZ0; }
|
||||
if (maxZ > z1) { minZ -= maxZ - z1; maxZ = z1; }
|
||||
minX = Math.max(minX, origX0); maxX = Math.min(maxX, x1);
|
||||
minZ = Math.max(minZ, origZ0); maxZ = Math.min(maxZ, z1);
|
||||
|
||||
// Fractional source rect for the minimap image
|
||||
// Image top-left = world (origX0, origZ0+origZR), bottom-right = (origX0+origXR, origZ0)
|
||||
this._mapSrc = {
|
||||
u: (minX - origX0) / origXR,
|
||||
v: (z1 - maxZ) / origZR,
|
||||
w: (maxX - minX) / origXR,
|
||||
h: (maxZ - minZ) / origZR,
|
||||
};
|
||||
|
||||
// Apply new bounds — worldToPixel will now map this sub-region to the full canvas
|
||||
this.x0 = minX;
|
||||
this.z0 = minZ;
|
||||
this.xRange = maxX - minX;
|
||||
this.zRange = maxZ - minZ;
|
||||
}
|
||||
|
||||
getPositionAtTime(entity, time) {
|
||||
const { times, positions } = entity;
|
||||
if (time < times[0] || time > times[times.length - 1]) return null;
|
||||
let lo = 0, hi = times.length - 1;
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (times[mid] <= time) lo = mid; else hi = mid;
|
||||
}
|
||||
const t0 = times[lo], t1 = times[hi];
|
||||
const frac = t1 > t0 ? (time - t0) / (t1 - t0) : 0;
|
||||
const i0 = lo * 2, i1 = hi * 2;
|
||||
return this.worldToPixel(
|
||||
positions[i0] + (positions[i1] - positions[i0]) * frac,
|
||||
positions[i0 + 1] + (positions[i1 + 1] - positions[i0 + 1]) * frac
|
||||
);
|
||||
}
|
||||
|
||||
getHeadingAtTime(entity, time) {
|
||||
// Compute heading in radians (0=up/north, CW) from position delta
|
||||
const dt = 500; // sample window in game ms
|
||||
const p0 = this.getPositionAtTime(entity, time - dt);
|
||||
const p1 = this.getPositionAtTime(entity, time);
|
||||
if (!p0 || !p1) return null;
|
||||
const dx = p1[0] - p0[0];
|
||||
const dy = p1[1] - p0[1];
|
||||
if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1) return null;
|
||||
return Math.atan2(dx, -dy); // 0=up, CW positive
|
||||
}
|
||||
|
||||
_entityScreenPos(entity, time) {
|
||||
if (entity.deathTime !== null && time >= entity.deathTime) return entity.deathPos;
|
||||
return this.getPositionAtTime(entity, time);
|
||||
}
|
||||
|
||||
_isEntityDead(entity, time) {
|
||||
return entity.deathTime !== null && time >= entity.deathTime;
|
||||
}
|
||||
|
||||
_isEntityGone(entity, time) {
|
||||
return entity.ghostEndTime !== null && time >= entity.ghostEndTime;
|
||||
}
|
||||
|
||||
async init() {
|
||||
this._buildDOM();
|
||||
await Promise.all([this._loadMap(), this._loadEntityIcons()]);
|
||||
this.playing = true;
|
||||
this.playBtn.innerHTML = '<i class="fas fa-pause"></i>';
|
||||
this.lastFrameTime = performance.now();
|
||||
this._tick = this._tick.bind(this);
|
||||
this.animFrameId = requestAnimationFrame(this._tick);
|
||||
}
|
||||
|
||||
_buildDOM() {
|
||||
this.container.innerHTML = '';
|
||||
const layout = document.createElement('div');
|
||||
layout.className = 'rc-layout';
|
||||
|
||||
// Left panel (winners)
|
||||
this.leftPanel = document.createElement('div');
|
||||
this.leftPanel.className = 'rc-panel rc-panel-win';
|
||||
this._buildTeamPanel(this.leftPanel, true);
|
||||
|
||||
// Center
|
||||
const center = document.createElement('div');
|
||||
center.className = 'rc-center';
|
||||
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.width = this.canvasSize;
|
||||
this.canvas.height = this.canvasSize;
|
||||
this.canvas.className = 'rc-canvas';
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
center.appendChild(this.canvas);
|
||||
|
||||
// Controls
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'rc-controls';
|
||||
controls.innerHTML = `
|
||||
<button class="rc-btn rc-play" title="${replayT('replay.playPause')}"><i class="fas fa-play"></i></button>
|
||||
<div class="rc-speeds">
|
||||
<button class="rc-btn rc-sp" data-speed="1">1x</button>
|
||||
<button class="rc-btn rc-sp" data-speed="2">2x</button>
|
||||
<button class="rc-btn rc-sp active" data-speed="4">4x</button>
|
||||
<button class="rc-btn rc-sp" data-speed="8">8x</button>
|
||||
</div>
|
||||
<input type="range" class="rc-scrub" min="0" max="1000" value="0">
|
||||
<span class="rc-time">0:00 / 0:00</span>
|
||||
`;
|
||||
center.appendChild(controls);
|
||||
|
||||
// Battle log
|
||||
const logWrap = document.createElement('div');
|
||||
logWrap.className = 'rc-log-wrap';
|
||||
logWrap.innerHTML = '<div class="rc-log" id="rcBattleLog"></div>';
|
||||
center.appendChild(logWrap);
|
||||
this.battleLog = logWrap.querySelector('#rcBattleLog');
|
||||
|
||||
// Pre-build sorted event list for the log
|
||||
this._buildEventList();
|
||||
|
||||
// Right panel (losers)
|
||||
this.rightPanel = document.createElement('div');
|
||||
this.rightPanel.className = 'rc-panel rc-panel-lose';
|
||||
this._buildTeamPanel(this.rightPanel, false);
|
||||
|
||||
layout.appendChild(this.leftPanel);
|
||||
layout.appendChild(center);
|
||||
layout.appendChild(this.rightPanel);
|
||||
this.container.appendChild(layout);
|
||||
|
||||
// Wire controls
|
||||
this.playBtn = controls.querySelector('.rc-play');
|
||||
this.scrubber = controls.querySelector('.rc-scrub');
|
||||
this.timeDisplay = controls.querySelector('.rc-time');
|
||||
|
||||
this.playBtn.addEventListener('click', () => this._togglePlay());
|
||||
this.scrubber.addEventListener('input', () => {
|
||||
this.currentTime = this.tStart + (this.scrubber.value / 1000) * (this.tEnd - this.tStart);
|
||||
this._updatePanelDeathStates();
|
||||
this._updateBattleLog();
|
||||
this.render();
|
||||
});
|
||||
controls.querySelectorAll('.rc-sp').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
controls.querySelectorAll('.rc-sp').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
this.speed = parseInt(btn.dataset.speed);
|
||||
});
|
||||
});
|
||||
|
||||
// Canvas hover — store mouse pos, re-evaluate each frame
|
||||
this._mouseOnCanvas = false;
|
||||
this._mouseX = 0;
|
||||
this._mouseY = 0;
|
||||
this.canvas.addEventListener('mousemove', (ev) => {
|
||||
this._mouseOnCanvas = true;
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this._mouseX = (ev.clientX - rect.left) * (this.canvasSize / rect.width);
|
||||
this._mouseY = (ev.clientY - rect.top) * (this.canvasSize / rect.height);
|
||||
});
|
||||
this.canvas.addEventListener('mouseleave', () => {
|
||||
this._mouseOnCanvas = false;
|
||||
this._setHighlight(null);
|
||||
});
|
||||
|
||||
// Offscreen map canvas
|
||||
this.mapCanvas = document.createElement('canvas');
|
||||
this.mapCanvas.width = this.canvasSize;
|
||||
this.mapCanvas.height = this.canvasSize;
|
||||
this.mapCtx = this.mapCanvas.getContext('2d');
|
||||
}
|
||||
|
||||
_buildTeamPanel(panel, isWinner) {
|
||||
// Show all players on this team, using their first entity (prefer ground)
|
||||
const teamEntities = this.entities.filter(e => e.playerId > 0 && e.isWinner === isWinner);
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
// First pass: ground entities
|
||||
for (const e of teamEntities) {
|
||||
if (e.type === 'ground' && !seen.has(e.playerId)) { seen.add(e.playerId); unique.push(e); }
|
||||
}
|
||||
// Second pass: any remaining players (aircraft etc)
|
||||
for (const e of teamEntities) {
|
||||
if (!seen.has(e.playerId)) { seen.add(e.playerId); unique.push(e); }
|
||||
}
|
||||
|
||||
const color = isWinner ? RC.WIN : RC.LOSE;
|
||||
const dimColor = isWinner ? 'rgba(0,200,0,0.15)' : 'rgba(220,30,30,0.15)';
|
||||
// Use squadron clan tag rendered with skyquake font
|
||||
const firstPlayer = unique.length > 0 ? this.players[unique[0].playerId] : null;
|
||||
const clanTag = firstPlayer?.clan || '';
|
||||
const label = clanTag
|
||||
? `<span class="rc-clan-tag">${this._esc(clanTag)}</span>`
|
||||
: (isWinner ? 'Winners' : 'Losers');
|
||||
|
||||
let html = `<div class="rc-panel-head" style="border-bottom-color:${dimColor}">
|
||||
<span class="rc-panel-label" style="color:${color}">${label}</span>
|
||||
</div><div class="rc-panel-list">`;
|
||||
|
||||
for (const ent of unique) {
|
||||
const p = this.players[ent.playerId];
|
||||
const name = p ? this._esc(p.name) : '?';
|
||||
const veh = this._esc(ent.vehicleName);
|
||||
const panelIcon = ent.miniIcon ? ent.miniIcon.replace('mini:', '') : (ent.iconKey || 'medium');
|
||||
html += `<div class="rc-row" data-player-id="${ent.playerId}" data-entity-index="${ent.entityIndex}">
|
||||
<img class="rc-type-icon" src="/api/icons/type/${panelIcon}" alt="" loading="lazy" onerror="this.style.display='none'">
|
||||
<div class="rc-row-info">
|
||||
<span class="rc-row-name">${name}</span>
|
||||
<span class="rc-row-veh">${veh}</span>
|
||||
</div>
|
||||
<span class="rc-row-status"></span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
panel.innerHTML = html;
|
||||
|
||||
// Hover
|
||||
panel.querySelectorAll('.rc-row').forEach(row => {
|
||||
row.addEventListener('mouseenter', () => {
|
||||
const pid = parseInt(row.dataset.playerId);
|
||||
const eidx = parseInt(row.dataset.entityIndex);
|
||||
const ent = this.entities.find(e => e.entityIndex === eidx);
|
||||
if (ent && !this._isEntityGone(ent, this.currentTime)) {
|
||||
this._setHighlight(pid);
|
||||
}
|
||||
});
|
||||
row.addEventListener('mouseleave', () => this._setHighlight(null));
|
||||
});
|
||||
}
|
||||
|
||||
_updatePanelDeathStates() {
|
||||
const t = this.currentTime;
|
||||
this.container.querySelectorAll('.rc-row').forEach(row => {
|
||||
const eidx = parseInt(row.dataset.entityIndex);
|
||||
const ent = this.entities.find(e => e.entityIndex === eidx);
|
||||
if (!ent) return;
|
||||
const dead = this._isEntityDead(ent, t);
|
||||
const gone = this._isEntityGone(ent, t);
|
||||
row.classList.toggle('rc-dead', dead);
|
||||
row.classList.toggle('rc-gone', gone);
|
||||
const status = row.querySelector('.rc-row-status');
|
||||
if (gone) {
|
||||
status.innerHTML = '<i class="fas fa-skull" style="opacity:0.3"></i>';
|
||||
row.style.cursor = 'default';
|
||||
} else if (dead) {
|
||||
status.innerHTML = '<i class="fas fa-skull" style="opacity:0.5"></i>';
|
||||
row.style.cursor = 'default';
|
||||
} else {
|
||||
status.innerHTML = '';
|
||||
row.style.cursor = 'pointer';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_setHighlight(playerId) {
|
||||
if (this.highlightedPlayerId === playerId) return;
|
||||
this.highlightedPlayerId = playerId;
|
||||
this.container.querySelectorAll('.rc-row').forEach(row => {
|
||||
row.classList.toggle('rc-hl', parseInt(row.dataset.playerId) === playerId);
|
||||
});
|
||||
if (!this.playing) this.render();
|
||||
}
|
||||
|
||||
_esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
_buildEventList() {
|
||||
// Merge kills and damages into a single sorted timeline
|
||||
this._events = [];
|
||||
for (const k of this.data.kills) {
|
||||
const killer = this.players[k.killerId];
|
||||
// Find victim name
|
||||
let victimName = '?';
|
||||
let victimTeam = -1;
|
||||
if (k.victimId && this.players[k.victimId]) {
|
||||
victimName = this.players[k.victimId].name;
|
||||
victimTeam = this.players[k.victimId].team;
|
||||
} else if (k.victimVehicle) {
|
||||
victimName = k.victimVehicle;
|
||||
}
|
||||
let html;
|
||||
if (!killer) {
|
||||
// No killer (crash / environment kill)
|
||||
const victimIsWin = victimTeam === this.data.teamWon;
|
||||
html = `<span class="${victimIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(victimName)}</span>`
|
||||
+ `<span class="rc-ev-action"> ${replayT('replay.crashed')}</span>`;
|
||||
} else {
|
||||
const killerIsWin = killer.team === this.data.teamWon;
|
||||
html = `<span class="${killerIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(killer.name)}</span>`
|
||||
+ `<span class="rc-ev-action"> ${replayT('replay.destroyed')} </span>`
|
||||
+ `<span class="${killerIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${this._esc(victimName)}</span>`
|
||||
+ (k.weapon ? `<span class="rc-ev-weapon">[${this._esc(k.weapon)}]</span>` : '');
|
||||
}
|
||||
this._events.push({
|
||||
time: k.time,
|
||||
type: 'kill',
|
||||
html,
|
||||
});
|
||||
}
|
||||
for (const dm of this.data.damages) {
|
||||
const atk = this.players[dm.offenderId];
|
||||
const vic = this.players[dm.offendedId];
|
||||
if (!atk || !vic) continue;
|
||||
const atkIsWin = atk.team === this.data.teamWon;
|
||||
this._events.push({
|
||||
time: dm.time,
|
||||
type: 'damage',
|
||||
html: `<span class="${atkIsWin ? 'rc-ev-win' : 'rc-ev-lose'}">${this._esc(atk.name)}</span>`
|
||||
+ `<span class="rc-ev-action"> ${replayT('replay.hit')} </span>`
|
||||
+ `<span class="${atkIsWin ? 'rc-ev-lose' : 'rc-ev-win'}">${this._esc(vic.name)}</span>`,
|
||||
});
|
||||
}
|
||||
this._events.sort((a, b) => a.time - b.time);
|
||||
this._lastLogIndex = -1;
|
||||
}
|
||||
|
||||
_updateBattleLog() {
|
||||
const t = this.currentTime;
|
||||
// Find how many events should be visible
|
||||
let idx = -1;
|
||||
for (let i = 0; i < this._events.length; i++) {
|
||||
if (this._events[i].time <= t) idx = i;
|
||||
else break;
|
||||
}
|
||||
if (idx === this._lastLogIndex) return;
|
||||
this._lastLogIndex = idx;
|
||||
// Rebuild log content
|
||||
const log = this.battleLog;
|
||||
log.innerHTML = '';
|
||||
for (let i = 0; i <= idx; i++) {
|
||||
const ev = this._events[i];
|
||||
const el = document.createElement('div');
|
||||
el.className = `rc-ev rc-ev-${ev.type}`;
|
||||
const elapsed = (ev.time - this.tStart) / 1000;
|
||||
const mm = Math.floor(elapsed / 60);
|
||||
const ss = Math.floor(elapsed % 60);
|
||||
el.innerHTML = `<span class="rc-ev-time">${mm}:${String(ss).padStart(2,'0')}</span>${ev.html}`;
|
||||
log.appendChild(el);
|
||||
}
|
||||
// Auto-scroll to bottom
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
_getTintedIcon(iconKey, color, size) {
|
||||
const cacheKey = `${iconKey}_${color}_${size}`;
|
||||
if (!this._tintCache) this._tintCache = {};
|
||||
if (this._tintCache[cacheKey]) return this._tintCache[cacheKey];
|
||||
const img = this._iconCache?.[iconKey];
|
||||
if (!img || !img.naturalWidth) return null;
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = size; c.height = size;
|
||||
const cx = c.getContext('2d');
|
||||
const [dx, dy, dw, dh] = this._containedImageRect(img, size);
|
||||
cx.drawImage(img, dx, dy, dw, dh);
|
||||
cx.globalCompositeOperation = 'source-atop';
|
||||
cx.fillStyle = color;
|
||||
cx.fillRect(0, 0, size, size);
|
||||
cx.globalCompositeOperation = 'source-over';
|
||||
this._tintCache[cacheKey] = c;
|
||||
return c;
|
||||
} catch (e) {
|
||||
// CORS or other canvas tainting — fall back to untinted
|
||||
this._tintCache[cacheKey] = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_containedImageRect(img, size) {
|
||||
const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1;
|
||||
let w = size, h = size;
|
||||
if (ratio > 1) h = size / ratio;
|
||||
else w = size * ratio;
|
||||
return [(size - w) / 2, (size - h) / 2, w, h];
|
||||
}
|
||||
|
||||
_drawContainedIcon(ctx, img, x, y, size) {
|
||||
const [dx, dy, dw, dh] = this._containedImageRect(img, size);
|
||||
ctx.drawImage(img, x - size / 2 + dx, y - size / 2 + dy, dw, dh);
|
||||
}
|
||||
|
||||
async _loadEntityIcons() {
|
||||
this._iconCache = {};
|
||||
this._iconDebug = {};
|
||||
const keysToLoad = new Set();
|
||||
for (const ent of this.entities) {
|
||||
if (ent.miniIcon) {
|
||||
const miniKey = ent.miniIcon.replace('mini:', '');
|
||||
keysToLoad.add(miniKey);
|
||||
ent._canvasIconKey = miniKey;
|
||||
} else if (ent.iconKey) {
|
||||
keysToLoad.add(ent.iconKey);
|
||||
ent._canvasIconKey = ent.iconKey;
|
||||
}
|
||||
}
|
||||
const promises = [];
|
||||
for (const key of keysToLoad) {
|
||||
promises.push(new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
this._iconCache[key] = img;
|
||||
this._iconDebug[key] = 'ok';
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => {
|
||||
this._iconDebug[key] = 'err';
|
||||
resolve();
|
||||
};
|
||||
img.src = `/api/icons/type/${key}`;
|
||||
}));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async _loadMap() {
|
||||
const level = this.data.mission?.level;
|
||||
if (!level) { this.mapCtx.fillStyle = '#111'; this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize); return; }
|
||||
|
||||
const loadImg = (src) => new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = src;
|
||||
});
|
||||
|
||||
// Load ground (tank) map
|
||||
this._groundMapImg = await loadImg(`/api/match/minimap/${level}`);
|
||||
|
||||
// Load full (air) map if available
|
||||
if (this._fullMapLevel) {
|
||||
this._airMapImg = await loadImg(`/api/match/minimap/${this._fullMapLevel}?type=full`);
|
||||
} else {
|
||||
this._airMapImg = null;
|
||||
}
|
||||
|
||||
this._drawMapToCanvas();
|
||||
}
|
||||
|
||||
_drawMapToCanvas() {
|
||||
const img = this._mode === 'air' ? (this._airMapImg || this._groundMapImg) : this._groundMapImg;
|
||||
if (!img) {
|
||||
this.mapCtx.fillStyle = '#111';
|
||||
this.mapCtx.fillRect(0, 0, this.canvasSize, this.canvasSize);
|
||||
return;
|
||||
}
|
||||
const { u, v, w, h } = this._mapSrc;
|
||||
const sx = u * img.naturalWidth, sy = v * img.naturalHeight;
|
||||
const sw = w * img.naturalWidth, sh = h * img.naturalHeight;
|
||||
this.mapCtx.drawImage(img, sx, sy, sw, sh, 0, 0, this.canvasSize, this.canvasSize);
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
if (mode === this._mode) return;
|
||||
if (mode === 'air' && !this.hasAirMode) return;
|
||||
this._mode = mode;
|
||||
|
||||
// Swap coordinate system
|
||||
const coords = mode === 'air' ? this._airCoords : this._groundCoords;
|
||||
this.x0 = coords.x0;
|
||||
this.z0 = coords.z0;
|
||||
this.xRange = coords.x1 - coords.x0;
|
||||
this.zRange = coords.z1 - coords.z0;
|
||||
this._mapSrc = { u: 0, v: 0, w: 1, h: 1 };
|
||||
|
||||
// Recompute autocrop for the new entity filter
|
||||
this._computeAutocrop();
|
||||
// Redraw map background with new crop region
|
||||
this._drawMapToCanvas();
|
||||
// Recompute death positions in new coordinate space
|
||||
this._computeDeaths();
|
||||
// Render immediately
|
||||
this.render();
|
||||
}
|
||||
|
||||
_togglePlay() {
|
||||
this.playing = !this.playing;
|
||||
this.playBtn.innerHTML = this.playing ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>';
|
||||
if (this.playing) {
|
||||
if (this.currentTime >= this.tEnd) this.currentTime = this.tStart;
|
||||
this.lastFrameTime = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
_tick(now) {
|
||||
if (this.playing) {
|
||||
const dt = now - this.lastFrameTime;
|
||||
this.lastFrameTime = now;
|
||||
this.currentTime += dt * this.speed;
|
||||
if (this.currentTime >= this.tEnd) {
|
||||
this.currentTime = this.tEnd;
|
||||
this.playing = false;
|
||||
this.playBtn.innerHTML = '<i class="fas fa-play"></i>';
|
||||
}
|
||||
}
|
||||
this.render();
|
||||
this._updateControls();
|
||||
// Update panel death states + battle log every ~250ms
|
||||
if (!this._lastPanelUpdate || now - this._lastPanelUpdate > 250) {
|
||||
this._updatePanelDeathStates();
|
||||
this._updateBattleLog();
|
||||
this._lastPanelUpdate = now;
|
||||
}
|
||||
this.animFrameId = requestAnimationFrame(this._tick);
|
||||
}
|
||||
|
||||
_updateControls() {
|
||||
const frac = (this.currentTime - this.tStart) / (this.tEnd - this.tStart);
|
||||
const pct = Math.round(frac * 1000);
|
||||
this.scrubber.value = pct;
|
||||
this.scrubber.style.setProperty('--rc-progress', (frac * 100).toFixed(1) + '%');
|
||||
const cur = (this.currentTime - this.tStart) / 1000;
|
||||
const total = (this.tEnd - this.tStart) / 1000;
|
||||
const fmt = (s) => `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`;
|
||||
this.timeDisplay.textContent = `${fmt(cur)} / ${fmt(total)}`;
|
||||
}
|
||||
|
||||
_updateCanvasHighlight() {
|
||||
if (!this._mouseOnCanvas) return;
|
||||
let bestId = null, bestDist = 400;
|
||||
for (const ent of this.entities) {
|
||||
if (ent.playerId === 0) continue;
|
||||
if (this._isEntityGone(ent, this.currentTime)) continue;
|
||||
const pos = this._entityScreenPos(ent, this.currentTime);
|
||||
if (!pos) continue;
|
||||
const dx = pos[0] - this._mouseX, dy = pos[1] - this._mouseY;
|
||||
const dist = dx * dx + dy * dy;
|
||||
if (dist < bestDist) { bestDist = dist; bestId = ent.playerId; }
|
||||
}
|
||||
this._setHighlight(bestId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const ctx = this.ctx;
|
||||
const t = this.currentTime;
|
||||
this._updateCanvasHighlight();
|
||||
ctx.drawImage(this.mapCanvas, 0, 0);
|
||||
this._drawTrails(ctx, t);
|
||||
this._drawDamageLines(ctx, t);
|
||||
this._drawKillLines(ctx, t);
|
||||
this._drawEntities(ctx, t);
|
||||
}
|
||||
|
||||
_drawTrails(ctx, time) {
|
||||
for (const ent of this.entities) {
|
||||
if (this._isEntityGone(ent, time)) continue;
|
||||
const endT = ent.deathTime !== null ? Math.min(time, ent.deathTime) : time;
|
||||
const trailLen = ent.type === 'ground' ? RC.TRAIL_MS
|
||||
: ent.type === 'aircraft' ? (this._mode === 'air' ? RC.TRAIL_MS : RC.AIR_TRAIL_MS)
|
||||
: RC.DRONE_TRAIL_MS;
|
||||
const tMin = endT - trailLen;
|
||||
const baseColor = ent.isWinner ? RC.WIN_TRAIL : RC.LOSE_TRAIL;
|
||||
const { times, positions } = ent;
|
||||
|
||||
ctx.lineWidth = ent.type === 'ground' ? 2 : (this._mode === 'air' ? 2 : 1.5);
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
// Aircraft in air mode: interpolate at fixed time steps so trail
|
||||
// segments are always pixel-visible (raw path points can be sub-pixel
|
||||
// apart at full-map scale).
|
||||
if (this._mode === 'air' && ent.type === 'aircraft') {
|
||||
const step = 200; // ms between interpolated trail points
|
||||
let prevPx = null, prevPy = null;
|
||||
for (let t = Math.max(tMin, times[0]); t <= Math.min(endT, times[times.length - 1]); t += step) {
|
||||
const pos = this.getPositionAtTime(ent, t);
|
||||
if (!pos) continue;
|
||||
const [px, py] = pos;
|
||||
if (prevPx !== null) {
|
||||
const age = time - t;
|
||||
const alpha = Math.max(0.08, 1 - age / trailLen);
|
||||
ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(prevPx, prevPy);
|
||||
ctx.lineTo(px, py);
|
||||
ctx.stroke();
|
||||
}
|
||||
prevPx = px; prevPy = py;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let prevPx = null, prevPy = null;
|
||||
for (let i = 0; i < times.length; i++) {
|
||||
if (times[i] < tMin) continue;
|
||||
if (times[i] > endT) break;
|
||||
const [px, py] = this.worldToPixel(positions[i*2], positions[i*2+1]);
|
||||
if (prevPx !== null) {
|
||||
const age = time - times[i];
|
||||
const alpha = Math.max(0.08, 1 - age / trailLen);
|
||||
ctx.strokeStyle = baseColor + alpha.toFixed(2) + ')';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(prevPx, prevPy);
|
||||
ctx.lineTo(px, py);
|
||||
ctx.stroke();
|
||||
}
|
||||
prevPx = px; prevPy = py;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_drawDamageLines(ctx, time) {
|
||||
for (const dm of this.data.damages) {
|
||||
const age = time - dm.time;
|
||||
if (age < 0 || age > RC.DMG_TTL) continue;
|
||||
// Find attacker and victim positions at damage time
|
||||
const attacker = this.entities.find(e => e.playerId === dm.offenderId);
|
||||
const victim = this.entities.find(e => e.playerId === dm.offendedId);
|
||||
if (!attacker || !victim) continue;
|
||||
const aPos = this.getPositionAtTime(attacker, dm.time);
|
||||
const vPos = this.getPositionAtTime(victim, dm.time);
|
||||
if (!aPos || !vPos) continue;
|
||||
const alpha = Math.max(0, 1 - age / RC.DMG_TTL);
|
||||
ctx.globalAlpha = alpha * 0.4;
|
||||
ctx.strokeStyle = '#ffcc44';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(aPos[0], aPos[1]); ctx.lineTo(vPos[0], vPos[1]); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
_drawKillLines(ctx, time) {
|
||||
for (const k of this.data.kills) {
|
||||
const age = time - k.time;
|
||||
if (age < 0 || age > RC.KILL_TTL) continue;
|
||||
if (!k.killerPos || !k.victimPos) continue;
|
||||
const alpha = Math.max(0, 1 - age / RC.KILL_TTL);
|
||||
const [kx, ky] = this.worldToPixel(k.killerPos.x, k.killerPos.z);
|
||||
const [vx, vy] = this.worldToPixel(k.victimPos.x, k.victimPos.z);
|
||||
|
||||
ctx.globalAlpha = alpha * 0.6;
|
||||
ctx.strokeStyle = '#ff3333';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath(); ctx.moveTo(kx, ky); ctx.lineTo(vx, vy); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// X marker
|
||||
ctx.globalAlpha = alpha * 0.9;
|
||||
ctx.strokeStyle = '#ff3333';
|
||||
ctx.lineWidth = 2;
|
||||
const s = 5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(vx-s, vy-s); ctx.lineTo(vx+s, vy+s);
|
||||
ctx.moveTo(vx+s, vy-s); ctx.lineTo(vx-s, vy+s);
|
||||
ctx.stroke();
|
||||
|
||||
// Weapon label
|
||||
if (k.weapon && alpha > 0.4) {
|
||||
ctx.font = '600 9px system-ui, sans-serif';
|
||||
ctx.fillStyle = `rgba(255,230,100,${(alpha * 0.85).toFixed(2)})`;
|
||||
ctx.fillText(k.weapon, (kx+vx)/2 + 6, (ky+vy)/2 - 6);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
_drawEntities(ctx, time) {
|
||||
const hl = this.highlightedPlayerId;
|
||||
|
||||
// Draw dead entities first (fading)
|
||||
for (const ent of this.entities) {
|
||||
if (!this._isEntityDead(ent, time)) continue;
|
||||
if (this._isEntityGone(ent, time)) continue;
|
||||
const pos = ent.deathPos;
|
||||
if (!pos) continue;
|
||||
const [px, py] = pos;
|
||||
const fade = 1 - (time - ent.deathTime) / RC.GHOST_TTL;
|
||||
ctx.globalAlpha = Math.max(0, fade * 0.5);
|
||||
ctx.fillStyle = '#333';
|
||||
const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R;
|
||||
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Draw alive entities
|
||||
for (const ent of this.entities) {
|
||||
if (this._isEntityDead(ent, time)) continue;
|
||||
const pos = this.getPositionAtTime(ent, time);
|
||||
if (!pos) continue;
|
||||
const [px, py] = pos;
|
||||
if (px < -20 || py < -20 || px > this.canvasSize+20 || py > this.canvasSize+20) continue;
|
||||
|
||||
let alpha = 1;
|
||||
if (hl !== null && ent.playerId !== hl && ent.playerId !== 0) alpha = 0.25;
|
||||
|
||||
const color = ent.isWinner ? RC.WIN : RC.LOSE;
|
||||
const iconSize = ent.type === 'ground' ? 12 : ent.type === 'aircraft' ? 20 : 14;
|
||||
const iconImg = this._iconCache?.[ent._canvasIconKey];
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
// Highlight ring
|
||||
if (hl === ent.playerId && ent.playerId !== 0) {
|
||||
const hr = iconSize / 2 + 5;
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(px, py, hr, 0, Math.PI*2); ctx.stroke();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(px, py, hr - 2, 0, Math.PI*2); ctx.stroke();
|
||||
}
|
||||
|
||||
if (iconImg && iconImg.naturalWidth) {
|
||||
const s = iconSize;
|
||||
const tinted = this._getTintedIcon(ent._canvasIconKey, color, s);
|
||||
const drawSrc = tinted || iconImg;
|
||||
// Rotate aircraft/drones to face their heading
|
||||
if (ent.type === 'aircraft' || ent.type === 'drone') {
|
||||
const heading = this.getHeadingAtTime(ent, time);
|
||||
if (heading !== null) {
|
||||
ctx.save();
|
||||
ctx.translate(px, py);
|
||||
ctx.rotate(heading);
|
||||
if (tinted) ctx.drawImage(drawSrc, -s/2, -s/2);
|
||||
else this._drawContainedIcon(ctx, drawSrc, 0, 0, s);
|
||||
ctx.restore();
|
||||
} else {
|
||||
if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2);
|
||||
else this._drawContainedIcon(ctx, drawSrc, px, py, s);
|
||||
}
|
||||
} else {
|
||||
if (tinted) ctx.drawImage(drawSrc, px-s/2, py-s/2);
|
||||
else this._drawContainedIcon(ctx, drawSrc, px, py, s);
|
||||
}
|
||||
} else {
|
||||
// Fallback: circle dot
|
||||
const r = ent.type === 'ground' ? RC.DOT_R : ent.type === 'aircraft' ? RC.AIR_R : RC.DRONE_R;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; }
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
window.ReplayCanvas = ReplayCanvas;
|
||||
@@ -1,332 +0,0 @@
|
||||
// Seasons Filter Utility
|
||||
// Parses and manages season/week data from the seasons constant file
|
||||
|
||||
class SeasonsFilter {
|
||||
constructor() {
|
||||
this.seasons = [];
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
// Parse seasons text content into structured data
|
||||
parseSeasons(content) {
|
||||
const seasons = [];
|
||||
const lines = content.split('\n');
|
||||
let currentSeason = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
if (!line) continue;
|
||||
|
||||
// Check if it's a season header (e.g., "2025-V")
|
||||
const seasonMatch = line.match(/^(\d{4}-[IVX]+)$/);
|
||||
if (seasonMatch) {
|
||||
if (currentSeason) {
|
||||
seasons.push(currentSeason);
|
||||
}
|
||||
currentSeason = {
|
||||
name: seasonMatch[1],
|
||||
weeks: []
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a week line
|
||||
// Format: "week 1 (01.09 – 07.09) <t:1756735246:R>: max BR 14.0"
|
||||
// or: "until eos (27.10 – 31.10) <t:1761487200:R>: max BR 5.0"
|
||||
const weekMatch = line.match(/(?:week\s+(\d+)|until eos)\s+\((\d{2})\.(\d{2})\s*[–—]\s*(\d{2})\.(\d{2})\)\s*<t:(\d+):R>/);
|
||||
if (weekMatch && currentSeason) {
|
||||
const weekNum = weekMatch[1] ? parseInt(weekMatch[1]) : null;
|
||||
const startDay = parseInt(weekMatch[2]);
|
||||
const startMonth = parseInt(weekMatch[3]);
|
||||
const endDay = parseInt(weekMatch[4]);
|
||||
const endMonth = parseInt(weekMatch[5]);
|
||||
const timestamp = parseInt(weekMatch[6]);
|
||||
|
||||
// Extract max BR if present (preserve .0 formatting)
|
||||
const brMatch = line.match(/max BR ([\d.]+)/);
|
||||
const maxBR = brMatch ? brMatch[1] : null; // Keep as string to preserve ".0"
|
||||
|
||||
// Use the Unix timestamp directly from the file (it's already correct!)
|
||||
// The timestamp is the START of the week
|
||||
const startDate = new Date(timestamp * 1000); // Convert to milliseconds
|
||||
|
||||
// Calculate end date: 7 days from start (or until end of season for "until eos")
|
||||
// For "until eos" entries, we'll need to calculate based on the actual end day
|
||||
let endDate;
|
||||
|
||||
// Determine year from season name (e.g., "2025-V" -> 2025)
|
||||
const yearMatch = currentSeason.name.match(/^(\d{4})/);
|
||||
const year = yearMatch ? parseInt(yearMatch[1]) : new Date().getFullYear();
|
||||
|
||||
// Build end date from the parsed end day/month
|
||||
// Need to handle year rollover (e.g., December -> January)
|
||||
let endYear = year;
|
||||
if (endMonth < startMonth) {
|
||||
endYear = year + 1; // Crossed into next year
|
||||
}
|
||||
endDate = new Date(endYear, endMonth - 1, endDay, 23, 59, 59, 999);
|
||||
|
||||
// Build display name with max BR (preserve decimal formatting like 14.0)
|
||||
let displayName;
|
||||
if (weekNum) {
|
||||
displayName = `Week ${weekNum} (${weekMatch[2]}.${weekMatch[3]} – ${weekMatch[4]}.${weekMatch[5]})`;
|
||||
if (maxBR) displayName += ` - BR ${maxBR}`;
|
||||
} else {
|
||||
displayName = `Until EOS (${weekMatch[2]}.${weekMatch[3]} – ${weekMatch[4]}.${weekMatch[5]})`;
|
||||
if (maxBR) displayName += ` - BR ${maxBR}`;
|
||||
}
|
||||
|
||||
currentSeason.weeks.push({
|
||||
weekNumber: weekNum,
|
||||
name: weekNum ? `Week ${weekNum}` : 'Until EOS',
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startTimestamp: timestamp, // Store original Unix timestamp (seconds)
|
||||
maxBR: maxBR,
|
||||
displayName: displayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last season
|
||||
if (currentSeason) {
|
||||
seasons.push(currentSeason);
|
||||
}
|
||||
|
||||
// Post-process: set each week's endDate to the next week's startDate - 1ms
|
||||
// This uses the exact Unix timestamps instead of the approximate text-parsed dates
|
||||
seasons.forEach(season => {
|
||||
season.weeks.forEach((week, i) => {
|
||||
if (i + 1 < season.weeks.length) {
|
||||
week.endDate = new Date(season.weeks[i + 1].startDate.getTime() - 1);
|
||||
}
|
||||
// Last week of each season keeps its text-parsed endDate
|
||||
});
|
||||
});
|
||||
|
||||
return seasons;
|
||||
}
|
||||
|
||||
// Load seasons data from the constant file
|
||||
async loadSeasons() {
|
||||
if (this.loaded) {
|
||||
return this.seasons;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/constants/seasons');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load seasons: ${response.status}`);
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
this.seasons = this.parseSeasons(content);
|
||||
this.loaded = true;
|
||||
|
||||
console.log('[Seasons Filter] Loaded seasons:', this.seasons);
|
||||
return this.seasons;
|
||||
} catch (error) {
|
||||
console.error('[Seasons Filter] Error loading seasons:', error);
|
||||
this.seasons = [];
|
||||
this.loaded = false;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get all seasons
|
||||
getSeasons() {
|
||||
return this.seasons;
|
||||
}
|
||||
|
||||
// Get a specific season by name
|
||||
getSeason(seasonName) {
|
||||
return this.seasons.find(s => s.name === seasonName);
|
||||
}
|
||||
|
||||
// Get current season (based on current date)
|
||||
getCurrentSeason() {
|
||||
const now = new Date();
|
||||
|
||||
for (const season of this.seasons) {
|
||||
for (const week of season.weeks) {
|
||||
if (now >= week.startDate && now <= week.endDate) {
|
||||
return season;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no current season found, return the most recent one
|
||||
return this.seasons.length > 0 ? this.seasons[this.seasons.length - 1] : null;
|
||||
}
|
||||
|
||||
// Get current week (based on current date)
|
||||
getCurrentWeek() {
|
||||
const now = new Date();
|
||||
|
||||
for (const season of this.seasons) {
|
||||
for (const week of season.weeks) {
|
||||
if (now >= week.startDate && now <= week.endDate) {
|
||||
return { season, week };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get date range for a specific season
|
||||
getSeasonDateRange(seasonName) {
|
||||
const season = this.getSeason(seasonName);
|
||||
if (!season || season.weeks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: season.weeks[0].startDate,
|
||||
endDate: season.weeks[season.weeks.length - 1].endDate,
|
||||
season: season
|
||||
};
|
||||
}
|
||||
|
||||
// Get date range for a specific week in a season
|
||||
getWeekDateRange(seasonName, weekNumber) {
|
||||
const season = this.getSeason(seasonName);
|
||||
if (!season) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const week = season.weeks.find(w => w.weekNumber === weekNumber);
|
||||
if (!week) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: week.startDate,
|
||||
endDate: week.endDate,
|
||||
week: week
|
||||
};
|
||||
}
|
||||
|
||||
// Get all unique BR values across all seasons (returns array of strings, preserving ".0")
|
||||
getAllBRValues() {
|
||||
const brSet = new Set();
|
||||
|
||||
this.seasons.forEach(season => {
|
||||
season.weeks.forEach(week => {
|
||||
if (week.maxBR) {
|
||||
brSet.add(week.maxBR);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort descending (convert to float for comparison, but keep as strings)
|
||||
return Array.from(brSet).sort((a, b) => parseFloat(b) - parseFloat(a));
|
||||
}
|
||||
|
||||
// Get all weeks that match a specific BR
|
||||
getWeeksByBR(maxBR) {
|
||||
const matchingWeeks = [];
|
||||
|
||||
this.seasons.forEach(season => {
|
||||
season.weeks.forEach(week => {
|
||||
if (week.maxBR === maxBR) {
|
||||
matchingWeeks.push({
|
||||
season: season,
|
||||
week: week,
|
||||
displayName: `${season.name} - ${week.name}`
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return matchingWeeks;
|
||||
}
|
||||
|
||||
// Get combined date range for all weeks with a specific BR (for filtering multiple weeks)
|
||||
getDateRangeForBR(maxBR) {
|
||||
const weeks = this.getWeeksByBR(maxBR);
|
||||
|
||||
if (weeks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return array of date ranges (one for each week with this BR)
|
||||
return weeks.map(({ season, week }) => ({
|
||||
startDate: week.startDate,
|
||||
endDate: week.endDate,
|
||||
seasonName: season.name,
|
||||
weekName: week.name,
|
||||
displayName: `${season.name} - ${week.displayName}`
|
||||
}));
|
||||
}
|
||||
|
||||
// Populate a select element with season options
|
||||
populateSeasonSelect(selectElement, includeAllOption = true) {
|
||||
selectElement.innerHTML = '';
|
||||
|
||||
if (includeAllOption) {
|
||||
const allOption = document.createElement('option');
|
||||
allOption.value = 'all';
|
||||
allOption.textContent = window.__t ? window.__t('leaderboard.allSeasons') : 'All Seasons';
|
||||
selectElement.appendChild(allOption);
|
||||
}
|
||||
|
||||
// Add seasons in reverse order (most recent first)
|
||||
for (let i = this.seasons.length - 1; i >= 0; i--) {
|
||||
const season = this.seasons[i];
|
||||
const option = document.createElement('option');
|
||||
option.value = season.name;
|
||||
option.textContent = season.name;
|
||||
selectElement.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate a select element with BR options
|
||||
populateBRSelect(selectElement, includeAllOption = true) {
|
||||
selectElement.innerHTML = '';
|
||||
|
||||
if (includeAllOption) {
|
||||
const allOption = document.createElement('option');
|
||||
allOption.value = '';
|
||||
allOption.textContent = window.__t ? window.__t('leaderboard.allBR') : 'All BR';
|
||||
selectElement.appendChild(allOption);
|
||||
}
|
||||
|
||||
const brValues = this.getAllBRValues();
|
||||
brValues.forEach(br => {
|
||||
const option = document.createElement('option');
|
||||
option.value = br;
|
||||
option.textContent = `BR ${br}`;
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Populate a select element with week options for a given season
|
||||
populateWeekSelect(selectElement, seasonName, includeAllOption = true) {
|
||||
selectElement.innerHTML = '';
|
||||
|
||||
if (includeAllOption) {
|
||||
const allOption = document.createElement('option');
|
||||
allOption.value = 'all';
|
||||
allOption.textContent = window.__t ? window.__t('leaderboard.allWeeks') : 'All Weeks';
|
||||
selectElement.appendChild(allOption);
|
||||
}
|
||||
|
||||
const season = this.getSeason(seasonName);
|
||||
if (!season) {
|
||||
return;
|
||||
}
|
||||
|
||||
season.weeks.forEach(week => {
|
||||
const option = document.createElement('option');
|
||||
option.value = week.weekNumber !== null ? week.weekNumber.toString() : 'final';
|
||||
option.textContent = week.displayName;
|
||||
selectElement.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
window.seasonsFilter = window.seasonsFilter || new SeasonsFilter();
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
// Client-side vehicle name translator.
|
||||
//
|
||||
// Loads the localized vehicle name map produced by the bot's
|
||||
// init_vehicle_translation_cache() (BOT/utils.py) and exposes:
|
||||
//
|
||||
// 1. window.vehicleI18n.translate(internal, fallback, lang?)
|
||||
// Synchronous lookup. Returns the localized name when available,
|
||||
// otherwise English, otherwise the supplied fallback.
|
||||
//
|
||||
// 2. window.vehicleI18n.apply(root?)
|
||||
// Walks `root` (defaults to <body>) and rewrites the textContent of
|
||||
// every element carrying `data-vehicle-internal="<cdk>"` to the
|
||||
// localized name. The element's *original* text is captured into
|
||||
// `data-vehicle-fallback` on first apply so re-applies (e.g. after
|
||||
// lang switch) don't lose the fallback.
|
||||
//
|
||||
// On startup the module:
|
||||
// - Eagerly loads the multilang map (via window.apiClient — /api/* is
|
||||
// gated by the website's apiSecurityCheck middleware).
|
||||
// - On first successful load + DOMContentLoaded, runs apply(document.body)
|
||||
// and installs a MutationObserver so any future inserts auto-translate.
|
||||
//
|
||||
// Server-side EJS or client-side template strings only need to add the
|
||||
// data attribute — no per-page wiring required.
|
||||
|
||||
(function () {
|
||||
const STORAGE_KEY = 'vehicleI18nCache_v2';
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let _map = null; // { internal: { en, ru, ... } }
|
||||
let _source = null; // 'multilang' | 'english_only' | 'none'
|
||||
let _loadingPromise = null;
|
||||
let _observerInstalled = false;
|
||||
|
||||
function readCache() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !parsed.fetchedAt || (Date.now() - parsed.fetchedAt) > TTL_MS) return null;
|
||||
if (parsed.source !== 'multilang') return null;
|
||||
return parsed.vehicles || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(vehicles, source) {
|
||||
if (source !== 'multilang') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ fetchedAt: Date.now(), source, vehicles }));
|
||||
} catch (e) {
|
||||
// quota exceeded or storage disabled
|
||||
}
|
||||
}
|
||||
|
||||
function fireReady(detail) {
|
||||
// Defer to next tick so any listeners installed after this module
|
||||
// executes still see the event.
|
||||
setTimeout(() => document.dispatchEvent(new CustomEvent('vehicle-i18n-ready', { detail })), 0);
|
||||
}
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (_map) return _map;
|
||||
const cached = readCache();
|
||||
if (cached) {
|
||||
_map = cached;
|
||||
_source = 'multilang';
|
||||
fireReady({ source: 'multilang', cached: true });
|
||||
return _map;
|
||||
}
|
||||
if (_loadingPromise) return _loadingPromise;
|
||||
_loadingPromise = (async () => {
|
||||
try {
|
||||
if (!window.apiClient) {
|
||||
await new Promise((resolve) => {
|
||||
const tick = () => window.apiClient ? resolve() : setTimeout(tick, 50);
|
||||
tick();
|
||||
});
|
||||
}
|
||||
const body = await window.apiClient.request('/api/i18n/vehicles');
|
||||
_map = body && body.vehicles ? body.vehicles : {};
|
||||
_source = body && body.source ? body.source : 'none';
|
||||
writeCache(_map, _source);
|
||||
fireReady({ source: _source });
|
||||
return _map;
|
||||
} catch (e) {
|
||||
console.error('vehicle i18n load failed', e);
|
||||
_map = {};
|
||||
_source = 'none';
|
||||
return _map;
|
||||
} finally {
|
||||
_loadingPromise = null;
|
||||
}
|
||||
})();
|
||||
return _loadingPromise;
|
||||
}
|
||||
|
||||
function currentLang() {
|
||||
const m = (document.cookie || '').match(/(?:^|;\s*)lang=([\w-]+)/);
|
||||
return (m && m[1]) || (document.documentElement.lang || 'en');
|
||||
}
|
||||
|
||||
// Case-insensitive map lookup. The DB historically stored mixed casings of
|
||||
// vehicle_internal (e.g. germ_leopard_I vs germ_leopard_i), and the API
|
||||
// now lowercases them; the bot's translation cache may or may not match
|
||||
// that casing depending on its source. Try the literal key first, then
|
||||
// lowercase, then a one-time-built lowercased index for everything else.
|
||||
let _lowerIndex = null;
|
||||
function lookup(internal) {
|
||||
if (!_map || !internal) return null;
|
||||
if (_map[internal]) return _map[internal];
|
||||
const lower = String(internal).toLowerCase();
|
||||
if (_map[lower]) return _map[lower];
|
||||
if (!_lowerIndex) {
|
||||
_lowerIndex = {};
|
||||
for (const k of Object.keys(_map)) _lowerIndex[k.toLowerCase()] = _map[k];
|
||||
}
|
||||
return _lowerIndex[lower] || null;
|
||||
}
|
||||
|
||||
function translate(internal, fallback, lang) {
|
||||
const lng = lang || currentLang();
|
||||
if (!_map || !internal) return fallback || internal || '';
|
||||
const entry = lookup(internal);
|
||||
if (!entry) return fallback || internal;
|
||||
const localized = entry[lng] || entry.en || fallback || internal;
|
||||
// The player-rendered display the DB stores can have a leading
|
||||
// country-leak / event glyph (▄ ◘ ◢ ␗ etc.) that WT's client prepends
|
||||
// at draw time. The translation map only knows the canonical name
|
||||
// without that prefix, so naive replacement would lose it — making
|
||||
// e.g. "▄F-16A ADF" (Italy) and "F-16A ADF" (no leak) look identical.
|
||||
// Preserve any leading run of non-letter / non-digit / non-space chars
|
||||
// from the original fallback when the translation doesn't already
|
||||
// include it.
|
||||
if (fallback) {
|
||||
const m = String(fallback).match(/^[^\p{L}\p{N}\s]+/u);
|
||||
if (m && !localized.startsWith(m[0])) return m[0] + localized;
|
||||
}
|
||||
return localized;
|
||||
}
|
||||
|
||||
function applyToElement(el) {
|
||||
if (!el || !el.dataset || !el.dataset.vehicleInternal) return;
|
||||
const internal = el.dataset.vehicleInternal;
|
||||
if (!internal) return;
|
||||
// Capture the rendered fallback once so language re-switches still have
|
||||
// something to fall back on if the map ever loses an entry.
|
||||
if (el.dataset.vehicleFallback === undefined) {
|
||||
el.dataset.vehicleFallback = el.textContent || '';
|
||||
}
|
||||
const lng = currentLang();
|
||||
// Cache the lang we last applied so we don't fight the DOM on every
|
||||
// mutation tick.
|
||||
if (el.dataset.vehicleAppliedLang === lng) return;
|
||||
el.textContent = translate(internal, el.dataset.vehicleFallback, lng);
|
||||
el.dataset.vehicleAppliedLang = lng;
|
||||
}
|
||||
|
||||
function apply(root) {
|
||||
if (!_map) return;
|
||||
const node = root || document.body;
|
||||
if (!node) return;
|
||||
if (node.nodeType === 1 && node.dataset && node.dataset.vehicleInternal) {
|
||||
applyToElement(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll('[data-vehicle-internal]').forEach(applyToElement);
|
||||
}
|
||||
}
|
||||
|
||||
function installMutationObserver() {
|
||||
if (_observerInstalled || typeof MutationObserver === 'undefined' || !document.body) return;
|
||||
_observerInstalled = true;
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1) apply(node);
|
||||
}
|
||||
if (m.type === 'attributes' && m.target && m.target.dataset && m.target.dataset.vehicleInternal) {
|
||||
delete m.target.dataset.vehicleAppliedLang;
|
||||
applyToElement(m.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['data-vehicle-internal'],
|
||||
});
|
||||
}
|
||||
|
||||
function autoApply() {
|
||||
if (!document.body) return;
|
||||
apply(document.body);
|
||||
installMutationObserver();
|
||||
}
|
||||
|
||||
document.addEventListener('vehicle-i18n-ready', autoApply);
|
||||
|
||||
window.vehicleI18n = {
|
||||
ensureLoaded,
|
||||
translate,
|
||||
apply,
|
||||
get ready() { return _map !== null; },
|
||||
get source() { return _source; },
|
||||
get currentLang() { return currentLang(); },
|
||||
invalidate() {
|
||||
try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
|
||||
_map = null;
|
||||
_source = null;
|
||||
_lowerIndex = null;
|
||||
// Also clear apply-state markers so a refresh re-translates.
|
||||
document.querySelectorAll('[data-vehicle-applied-lang]').forEach(el => {
|
||||
delete el.dataset.vehicleAppliedLang;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Kick off load. apply() runs from the ready event handler above.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', ensureLoaded);
|
||||
} else {
|
||||
ensureLoaded();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user