Football / src /app /core.js
Prataykarali's picture
Improve accessibility and quality guardrails
e016d0b
Raw
History Blame Contribute Delete
17.1 kB
import { MatchFeed } from '../services/matchFeed.js';
import { CommentaryService } from '../services/commentary.js';
import { PlayerCardService } from '../services/playerCard.js';
import { NightOwlService } from '../services/nightOwl.js';
import { CrowdPulseService } from '../services/crowdPulse.js';
import { VenueMapService } from '../services/venueMap.js';
import { MatchProofService } from '../services/matchProof.js';
import { SustainabilityService } from '../services/sustainability.js';
import { StaffPanel } from '../components/StaffPanel.js';
import { SAMPLE_MATCH_EVENTS } from '../data/sampleMatch.js';
import { DEFAULT_MATCH_SPEED } from './constants.js';
import { Toast } from '../components/Toast.js';
import { SettingsPanel } from '../components/SettingsPanel.js';
import { setHTML } from '../utils/dom.js';
import { setTTSEnabled, speak as speakTTS } from '../utils/tts.js';
import { gsap } from 'gsap';
export const coreMethods = {
_handleRoute() {
const hash = window.location.hash.replace('#/', '') || 'home';
const validPages = ['home', 'live', 'highlights', 'predictions', 'standings', 'venue'];
const page = validPages.includes(hash) ? hash : 'home';
this._showPage(page);
},
_showPage(pageId) {
document.querySelectorAll('.page').forEach(p => {
p.classList.remove('page--active');
p.style.animation = 'none';
});
const page = document.getElementById(`page-${pageId}`);
if (page) {
page.classList.add('page--active');
page.style.animation = '';
void page.offsetHeight;
page.style.animation = 'pageIn 0.6s var(--ease-out-expo) forwards';
}
document.querySelectorAll('.site-nav__link').forEach(link => {
const isActive = link.dataset.page === pageId;
link.classList.toggle('site-nav__link--active', isActive);
if (isActive) {
link.setAttribute('aria-current', 'page');
} else {
link.removeAttribute('aria-current');
}
});
this.currentPage = pageId;
window.scrollTo({ top: 0, behavior: 'smooth' });
document.getElementById('main-content')?.focus?.({ preventScroll: true });
this._initPageContent(pageId);
},
_initPageContent(pageId) {
switch (pageId) {
case 'home':
this._initHomePage();
break;
case 'live':
this._initLivePage();
break;
case 'highlights':
this._initHighlightsPage();
break;
case 'predictions':
this._initPredictionsPage();
break;
case 'standings':
this._initStandingsPage();
break;
case 'venue':
this._initVenuePage();
break;
}
setTimeout(() => this._initScrollAnimations(), 100);
},
// ─── Navigation ─────────────────────────────────────────
_bindNavigation() {
document.querySelectorAll('[data-nav]').forEach(el => {
el.addEventListener('click', () => {
window.location.hash = '#/home';
});
});
const hamburger = document.getElementById('nav-hamburger');
const links = document.getElementById('nav-links');
if (hamburger && links) {
const toggleMenu = () => {
const isOpen = links.classList.toggle('open');
hamburger.setAttribute('aria-expanded', isOpen.toString());
};
hamburger.addEventListener('click', toggleMenu);
links.querySelectorAll('.site-nav__link').forEach(link => {
link.addEventListener('click', () => {
links.classList.remove('open');
hamburger.setAttribute('aria-expanded', 'false');
});
});
}
window.addEventListener('scroll', () => {
const nav = document.getElementById('site-nav');
if (nav) {
nav.classList.toggle('scrolled', window.scrollY > 50);
}
});
},
// ─── Theme Switcher ─────────────────────────────────────
_bindThemeSwitcher() {
const switcher = document.getElementById('theme-switcher');
if (!switcher) return;
const savedTheme = this._normalizeTheme(typeof localStorage === 'undefined'
? 'neon'
: localStorage.getItem('vantage_theme') || 'neon');
this._applyTheme(savedTheme);
const dots = switcher.querySelectorAll('.theme-dot');
const applySelection = (dot, { focus = false } = {}) => {
const theme = dot.dataset.theme;
this._applyTheme(theme);
dots.forEach(d => {
const isActive = d === dot;
d.classList.toggle('theme-dot--active', isActive);
d.setAttribute('aria-checked', isActive.toString());
d.tabIndex = isActive ? 0 : -1;
});
if (typeof localStorage !== 'undefined') {
localStorage.setItem('vantage_theme', theme);
}
if (focus) dot.focus?.();
Toast.show({ message: `Theme: ${theme}`, type: 'info', duration: 2000 });
};
dots.forEach(dot => {
const isCurrent = dot.dataset.theme === savedTheme;
dot.classList.toggle('theme-dot--active', isCurrent);
dot.setAttribute('aria-checked', isCurrent.toString());
dot.tabIndex = isCurrent ? 0 : -1;
const activateTheme = () => {
applySelection(dot);
};
dot.addEventListener('click', activateTheme);
dot.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activateTheme();
} else if (['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
e.preventDefault();
const currentIndex = [...dots].indexOf(dot);
let nextIndex = currentIndex;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') nextIndex = (currentIndex + 1) % dots.length;
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') nextIndex = (currentIndex - 1 + dots.length) % dots.length;
if (e.key === 'Home') nextIndex = 0;
if (e.key === 'End') nextIndex = dots.length - 1;
applySelection(dots[nextIndex], { focus: true });
}
});
});
},
_normalizeTheme(theme = 'neon') {
const allowedThemes = ['neon', 'cyber', 'nightowl', 'rose', 'purple', 'light'];
return allowedThemes.includes(theme) ? theme : 'neon';
},
_applyTheme(theme) {
const safeTheme = this._normalizeTheme(theme);
document.body.classList.forEach(cls => {
if (cls.startsWith('theme-')) document.body.classList.remove(cls);
});
if (safeTheme !== 'neon') {
document.body.classList.add(`theme-${safeTheme}`);
}
this._syncThemeSwitcher(safeTheme);
return safeTheme;
},
_syncThemeSwitcher(theme = 'neon') {
const safeTheme = this._normalizeTheme(theme);
document.querySelectorAll('#theme-switcher .theme-dot').forEach(dot => {
const isActive = dot.dataset.theme === safeTheme;
dot.classList.toggle('theme-dot--active', isActive);
dot.setAttribute('aria-checked', isActive.toString());
dot.tabIndex = isActive ? 0 : -1;
});
},
// ─── Settings Panel ────────────────────────────────────
_initSettingsPanel() {
const container = document.getElementById('settings-container');
if (!container) return;
this.settingsPanel = new SettingsPanel(container);
this.settingsPanel.render();
this.settingsPanel.onSettingsChange((next) => this._applySettings(next));
const btn = document.getElementById('btn-settings');
btn?.addEventListener('click', () => {
const isOpen = !container.hidden;
container.hidden = isOpen;
btn.setAttribute('aria-expanded', (!isOpen).toString());
if (!isOpen) container.focus?.();
});
container.addEventListener?.('vantage:settings-close', () => {
container.hidden = true;
btn?.setAttribute('aria-expanded', 'false');
btn?.focus?.();
});
// Apply the persisted settings once on boot so toggles stay in sync.
this._applySettings(this.settings);
},
_applySettings(next = {}) {
this.settings = { ...this.settings, ...next };
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('vantage_settings', JSON.stringify(this.settings));
} catch (error) {
console.warn('Settings could not be persisted.', error);
}
}
const body = document.body;
body.classList.toggle('reduced-motion', Boolean(this.settings.visionImpaired));
body.classList.toggle('high-contrast', Boolean(this.settings.visionImpaired));
body.classList.toggle('hearing-impaired', Boolean(this.settings.hearingImpaired));
if (this.settings.theme) {
this.settings.theme = this._applyTheme(this.settings.theme);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('vantage_theme', this.settings.theme);
}
}
setTTSEnabled(Boolean(this.settings.visionImpaired));
// Reflect toggle state back into the panel checkboxes if they exist.
const panel = this.settingsPanel?.containerEl;
if (panel) {
const hearing = panel.querySelector('#toggle-hearing');
const vision = panel.querySelector('#toggle-vision');
const staff = panel.querySelector('#toggle-staff');
hearing?.classList.toggle('toggle--active', Boolean(this.settings.hearingImpaired));
hearing?.setAttribute('aria-checked', Boolean(this.settings.hearingImpaired).toString());
vision?.classList.toggle('toggle--active', Boolean(this.settings.visionImpaired));
vision?.setAttribute('aria-checked', Boolean(this.settings.visionImpaired).toString());
staff?.classList.toggle('toggle--active', Boolean(this.settings.staffMode));
staff?.setAttribute('aria-checked', Boolean(this.settings.staffMode).toString());
const lang = panel.querySelector('#setting-lang');
if (lang && this.settings.language) lang.value = this.settings.language;
const pace = panel.querySelector('#setting-pace');
if (pace && this.settings.pace) pace.value = this.settings.pace;
const register = panel.querySelector('#setting-register');
if (register && this.settings.register) register.value = this.settings.register;
const theme = panel.querySelector('#setting-theme');
if (theme && this.settings.theme) theme.value = this.settings.theme;
}
const staffContainer = document.getElementById('staff-command-container');
if (staffContainer) {
staffContainer.hidden = !this.settings.staffMode;
if (this.settings.staffMode) {
if (!this.staffPanel) {
this.staffPanel = new StaffPanel(staffContainer, this);
}
this.staffPanel.render();
}
}
},
_speak(text) {
if (this.settings?.visionImpaired) speakTTS(text, { lang: this.settings?.language || 'en' });
},
// ─── Services ───────────────────────────────────────────
_initServices() {
this.commentaryService = new CommentaryService();
this.playerCardService = new PlayerCardService();
this.venueMapService = new VenueMapService();
this.matchProofService = new MatchProofService();
this.sustainabilityService = new SustainabilityService();
const initialSpeed = parseInt(document.getElementById('speed-select')?.value || `${DEFAULT_MATCH_SPEED}`, 10);
this.matchFeed = new MatchFeed(SAMPLE_MATCH_EVENTS, initialSpeed);
this.nightOwlService = new NightOwlService(this.matchFeed);
this.crowdPulseService = new CrowdPulseService(this.matchFeed);
},
async _loadApiStatus() {
try {
const response = await fetch('/api/status');
this.apiStatus = response.ok ? await response.json() : null;
} catch (error) {
console.warn('API status unavailable; using offline service banner.', error);
this.apiStatus = null;
}
},
_renderApiStatus() {
const el = document.getElementById('api-status-banner');
if (!el) return;
const isGeminiOnline = this.apiStatus?.services?.gemini?.configured;
setHTML(el, `
<div class="api-status-card__title">Live Services</div>
<div class="api-status-card__body">
Commentary: ${isGeminiOnline ? '<strong style="color:var(--accent-green);">Gemini 2.5 Live Active</strong>' : 'fallback mode'}<br>
GPS: browser location + local route fallback<br>
Quiz/leaderboard: local + backend fallback<br>
Maps/weather: local stadium data
</div>
`);
if (isGeminiOnline) {
el.classList.remove('api-status-card--offline');
el.classList.add('api-status-card--online');
} else {
el.classList.remove('api-status-card--online');
el.classList.add('api-status-card--offline');
}
},
_startManagedInterval(key, callback, delayMs) {
this._clearManagedInterval(key);
const intervalId = setInterval(callback, delayMs);
this._managedIntervals.set(key, intervalId);
return intervalId;
},
_clearManagedInterval(key) {
const intervalId = this._managedIntervals.get(key);
if (intervalId !== undefined) {
clearInterval(intervalId);
this._managedIntervals.delete(key);
}
},
_clearManagedIntervals() {
this._managedIntervals.forEach((intervalId) => clearInterval(intervalId));
this._managedIntervals.clear();
},
// ─── HOME PAGE ──────────────────────────────────────────
_initScrollAnimations() {
// All elements are visible by default via CSS
// This method is kept for future scroll-triggered enhancements
},
// ─── Hero Particles ─────────────────────────────────────
_initParticles() {
const container = document.getElementById('hero-particles');
if (!container) return;
for (let i = 0; i < 30; i++) {
const particle = document.createElement('div');
particle.className = 'hero__particle';
particle.style.left = `${Math.random() * 100}%`;
particle.style.animationDelay = `${Math.random() * 6}s`;
particle.style.animationDuration = `${4 + Math.random() * 4}s`;
particle.style.width = `${1 + Math.random() * 2}px`;
particle.style.height = particle.style.width;
container.appendChild(particle);
}
},
// ─── Hero Animation ─────────────────────────────────────
_animateHero() {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
tl.from('.hero__badge', { y: 20, opacity: 0, duration: 0.6, delay: 0.3 })
.from('.hero__title-line', { y: 40, opacity: 0, duration: 0.7, stagger: 0.12 }, '-=0.3')
.from('.hero__subtitle', { y: 20, opacity: 0, duration: 0.6 }, '-=0.3')
.from('.hero__cta', { y: 20, opacity: 0, duration: 0.5 }, '-=0.2')
.from('.feature-card', { y: 30, opacity: 0, duration: 0.5, stagger: 0.1 }, '-=0.3')
.from('.hero__scroll', { opacity: 0, duration: 0.5 }, '-=0.2');
const hero = document.getElementById('hero');
if (hero) {
hero.addEventListener('mousemove', (e) => {
const x = (e.clientX / window.innerWidth - 0.5) * 10;
const y = (e.clientY / window.innerHeight - 0.5) * 10;
gsap.to('.hero__video', { x: x * 0.5, y: y * 0.5, duration: 0.8, ease: 'power2.out' });
});
}
},
// ─── Settings Persistence ───────────────────────────────
_loadSettings() {
if (typeof localStorage === 'undefined') {
return this._defaultSettings();
}
try {
const saved = localStorage.getItem('vantage_settings');
if (saved) return JSON.parse(saved);
} catch (error) {
console.warn('Saved settings could not be read; using defaults.', error);
}
return this._defaultSettings();
},
_defaultSettings() {
return {
language: 'en',
pace: 'medium',
register: 'casual',
hearingImpaired: false,
visionImpaired: false,
nightOwl: false,
notifications: true,
playbackSpeed: 10,
theme: 'neon',
};
},
destroy() {
this._liveEventsAbortController?.abort?.();
this._liveEventsAbortController = null;
this._clearManagedIntervals();
if (this._tacticalPitchAnimationFrame) {
cancelAnimationFrame(this._tacticalPitchAnimationFrame);
this._tacticalPitchAnimationFrame = null;
}
if (this._tacticalPitchResizeHandler) {
window.removeEventListener('resize', this._tacticalPitchResizeHandler);
this._tacticalPitchResizeHandler = null;
}
if (this._leafletMap) {
this._leafletMap.remove();
this._leafletMap = null;
}
this.matchFeed?.destroy();
this.nightOwlService?.deactivate();
this.videoPlayer?.destroy();
}
};