1454 lines
57 KiB
JavaScript
1454 lines
57 KiB
JavaScript
/**
|
||
* FLOCKRADAR - LIVE MAPPING & PROXIMITY ALERT ENGINE
|
||
* Full-screen Waze-style radar map with red camera recording zones,
|
||
* real-time proximity calculation, audio warning synthesizer, and drive simulator.
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// --- CONFIGURATION & CAMERA CLUSTERS ---
|
||
// Key metropolitan corridors with realistic camera intersection networks
|
||
const DEFAULT_CAMERAS = [
|
||
// === PALMDALE & ANTELOPE VALLEY, CA (LOCAL SURVEILLANCE CORRIDORS) ===
|
||
{ id: 'cam-plm-1', name: 'Palmdale Blvd & 10th St W (SR-14 Commercial Corridor)', lat: 34.60194, lng: -118.14847, radius: 320, agency: 'City of Palmdale / LASD', slug: 'palmdale-ca-lasd', cameras: 6, captures30d: '385,000' },
|
||
{ id: 'cam-plm-2', name: 'Palmdale Blvd & 20th St E (East Gateway)', lat: 34.60230, lng: -118.10305, radius: 300, agency: 'City of Palmdale / LASD', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '295,000' },
|
||
{ id: 'cam-plm-3', name: 'Ave R & 47th St E (East Palmdale Hub)', lat: 34.58081, lng: -118.04497, radius: 300, agency: 'City of Palmdale / LASD', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '240,000' },
|
||
{ id: 'cam-plm-4', name: '10th St E & Ave R (Palmdale Civic Center)', lat: 34.57985, lng: -118.11677, radius: 280, agency: 'Palmdale Sheriff Station', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '310,000' },
|
||
{ id: 'cam-plm-5', name: 'Ave S & 10th St W (Pelona Vista / Retail)', lat: 34.56829, lng: -118.14702, radius: 300, agency: 'Los Angeles County Sheriff', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '330,000' },
|
||
{ id: 'cam-plm-6', name: 'Ave S & 25th St E', lat: 34.55799, lng: -118.08278, radius: 280, agency: 'Flock Safety / LASD', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '215,000' },
|
||
{ id: 'cam-plm-7', name: 'Ave S & 40th St E', lat: 34.55821, lng: -118.04760, radius: 280, agency: 'Flock Safety / LASD', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '190,000' },
|
||
{ id: 'cam-plm-8', name: 'Rancho Vista Blvd & 15th St W (AV Mall & Amphitheater)', lat: 34.63179, lng: -118.12594, radius: 350, agency: 'City of Palmdale / LASD', slug: 'palmdale-ca-lasd', cameras: 6, captures30d: '410,000' },
|
||
{ id: 'cam-plm-9', name: 'Palmdale Blvd & Highland High / 30th St W', lat: 34.60164, lng: -118.18153, radius: 300, agency: 'City of Palmdale / LASD', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '260,000' },
|
||
{ id: 'cam-plm-10', name: 'Pearblossom Hwy & Sierra Hwy (South Gateway)', lat: 34.53625, lng: -118.08431, radius: 320, agency: 'Los Angeles County Sheriff', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '345,000' },
|
||
{ id: 'cam-plm-11', name: 'Technology Dr & 10th St W (Auto Center Hub)', lat: 34.59852, lng: -118.14467, radius: 280, agency: 'Commercial ALPR Network', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '280,000' },
|
||
{ id: 'cam-plm-12', name: 'Sierra Hwy & Palmdale Blvd (Transit Station)', lat: 34.58259, lng: -118.13690, radius: 280, agency: 'Palmdale Transit Hub', slug: 'palmdale-ca-lasd', cameras: 4, captures30d: '290,000' },
|
||
|
||
// === LANCASTER, CA (ANTELOPE VALLEY NORTH) ===
|
||
{ id: 'cam-lan-1', name: 'Ave L & 10th St W (Lancaster Commercial)', lat: 34.69718, lng: -118.14770, radius: 320, agency: 'City of Lancaster / LASD', slug: 'lancaster-ca-lasd', cameras: 6, captures30d: '390,000' },
|
||
{ id: 'cam-lan-2', name: 'Ave K & 30th St W (Lancaster West)', lat: 34.68925, lng: -118.18368, radius: 300, agency: 'City of Lancaster / LASD', slug: 'lancaster-ca-lasd', cameras: 4, captures30d: '275,000' },
|
||
{ id: 'cam-lan-3', name: 'Ave J & 10th St W (Lancaster Central)', lat: 34.67398, lng: -118.14664, radius: 320, agency: 'City of Lancaster / LASD', slug: 'lancaster-ca-lasd', cameras: 6, captures30d: '360,000' },
|
||
{ id: 'cam-lan-4', name: 'The BLVD & Sierra Hwy (Lancaster Downtown)', lat: 34.69838, lng: -118.13727, radius: 280, agency: 'Lancaster Downtown BLVD', slug: 'lancaster-ca-lasd', cameras: 4, captures30d: '310,000' },
|
||
|
||
// === NATIONAL DEMO CORRIDORS (Lexington, KY) ===
|
||
{ id: 'cam-lex-1', name: 'Lexington PD - New Circle Rd & Nicholasville Rd', lat: 37.9942, lng: -84.5268, radius: 300, agency: 'Lexington Police Department', slug: 'lexington-ky-pd', cameras: 4, captures30d: '284,500' },
|
||
{ id: 'cam-lex-2', name: 'Lexington PD - Man o War Blvd & Harrodsburg Rd', lat: 37.9785, lng: -84.5582, radius: 320, agency: 'Lexington Police Department', slug: 'lexington-ky-pd', cameras: 6, captures30d: '312,000' },
|
||
{ id: 'cam-lex-3', name: 'Lexington PD - Richmond Rd & New Circle', lat: 38.0125, lng: -84.4715, radius: 280, agency: 'Lexington Police Department', slug: 'lexington-ky-pd', cameras: 4, captures30d: '265,100' },
|
||
{ id: 'cam-lex-4', name: 'Lexington PD - Downtown High St & S Broadway', lat: 38.0435, lng: -84.5020, radius: 250, agency: 'Lexington Police Department', slug: 'lexington-ky-pd', cameras: 2, captures30d: '198,400' },
|
||
{ id: 'cam-lex-5', name: 'Lexington PD - Russell Cave Rd & I-75', lat: 38.0815, lng: -84.4710, radius: 350, agency: 'Lexington Police Department', slug: 'lexington-ky-pd', cameras: 8, captures30d: '450,200' },
|
||
|
||
// Atlanta / Cobb County / Marietta, GA
|
||
{ id: 'cam-atl-1', name: 'Cobb County SO - Cobb Pkwy & Windy Hill Rd', lat: 33.9052, lng: -84.4789, radius: 350, agency: 'Cobb County Sheriff', slug: 'cobb-county-ga', cameras: 6, captures30d: '420,000' },
|
||
{ id: 'cam-atl-2', name: 'Marietta PD - Roswell St & Fairground St', lat: 33.9482, lng: -84.5365, radius: 300, agency: 'Marietta Police Department', slug: 'marietta-ga-pd', cameras: 4, captures30d: '290,000' },
|
||
{ id: 'cam-atl-3', name: 'Sandy Springs PD - Roswell Rd & Abernathy Rd', lat: 33.9312, lng: -84.3789, radius: 320, agency: 'Sandy Springs Police', slug: 'sandy-springs-ga-pd', cameras: 4, captures30d: '340,000' },
|
||
|
||
// California - Bay Area & Los Angeles
|
||
{ id: 'cam-cal-1', name: 'Vallejo PD - Sonoma Blvd & Redwood St', lat: 38.1125, lng: -122.2541, radius: 320, agency: 'Vallejo Police Department', slug: 'vallejo-ca-pd', cameras: 4, captures30d: '310,000' },
|
||
{ id: 'cam-cal-2', name: 'San Mateo County SO - El Camino Real & 5th Ave', lat: 37.5612, lng: -122.3215, radius: 300, agency: 'San Mateo County SO', slug: 'san-mateo-ca-so', cameras: 4, captures30d: '280,000' },
|
||
{ id: 'cam-cal-3', name: 'Glendale PD - Brand Blvd & Colorado St', lat: 34.1445, lng: -118.2555, radius: 280, agency: 'Glendale Police Department', slug: 'glendale-az-pd', cameras: 4, captures30d: '260,000' },
|
||
|
||
// Texas - Fort Worth / Dallas Metro
|
||
{ id: 'cam-dfw-1', name: 'Fort Worth PD - W 7th St & University Dr', lat: 32.7512, lng: -97.3562, radius: 320, agency: 'Fort Worth Police Department', slug: 'fort-worth-tx-pd', cameras: 6, captures30d: '390,000' },
|
||
{ id: 'cam-dfw-2', name: 'Fort Worth PD - Camp Bowie Blvd & Montgomery', lat: 32.7485, lng: -97.3685, radius: 300, agency: 'Fort Worth Police Department', slug: 'fort-worth-tx-pd', cameras: 4, captures30d: '310,000' }
|
||
];
|
||
|
||
// Simulated Drive Route: Palmdale Boulevard Corridor (Heading East past 10th St W & Sierra Hwy)
|
||
const SIM_ROUTE = [
|
||
{ lat: 34.60194, lng: -118.1680, speed: 45, label: 'Palmdale Blvd - Approaching 15th St W' },
|
||
{ lat: 34.60194, lng: -118.1580, speed: 42, label: 'Palmdale Blvd - Approaching SR-14 Freeway' },
|
||
{ lat: 34.60194, lng: -118.1525, speed: 38, label: 'ENTERING FLOCK CAMERA RECORDED ZONE' },
|
||
{ lat: 34.60194, lng: -118.14847, speed: 30, label: 'AT FLOCK CAMERA INTERSECTION (Palmdale Blvd & 10th St W)' },
|
||
{ lat: 34.60194, lng: -118.1420, speed: 35, label: 'Passing 5th St W' },
|
||
{ lat: 34.60194, lng: -118.1360, speed: 32, label: 'Approaching Sierra Hwy & Metrolink Crossing' },
|
||
{ lat: 34.60194, lng: -118.1250, speed: 40, label: 'Palmdale Blvd & 5th St E' },
|
||
{ lat: 34.60210, lng: -118.1150, speed: 42, label: 'Palmdale Blvd & 10th St E' },
|
||
{ lat: 34.60220, lng: -118.1080, speed: 38, label: 'Approaching East Palmdale Surveillance Grid' },
|
||
{ lat: 34.60230, lng: -118.10305, speed: 32, label: 'AT 20TH ST E FLOCK RECORDING INTERSECTION' },
|
||
{ lat: 34.60230, lng: -118.0950, speed: 45, label: 'Heading East towards 30th St E' }
|
||
];
|
||
|
||
// Default Location: Palmdale, California
|
||
const PALMDALE_DEFAULT = { lat: 34.60194, lng: -118.14847 };
|
||
|
||
// --- STATE ---
|
||
let map = null;
|
||
let darkTileLayer = null;
|
||
let satelliteLayer = null;
|
||
let satelliteLabelsLayer = null;
|
||
let isSatelliteActive = false;
|
||
let allCameras = [];
|
||
let cameraCircles = [];
|
||
let userMarker = null;
|
||
let currentPosition = { ...PALMDALE_DEFAULT };
|
||
let currentSpeed = 0;
|
||
let isInZone = false;
|
||
let nearestCamDistanceFt = 9999;
|
||
let audioContext = null;
|
||
let lastChimeTime = 0;
|
||
let isSimulating = false;
|
||
let simIndex = 0;
|
||
let simAnimId = null;
|
||
let simProgress = 0;
|
||
|
||
// New 5% Driving Mode Enhancements
|
||
let isFollowCarLocked = true;
|
||
let radarRangeMode = 'city'; // 'city' (1,000 ft) or 'highway' (2,500 ft)
|
||
let lastCautionSpokenCamId = null;
|
||
|
||
// --- DOM REFERENCES ---
|
||
const el = {
|
||
radarView: document.getElementById('radar-view'),
|
||
directoryView: document.getElementById('directory-view'),
|
||
navMapBtn: document.getElementById('nav-map-btn'),
|
||
navDirBtn: document.getElementById('nav-dir-btn'),
|
||
driverProfileBtn: document.getElementById('driver-profile-btn'),
|
||
userProfilePill: document.getElementById('user-profile-pill'),
|
||
|
||
// HUD Elements
|
||
hudZoneBadge: document.getElementById('hud-zone-badge'),
|
||
hudSpeedVal: document.getElementById('hud-speed-val'),
|
||
hudDistVal: document.getElementById('hud-dist-val'),
|
||
hudDistSub: document.getElementById('hud-dist-sub'),
|
||
hudCamAgency: document.getElementById('hud-cam-agency'),
|
||
hudBannerAlert: document.getElementById('hud-banner-alert'),
|
||
hudSpottedBadge: document.getElementById('hud-spotted-badge'),
|
||
hudSpottedText: document.getElementById('hud-spotted-text'),
|
||
hudGaugeState: document.getElementById('hud-gauge-state'),
|
||
hudSignalMeter: document.getElementById('hud-signal-meter'),
|
||
|
||
// Quick Location Buttons & Search
|
||
btnLocateMe: document.getElementById('btn-locate-me'),
|
||
btnLocPalmdale: document.getElementById('btn-loc-palmdale'),
|
||
btnLocLancaster: document.getElementById('btn-loc-lancaster'),
|
||
btnLocPalmdaleEast: document.getElementById('btn-loc-palmdale-east'),
|
||
btnLocPalmdaleSouth: document.getElementById('btn-loc-palmdale-south'),
|
||
btnLocRancho: document.getElementById('btn-loc-rancho'),
|
||
mapSearchForm: document.getElementById('map-search-form'),
|
||
mapSearchInput: document.getElementById('map-search-input'),
|
||
|
||
// Sensitivity & Driving Controls
|
||
btnSensCity: document.getElementById('btn-sens-city'),
|
||
btnSensHwy: document.getElementById('btn-sens-hwy'),
|
||
btnFollowCar: document.getElementById('btn-follow-car'),
|
||
btnFullscreenMount: document.getElementById('btn-fullscreen-mount'),
|
||
btnRecenterCar: document.getElementById('btn-recenter-car'),
|
||
btnQuickLogCam: document.getElementById('btn-quick-log-cam'),
|
||
|
||
// Controls
|
||
btnLiveGps: document.getElementById('btn-live-gps'),
|
||
btnMapLayer: document.getElementById('btn-map-layer'),
|
||
btnTestCamera: document.getElementById('btn-test-camera'),
|
||
btnTestAlert: document.getElementById('btn-test-alert'),
|
||
btnSimDrive: document.getElementById('btn-sim-drive'),
|
||
btnResetSim: document.getElementById('btn-reset-sim'),
|
||
btnMuteSound: document.getElementById('btn-mute-sound'),
|
||
btnMobileConnect: document.getElementById('btn-mobile-connect'),
|
||
btnReportCameraFab: document.getElementById('btn-report-camera-fab'),
|
||
|
||
// Modals
|
||
loginModal: document.getElementById('login-modal'),
|
||
reportModal: document.getElementById('report-modal'),
|
||
loginForm: document.getElementById('login-form'),
|
||
reportForm: document.getElementById('report-form'),
|
||
loginCloseBtn: document.getElementById('login-close-btn'),
|
||
reportCloseBtn: document.getElementById('report-close-btn'),
|
||
loginGuestBtn: document.getElementById('login-guest-btn')
|
||
};
|
||
|
||
// --- INITIALIZATION ---
|
||
function init() {
|
||
setupViewSwitching();
|
||
initLeafletMap();
|
||
loadAllCameraData();
|
||
renderCameraZones();
|
||
setupUserMarker();
|
||
setupHud();
|
||
bindEvents();
|
||
updateProfileDisplay();
|
||
|
||
// Auto-detect real location immediately (Palmdale / GPS)
|
||
attemptAutoLocate();
|
||
|
||
// Start proximity ticker (runs locally every 300ms, zero API cost)
|
||
setInterval(checkProximityToCameras, 300);
|
||
}
|
||
|
||
// --- NAVIGATION VIEW SWITCHER ---
|
||
function setupViewSwitching() {
|
||
el.navMapBtn.addEventListener('click', () => switchView('map'));
|
||
el.navDirBtn.addEventListener('click', () => switchView('dir'));
|
||
}
|
||
|
||
function switchView(view) {
|
||
if (view === 'map') {
|
||
el.radarView.style.display = 'block';
|
||
el.directoryView.style.display = 'none';
|
||
el.navMapBtn.classList.add('active');
|
||
el.navDirBtn.classList.remove('active');
|
||
|
||
setTimeout(() => {
|
||
if (map) map.invalidateSize();
|
||
}, 100);
|
||
} else {
|
||
el.radarView.style.display = 'none';
|
||
el.directoryView.style.display = 'block';
|
||
el.navMapBtn.classList.remove('active');
|
||
el.navDirBtn.classList.add('active');
|
||
}
|
||
}
|
||
|
||
// --- LEAFLET MAP SETUP (HIGH-RESOLUTION ZERO-WATERMARK RADAR DARK MODE) ---
|
||
function initLeafletMap() {
|
||
// Center initially on Palmdale, California
|
||
map = L.map('radar-map', {
|
||
center: [34.5950, -118.1350],
|
||
zoom: 14,
|
||
zoomControl: false,
|
||
attributionControl: false
|
||
});
|
||
|
||
// High-Resolution Radar Dark Tiles (Zero API Key, Zero Watermarks, Sub-Meter Street Clarity up to Zoom 19)
|
||
darkTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
subdomains: 'abc',
|
||
maxZoom: 19,
|
||
className: 'dark-radar-tile',
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
}).addTo(map);
|
||
|
||
// Prepare optional Satellite Layer (Zero API Key, Zero Watermarks)
|
||
satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||
maxNativeZoom: 18,
|
||
maxZoom: 19,
|
||
attribution: '© Esri, Maxar, Earthstar Geographics'
|
||
});
|
||
|
||
// High-contrast street labels overlay for satellite view (Zero Watermarks)
|
||
satelliteLabelsLayer = L.tileLayer('https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', {
|
||
maxNativeZoom: 18,
|
||
maxZoom: 19
|
||
});
|
||
|
||
// Minimal zoom controls on bottom-left
|
||
L.control.zoom({ position: 'bottomleft' }).addTo(map);
|
||
|
||
// When the user drags the map, pause auto-centering and show Recenter button
|
||
map.on('dragstart', () => {
|
||
if (isFollowCarLocked) {
|
||
isFollowCarLocked = false;
|
||
updateFollowCarUI();
|
||
}
|
||
});
|
||
}
|
||
|
||
// --- LOAD CAMERA DATA (DEFAULTS + COMMUNITY + MUNICIPAL HUBS) ---
|
||
function loadAllCameraData() {
|
||
allCameras = [...DEFAULT_CAMERAS];
|
||
|
||
// Load crowdsourced reports from auth store
|
||
if (window.FlockAuth) {
|
||
const reports = window.FlockAuth.getReports();
|
||
reports.forEach(r => {
|
||
allCameras.push({
|
||
id: r.id,
|
||
name: `Community Report: ${r.type}`,
|
||
lat: r.lat,
|
||
lng: r.lng,
|
||
radius: 280,
|
||
agency: 'Crowdsourced Report',
|
||
slug: '',
|
||
cameras: 1,
|
||
captures30d: 'Active Surveillance Zone',
|
||
isCommunity: true,
|
||
reportedBy: r.reportedBy,
|
||
notes: r.notes
|
||
});
|
||
});
|
||
}
|
||
|
||
// Load ALL 1,439 geocoded portals from national dataset as active radar zones
|
||
if (window.FLOCK_DATA && window.FLOCK_DATA.portals) {
|
||
window.FLOCK_DATA.portals.forEach(p => {
|
||
if (p.lat && p.lng && !allCameras.some(c => c.slug === p.slug)) {
|
||
const count = p.cameras || 4;
|
||
const radiusMeters = Math.min(600, Math.max(250, Math.round(Math.sqrt(count) * 45)));
|
||
allCameras.push({
|
||
id: 'portal-' + p.slug,
|
||
name: `${p.name} (${p.location}, ${p.state})`,
|
||
lat: p.lat,
|
||
lng: p.lng,
|
||
radius: radiusMeters,
|
||
agency: p.name,
|
||
slug: p.slug,
|
||
cameras: count,
|
||
captures30d: (p.vehicles_captured ? p.vehicles_captured.toLocaleString() : 'Active')
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- RENDER RED RADAR CIRCLES & CAMERA NODES ---
|
||
function renderCameraZones() {
|
||
// Clear existing
|
||
cameraCircles.forEach(c => map.removeLayer(c));
|
||
cameraCircles = [];
|
||
|
||
allCameras.forEach(cam => {
|
||
// 1. Red Radar Recording Zone Circle (Translucent buffer)
|
||
const radarCircle = L.circle([cam.lat, cam.lng], {
|
||
radius: cam.radius,
|
||
color: '#ef4444',
|
||
weight: 1.5,
|
||
opacity: 0.8,
|
||
fillColor: '#ef4444',
|
||
fillOpacity: 0.18,
|
||
className: 'flock-radar-circle'
|
||
}).addTo(map);
|
||
|
||
// 2. Camera Center Node Marker
|
||
const iconHtml = `
|
||
<div class="camera-map-pin ${cam.isCommunity ? 'community-pin' : ''}">
|
||
<div class="cam-pulse-wave"></div>
|
||
<div class="cam-dot-center">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2.5">
|
||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
|
||
<circle cx="12" cy="13" r="4"></circle>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const customIcon = L.divIcon({
|
||
html: iconHtml,
|
||
className: 'custom-cam-div-icon',
|
||
iconSize: [28, 28],
|
||
iconAnchor: [14, 14]
|
||
});
|
||
|
||
const marker = L.marker([cam.lat, cam.lng], { icon: customIcon }).addTo(map);
|
||
|
||
// Popup details
|
||
const popupHtml = `
|
||
<div class="radar-popup-card">
|
||
<div class="popup-badge">${cam.isCommunity ? 'COMMUNITY REPORT' : 'FLOCK RECORDED ZONE'}</div>
|
||
<h4 class="popup-title">${escapeHtml(cam.name)}</h4>
|
||
<div class="popup-meta">
|
||
<span><strong>Agency:</strong> ${escapeHtml(cam.agency)}</span><br>
|
||
<span><strong>Cameras in Cluster:</strong> ${cam.cameras || 1}</span><br>
|
||
<span><strong>30d Activity:</strong> ${cam.captures30d || 'Active'}</span>
|
||
${cam.notes ? `<br><span><em>${escapeHtml(cam.notes)}</em></span>` : ''}
|
||
</div>
|
||
${cam.slug ? `
|
||
<button class="popup-inspect-btn" onclick="window.inspectPortal('${escapeHtml(cam.slug)}')">
|
||
Inspect Agency Records & Sharing ↗
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
|
||
marker.bindPopup(popupHtml);
|
||
|
||
cameraCircles.push(radarCircle);
|
||
cameraCircles.push(marker);
|
||
});
|
||
}
|
||
|
||
// --- VEHICLE MARKER SETUP ---
|
||
function setupUserMarker() {
|
||
const vehicleHtml = `
|
||
<div class="user-vehicle-marker" id="user-vehicle-marker">
|
||
<div class="radar-scan-cone"></div>
|
||
<div class="car-body-dot">
|
||
<div class="car-arrow"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const vehicleIcon = L.divIcon({
|
||
html: vehicleHtml,
|
||
className: 'custom-vehicle-div-icon',
|
||
iconSize: [40, 40],
|
||
iconAnchor: [20, 20]
|
||
});
|
||
|
||
userMarker = L.marker([currentPosition.lat, currentPosition.lng], {
|
||
icon: vehicleIcon,
|
||
zIndexOffset: 1000
|
||
}).addTo(map);
|
||
}
|
||
|
||
// --- HUD SETUP ---
|
||
function setupHud() {
|
||
el.hudSpeedVal.textContent = '0';
|
||
el.hudDistVal.textContent = '—';
|
||
el.hudZoneBadge.className = 'hud-badge badge-clear';
|
||
el.hudZoneBadge.innerHTML = `<span class="badge-dot dot-clear"></span> ALL CLEAR`;
|
||
el.hudCamAgency.textContent = 'Monitoring Highway Network';
|
||
}
|
||
|
||
// --- PROXIMITY CALCULATION & ALERT ENGINE ---
|
||
function checkProximityToCameras() {
|
||
if (!allCameras.length) return;
|
||
|
||
let closestDistMeters = Infinity;
|
||
let closestCam = null;
|
||
|
||
allCameras.forEach(cam => {
|
||
const dist = getHaversineDistanceMeters(currentPosition.lat, currentPosition.lng, cam.lat, cam.lng);
|
||
if (dist < closestDistMeters) {
|
||
closestDistMeters = dist;
|
||
closestCam = cam;
|
||
}
|
||
});
|
||
|
||
const distFt = Math.round(closestDistMeters * 3.28084);
|
||
nearestCamDistanceFt = distFt;
|
||
|
||
// Check if inside zone radius
|
||
const insideZone = closestDistMeters <= (closestCam.radius || 300);
|
||
|
||
// Update HUD
|
||
if (distFt < 5280) {
|
||
el.hudDistVal.textContent = distFt > 1000 ? (distFt / 1000).toFixed(1) + 'k' : distFt;
|
||
el.hudDistSub.textContent = 'FT';
|
||
} else {
|
||
el.hudDistVal.textContent = (distFt / 5280).toFixed(1);
|
||
el.hudDistSub.textContent = 'MI';
|
||
}
|
||
|
||
if (closestCam) {
|
||
el.hudCamAgency.textContent = closestCam.name;
|
||
}
|
||
|
||
// Update Graduated Radar Signal Strength Meter (Valentine One Style)
|
||
updateRadarGauge(distFt, insideZone, closestCam);
|
||
|
||
// State Transitions
|
||
const cautionThreshold = (radarRangeMode === 'highway') ? 2500 : 1200;
|
||
if (insideZone) {
|
||
if (!isInZone) {
|
||
// Just entered zone! Trigger Alert!
|
||
triggerZoneAlert(closestCam, distFt);
|
||
}
|
||
isInZone = true;
|
||
el.hudZoneBadge.className = 'hud-badge badge-danger';
|
||
el.hudZoneBadge.innerHTML = `<span class="badge-dot dot-danger"></span> IN RECORDED ZONE`;
|
||
el.hudBannerAlert.classList.add('visible');
|
||
el.hudBannerAlert.innerHTML = `
|
||
<span class="banner-pulse-icon">⚠️</span>
|
||
<div>
|
||
<strong>FLOCK CAMERA ACTIVE ZONE</strong>
|
||
<p>${escapeHtml(closestCam.agency)} • Vehicle Profile Logging</p>
|
||
</div>
|
||
`;
|
||
} else if (distFt <= cautionThreshold) {
|
||
// Approaching camera
|
||
isInZone = false;
|
||
el.hudZoneBadge.className = 'hud-badge badge-warning';
|
||
el.hudZoneBadge.innerHTML = `<span class="badge-dot dot-warning"></span> CAMERA AHEAD`;
|
||
el.hudBannerAlert.classList.remove('visible');
|
||
} else {
|
||
// Clear
|
||
isInZone = false;
|
||
el.hudZoneBadge.className = 'hud-badge badge-clear';
|
||
el.hudZoneBadge.innerHTML = `<span class="badge-dot dot-clear"></span> ALL CLEAR`;
|
||
el.hudBannerAlert.classList.remove('visible');
|
||
}
|
||
}
|
||
|
||
// --- 3-STAGE GRADUATED RADAR DETECTOR SIGNAL GAUGE ---
|
||
function updateRadarGauge(distFt, insideZone, closestCam) {
|
||
if (!el.hudGaugeState || !el.hudSignalMeter) return;
|
||
|
||
const isHighway = (radarRangeMode === 'highway');
|
||
const cautionDist = isHighway ? 2800 : 1500;
|
||
const warnDist = isHighway ? 1400 : 800;
|
||
|
||
const bars = el.hudSignalMeter.querySelectorAll('.signal-bar');
|
||
bars.forEach(b => {
|
||
b.className = 'signal-bar';
|
||
});
|
||
|
||
if (insideZone || distFt <= 450) {
|
||
// Level 5: Red Alert In Zone
|
||
el.hudGaugeState.className = 'hud-gauge-status status-red';
|
||
el.hudGaugeState.textContent = 'IN RECORDED ZONE!';
|
||
bars.forEach(b => b.classList.add('active-red'));
|
||
} else if (distFt <= warnDist) {
|
||
// Level 4: Immediate Proximity
|
||
el.hudGaugeState.className = 'hud-gauge-status status-red';
|
||
el.hudGaugeState.textContent = `ALERT: ${distFt} FT`;
|
||
bars[0].classList.add('active-red');
|
||
bars[1].classList.add('active-red');
|
||
bars[2].classList.add('active-red');
|
||
bars[3].classList.add('active-red');
|
||
} else if (distFt <= cautionDist) {
|
||
// Level 3: Approaching (Yellow Caution)
|
||
el.hudGaugeState.className = 'hud-gauge-status status-yellow';
|
||
el.hudGaugeState.textContent = `APPROACHING (${Math.round(distFt / 100) * 100} FT)`;
|
||
bars[0].classList.add('active-yellow');
|
||
bars[1].classList.add('active-yellow');
|
||
bars[2].classList.add('active-yellow');
|
||
|
||
// Pre-alert announcement once per camera approach
|
||
if (closestCam && lastCautionSpokenCamId !== closestCam.id) {
|
||
lastCautionSpokenCamId = closestCam.id;
|
||
playCautionBeep();
|
||
speakVoiceAlert(`Caution. Approaching surveillance camera in ${Math.round(distFt / 100) * 100} feet.`);
|
||
}
|
||
} else if (distFt <= (cautionDist * 1.6)) {
|
||
// Level 2: Grid Range
|
||
el.hudGaugeState.className = 'hud-gauge-status status-green';
|
||
el.hudGaugeState.textContent = 'MONITORING GRID';
|
||
bars[0].classList.add('active-green');
|
||
bars[1].classList.add('active-green');
|
||
if (closestCam && lastCautionSpokenCamId === closestCam.id && distFt > cautionDist + 500) {
|
||
lastCautionSpokenCamId = null; // Reset when moved away
|
||
}
|
||
} else {
|
||
// Level 1: All Clear
|
||
el.hudGaugeState.className = 'hud-gauge-status status-green';
|
||
el.hudGaugeState.textContent = 'ALL CLEAR';
|
||
bars[0].classList.add('active-green');
|
||
lastCautionSpokenCamId = null;
|
||
}
|
||
}
|
||
|
||
// --- AUDIO SYNTHESIZERS: CHIRPS & CAUTION BEEPS ---
|
||
function playLogConfirmationChirp() {
|
||
try {
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
if (audioContext.state === 'suspended') {
|
||
audioContext.resume();
|
||
}
|
||
const osc = audioContext.createOscillator();
|
||
const gain = audioContext.createGain();
|
||
osc.type = 'triangle';
|
||
osc.frequency.setValueAtTime(587.33, audioContext.currentTime); // D5
|
||
osc.frequency.setValueAtTime(880, audioContext.currentTime + 0.1); // A5
|
||
osc.frequency.setValueAtTime(1174.66, audioContext.currentTime + 0.2); // D6
|
||
gain.gain.setValueAtTime(0.35, audioContext.currentTime);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.4);
|
||
osc.connect(gain);
|
||
gain.connect(audioContext.destination);
|
||
osc.start();
|
||
osc.stop(audioContext.currentTime + 0.4);
|
||
} catch (e) {
|
||
console.warn('Log chirp error:', e);
|
||
}
|
||
}
|
||
|
||
function playCautionBeep() {
|
||
const user = window.FlockAuth ? window.FlockAuth.getUser() : null;
|
||
if (user && !user.soundEnabled) return;
|
||
try {
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
if (audioContext.state === 'suspended') {
|
||
audioContext.resume();
|
||
}
|
||
const osc = audioContext.createOscillator();
|
||
const gain = audioContext.createGain();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(520, audioContext.currentTime);
|
||
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.22);
|
||
osc.connect(gain);
|
||
gain.connect(audioContext.destination);
|
||
osc.start();
|
||
osc.stop(audioContext.currentTime + 0.22);
|
||
} catch (e) {
|
||
console.warn('Caution beep error:', e);
|
||
}
|
||
}
|
||
|
||
// --- 1-TAP DRIVER QUICK-LOG CAMERA ---
|
||
function quickLogFlockCameraAtCurrentLocation() {
|
||
const lat = currentPosition.lat;
|
||
const lng = currentPosition.lng;
|
||
const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
const camId = 'usr-pin-' + Date.now();
|
||
|
||
const newCam = {
|
||
id: camId,
|
||
name: `Driver Pinned Camera (${timeStr})`,
|
||
lat: lat,
|
||
lng: lng,
|
||
radius: 280,
|
||
agency: 'Driver Crowdsourced ALPR',
|
||
slug: '',
|
||
cameras: 1,
|
||
captures30d: 'Verified Mobile Sighting',
|
||
isCommunity: true,
|
||
reportedBy: 'Active Driver',
|
||
notes: 'Logged via 1-Tap Dashboard Button'
|
||
};
|
||
|
||
allCameras.unshift(newCam);
|
||
|
||
// Save to persistence store
|
||
if (window.FlockAuth) {
|
||
const reports = window.FlockAuth.getReports();
|
||
reports.unshift({
|
||
id: camId,
|
||
type: 'Fixed ALPR Camera',
|
||
lat: lat,
|
||
lng: lng,
|
||
reportedBy: 'Active Driver',
|
||
timestamp: new Date().toISOString(),
|
||
notes: '1-Tap Mobile Driver Pin'
|
||
});
|
||
localStorage.setItem('flock_community_reports', JSON.stringify(reports));
|
||
}
|
||
|
||
// Immediately draw the new red perimeter circle
|
||
renderCameraZones();
|
||
|
||
// Positive audio feedback + voice announcement
|
||
playLogConfirmationChirp();
|
||
speakVoiceAlert("Camera logged at current location. Radar grid updated.");
|
||
|
||
// Animate the button briefly
|
||
if (el.btnQuickLogCam) {
|
||
el.btnQuickLogCam.style.transform = 'scale(1.1)';
|
||
setTimeout(() => {
|
||
if (el.btnQuickLogCam) el.btnQuickLogCam.style.transform = '';
|
||
}, 300);
|
||
}
|
||
|
||
showToast("📸 Camera logged at current location & synced to radar!");
|
||
}
|
||
|
||
// --- AUTO-CENTER / FOLLOW CAR HANDLER ---
|
||
function toggleFollowCar() {
|
||
isFollowCarLocked = !isFollowCarLocked;
|
||
updateFollowCarUI();
|
||
if (isFollowCarLocked) {
|
||
map.setView([currentPosition.lat, currentPosition.lng], Math.max(map.getZoom(), 15), { animate: true });
|
||
}
|
||
}
|
||
|
||
function recenterOnCar() {
|
||
isFollowCarLocked = true;
|
||
updateFollowCarUI();
|
||
map.setView([currentPosition.lat, currentPosition.lng], Math.max(map.getZoom(), 15), { animate: true });
|
||
}
|
||
|
||
function updateFollowCarUI() {
|
||
if (el.btnFollowCar) {
|
||
el.btnFollowCar.classList.toggle('active', isFollowCarLocked);
|
||
el.btnFollowCar.innerHTML = isFollowCarLocked
|
||
? `<span>🎯</span><span>Follow Car: ON</span>`
|
||
: `<span>🎯</span><span>Follow Car: OFF</span>`;
|
||
}
|
||
if (el.btnRecenterCar) {
|
||
el.btnRecenterCar.classList.toggle('hidden', isFollowCarLocked);
|
||
}
|
||
}
|
||
|
||
// --- ALERT SENSITIVITY RANGE (CITY vs HIGHWAY) ---
|
||
function setRadarSensitivity(mode) {
|
||
radarRangeMode = mode;
|
||
if (el.btnSensCity) el.btnSensCity.classList.toggle('active', mode === 'city');
|
||
if (el.btnSensHwy) el.btnSensHwy.classList.toggle('active', mode === 'highway');
|
||
playCautionBeep();
|
||
showToast(mode === 'highway' ? '🛣️ Highway Range Active (2,500 ft advance alert)' : '🏙️ City Range Active (1,000 ft alert)');
|
||
}
|
||
|
||
// --- DASHBOARD FULLSCREEN MOUNT MODE ---
|
||
function toggleFullscreenMount() {
|
||
if (!document.fullscreenElement) {
|
||
document.documentElement.requestFullscreen().then(() => {
|
||
if (el.btnFullscreenMount) el.btnFullscreenMount.classList.add('active');
|
||
showToast("📱 Fullscreen Dashboard Mode Active");
|
||
}).catch(err => {
|
||
console.warn('Fullscreen request failed:', err);
|
||
});
|
||
} else {
|
||
if (document.exitFullscreen) {
|
||
document.exitFullscreen();
|
||
if (el.btnFullscreenMount) el.btnFullscreenMount.classList.remove('active');
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- AUDIO SYNTHESIZER & SPOKEN VOICE ALERTS ---
|
||
function playRadarChime() {
|
||
const user = window.FlockAuth ? window.FlockAuth.getUser() : null;
|
||
if (user && !user.soundEnabled) return;
|
||
|
||
const now = Date.now();
|
||
if (now - lastChimeTime < 3000) return;
|
||
lastChimeTime = now;
|
||
|
||
try {
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
if (audioContext.state === 'suspended') {
|
||
audioContext.resume();
|
||
}
|
||
|
||
// Synthesize clean two-tone radar alert
|
||
const osc = audioContext.createOscillator();
|
||
const gain = audioContext.createGain();
|
||
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(880, audioContext.currentTime); // High tone
|
||
osc.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.12); // Tone 2
|
||
osc.frequency.setValueAtTime(880, audioContext.currentTime + 0.24); // Return tone
|
||
|
||
gain.gain.setValueAtTime(0.35, audioContext.currentTime);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.45);
|
||
|
||
osc.connect(gain);
|
||
gain.connect(audioContext.destination);
|
||
|
||
osc.start();
|
||
osc.stop(audioContext.currentTime + 0.45);
|
||
} catch (e) {
|
||
console.warn('Audio chime error:', e);
|
||
}
|
||
}
|
||
|
||
function speakVoiceAlert(text) {
|
||
const user = window.FlockAuth ? window.FlockAuth.getUser() : null;
|
||
if (user && !user.soundEnabled) return;
|
||
if (!window.speechSynthesis) return;
|
||
|
||
try {
|
||
window.speechSynthesis.cancel();
|
||
const utter = new SpeechSynthesisUtterance(text);
|
||
utter.rate = 1.05;
|
||
utter.pitch = 1.0;
|
||
utter.volume = 1.0;
|
||
window.speechSynthesis.speak(utter);
|
||
} catch (e) {
|
||
console.warn('Speech error:', e);
|
||
}
|
||
}
|
||
|
||
function triggerZoneAlert(cam, distFt) {
|
||
playRadarChime();
|
||
speakVoiceAlert("Warning. Flock camera detected ahead. You are entering a recorded surveillance zone.");
|
||
}
|
||
|
||
// --- TEST ALERT SOUND & VOICE NOW ---
|
||
function testAlertSoundAndVoice() {
|
||
playRadarChime();
|
||
speakVoiceAlert("Warning. Flock camera active zone ahead. You are being recorded.");
|
||
el.hudBannerAlert.classList.add('visible');
|
||
el.hudBannerAlert.innerHTML = `
|
||
<span class="banner-pulse-icon">⚠️</span>
|
||
<div>
|
||
<strong>FLOCK CAMERA ACTIVE ZONE</strong>
|
||
<p>Test Alert • Vehicle Profile Logging Active</p>
|
||
</div>
|
||
`;
|
||
setTimeout(() => {
|
||
el.hudBannerAlert.classList.remove('visible');
|
||
}, 4500);
|
||
}
|
||
|
||
// --- PLACE TEST CAMERA 400FT AHEAD OF USER ---
|
||
function placeTestCameraAhead() {
|
||
// Offset approx 120m (400 ft) north-east of current location
|
||
const testCam = {
|
||
id: 'cam-test-' + Date.now(),
|
||
name: 'Test Flock Camera (400ft Ahead)',
|
||
lat: currentPosition.lat + 0.0011,
|
||
lng: currentPosition.lng + 0.0003,
|
||
radius: 260,
|
||
agency: 'Local Test Camera Deployment',
|
||
slug: '',
|
||
cameras: 2,
|
||
captures30d: 'Active Demonstration Zone',
|
||
isCommunity: true,
|
||
notes: 'Manually dropped test camera to verify real-time driving alerts.'
|
||
};
|
||
|
||
allCameras.push(testCam);
|
||
renderCameraZones();
|
||
map.flyTo([testCam.lat, testCam.lng], 16, { duration: 1 });
|
||
|
||
playRadarChime();
|
||
speakVoiceAlert('Test camera placed 400 feet ahead. Move forward into the red circle to test the alert.');
|
||
alert('📍 Test Flock camera placed 400 feet ahead of you!\n\nWalk or drive forward into the red circle zone to trigger the live warning chime and voice alert.');
|
||
}
|
||
|
||
// --- LIVE GPS CONTINUOUS TRACKING ---
|
||
let liveGpsWatchId = null;
|
||
|
||
function toggleLiveGps() {
|
||
if (!navigator.geolocation) {
|
||
alert('Geolocation is not supported by your browser.');
|
||
return;
|
||
}
|
||
|
||
if (liveGpsWatchId !== null) {
|
||
navigator.geolocation.clearWatch(liveGpsWatchId);
|
||
liveGpsWatchId = null;
|
||
el.btnLiveGps.classList.remove('active');
|
||
el.btnLiveGps.innerHTML = `<span>🎯</span><span>Live GPS Drive Mode</span>`;
|
||
el.hudSpeedVal.textContent = '0';
|
||
return;
|
||
}
|
||
|
||
if (isSimulating) stopDriveSimulation();
|
||
|
||
el.btnLiveGps.classList.add('active');
|
||
el.btnLiveGps.innerHTML = `<span>🛑</span><span>Stop Live GPS</span>`;
|
||
speakVoiceAlert('Live GPS drive mode activated. Monitoring for Flock cameras.');
|
||
|
||
liveGpsWatchId = navigator.geolocation.watchPosition(
|
||
pos => {
|
||
currentPosition = {
|
||
lat: pos.coords.latitude,
|
||
lng: pos.coords.longitude
|
||
};
|
||
|
||
// Speed in mph
|
||
if (pos.coords.speed !== null && !isNaN(pos.coords.speed)) {
|
||
currentSpeed = Math.round(pos.coords.speed * 2.23694);
|
||
} else {
|
||
currentSpeed = 0;
|
||
}
|
||
el.hudSpeedVal.textContent = currentSpeed;
|
||
|
||
// Heading
|
||
if (pos.coords.heading !== null && !isNaN(pos.coords.heading)) {
|
||
const carElem = document.getElementById('user-vehicle-marker');
|
||
if (carElem) carElem.style.transform = `rotate(${pos.coords.heading}deg)`;
|
||
}
|
||
|
||
if (userMarker) userMarker.setLatLng([currentPosition.lat, currentPosition.lng]);
|
||
if (isFollowCarLocked) {
|
||
map.panTo([currentPosition.lat, currentPosition.lng], { animate: true, duration: 0.8 });
|
||
if (currentSpeed > 15 && map.getZoom() < 16) {
|
||
map.setZoom(16);
|
||
}
|
||
}
|
||
checkProximityToCameras();
|
||
},
|
||
err => {
|
||
console.warn('GPS Error:', err);
|
||
alert('GPS Location Error: ' + err.message + '\nPlease allow location access in your browser.');
|
||
},
|
||
{
|
||
enableHighAccuracy: true,
|
||
maximumAge: 1000,
|
||
timeout: 10000
|
||
}
|
||
);
|
||
}
|
||
|
||
// --- MOBILE PHONE CONNECT INSTRUCTIONS ---
|
||
function showMobileConnectModal() {
|
||
const httpUrl = 'http://192.168.1.249:8085';
|
||
const httpsUrl = 'https://192.168.1.249:8443';
|
||
alert(
|
||
"📱 HOW TO USE FLOCKRADAR ON YOUR PHONE IN ANTELOPE VALLEY:\n\n" +
|
||
"1. Connect your phone to your Wi-Fi or hotspot.\n\n" +
|
||
"2. FOR CONTINUOUS DRIVING GPS (iOS Safari & Android Chrome):\n" +
|
||
" Open: " + httpsUrl + "\n" +
|
||
" (Tap 'Show Details' / 'Advanced' -> 'Visit this website' to allow local SSL, then allow location access!)\n\n" +
|
||
"3. FOR INSTANT OVERVIEW: Open " + httpUrl + "\n\n" +
|
||
"4. Tap 'Auto-Detect (GPS)' and your device will track and alert you across all Antelope Valley Flock cameras!"
|
||
);
|
||
}
|
||
|
||
// --- DRIVE SIMULATOR ENGINE ---
|
||
function toggleDriveSimulation() {
|
||
if (isSimulating) {
|
||
stopDriveSimulation();
|
||
} else {
|
||
startDriveSimulation();
|
||
}
|
||
}
|
||
|
||
function startDriveSimulation() {
|
||
isSimulating = true;
|
||
el.btnSimDrive.innerHTML = `<span>⏸</span> Pause Simulation`;
|
||
el.btnSimDrive.classList.add('active');
|
||
|
||
simIndex = 0;
|
||
simProgress = 0;
|
||
currentPosition = { ...SIM_ROUTE[0] };
|
||
map.flyTo([currentPosition.lat, currentPosition.lng], 15, { animate: true, duration: 1 });
|
||
|
||
stepSimulation();
|
||
}
|
||
|
||
function stopDriveSimulation() {
|
||
isSimulating = false;
|
||
el.btnSimDrive.innerHTML = `<span>▶</span> Simulate Drive`;
|
||
el.btnSimDrive.classList.remove('active');
|
||
if (simAnimId) cancelAnimationFrame(simAnimId);
|
||
}
|
||
|
||
function resetDriveSimulation() {
|
||
stopDriveSimulation();
|
||
simIndex = 0;
|
||
simProgress = 0;
|
||
currentPosition = { ...SIM_ROUTE[0] };
|
||
currentSpeed = 0;
|
||
el.hudSpeedVal.textContent = '0';
|
||
if (userMarker) userMarker.setLatLng([currentPosition.lat, currentPosition.lng]);
|
||
map.panTo([currentPosition.lat, currentPosition.lng]);
|
||
checkProximityToCameras();
|
||
}
|
||
|
||
function stepSimulation() {
|
||
if (!isSimulating) return;
|
||
|
||
const p1 = SIM_ROUTE[simIndex];
|
||
const p2 = SIM_ROUTE[simIndex + 1];
|
||
|
||
if (!p2) {
|
||
// Reached end of demo route, loop smoothly
|
||
simIndex = 0;
|
||
simProgress = 0;
|
||
simAnimId = requestAnimationFrame(stepSimulation);
|
||
return;
|
||
}
|
||
|
||
// Step rate
|
||
simProgress += 0.004;
|
||
|
||
if (simProgress >= 1) {
|
||
simProgress = 0;
|
||
simIndex++;
|
||
} else {
|
||
// Interpolate lat / lng
|
||
currentPosition.lat = p1.lat + (p2.lat - p1.lat) * simProgress;
|
||
currentPosition.lng = p1.lng + (p2.lng - p1.lng) * simProgress;
|
||
currentSpeed = Math.round(p1.speed + (p2.speed - p1.speed) * simProgress);
|
||
|
||
// Update marker position
|
||
if (userMarker) userMarker.setLatLng([currentPosition.lat, currentPosition.lng]);
|
||
|
||
// Rotate car icon based on heading angle
|
||
const angle = getBearing(p1.lat, p1.lng, p2.lat, p2.lng);
|
||
const carElem = document.getElementById('user-vehicle-marker');
|
||
if (carElem) {
|
||
carElem.style.transform = `rotate(${angle}deg)`;
|
||
}
|
||
|
||
// Smoothly pan map along if follow car is locked
|
||
if (isFollowCarLocked) {
|
||
map.panTo([currentPosition.lat, currentPosition.lng], { animate: false });
|
||
}
|
||
|
||
// Update Speedometer
|
||
el.hudSpeedVal.textContent = currentSpeed;
|
||
}
|
||
|
||
simAnimId = requestAnimationFrame(stepSimulation);
|
||
}
|
||
|
||
// --- HAVERSINE FORMULA ---
|
||
function getHaversineDistanceMeters(lat1, lon1, lat2, lon2) {
|
||
const R = 6371e3; // Earth radius in meters
|
||
const phi1 = lat1 * Math.PI / 180;
|
||
const phi2 = lat2 * Math.PI / 180;
|
||
const deltaPhi = (lat2 - lat1) * Math.PI / 180;
|
||
const deltaLambda = (lon2 - lon1) * Math.PI / 180;
|
||
|
||
const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
||
Math.cos(phi1) * Math.cos(phi2) *
|
||
Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
|
||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
|
||
return R * c;
|
||
}
|
||
|
||
function getBearing(startLat, startLng, destLat, destLng) {
|
||
const startLatRad = startLat * Math.PI / 180;
|
||
const startLngRad = startLng * Math.PI / 180;
|
||
const destLatRad = destLat * Math.PI / 180;
|
||
const destLngRad = destLng * Math.PI / 180;
|
||
|
||
const y = Math.sin(destLngRad - startLngRad) * Math.cos(destLatRad);
|
||
const x = Math.cos(startLatRad) * Math.sin(destLatRad) -
|
||
Math.sin(startLatRad) * Math.cos(destLatRad) * Math.cos(destLngRad - startLngRad);
|
||
let brng = Math.atan2(y, x) * 180 / Math.PI;
|
||
return (brng + 360) % 360;
|
||
}
|
||
|
||
// --- MULTI-TIER AUTO-LOCATION RESOLVER (ANTELOPE VALLEY & BEYOND) ---
|
||
async function attemptAutoLocate() {
|
||
updateSpottedBadge('🛰️ Spotting Antelope Valley Location...');
|
||
|
||
// Tier 1: Instant IP-based Network Geolocation
|
||
// Works 100% reliably even when browser GPS is blocked, disabled, or accessed over HTTP
|
||
let ipFound = false;
|
||
try {
|
||
const resp = await fetch('https://get.geojs.io/v1/ip/geo.json', { cache: 'no-cache' });
|
||
const ipData = await resp.json();
|
||
if (ipData && ipData.latitude && ipData.longitude) {
|
||
const ipLat = parseFloat(ipData.latitude);
|
||
const ipLng = parseFloat(ipData.longitude);
|
||
const city = ipData.city || 'Palmdale';
|
||
const region = ipData.region || 'California';
|
||
|
||
console.log('IP Network Geolocation spotted:', city, region, ipLat, ipLng);
|
||
|
||
// Center directly on Antelope Valley (Palmdale / Lancaster corridor)
|
||
let targetLat = ipLat;
|
||
let targetLng = ipLng;
|
||
const isAntelopeValley = city.toLowerCase().includes('palmdale') ||
|
||
city.toLowerCase().includes('lancaster') ||
|
||
(ipLat > 34.2 && ipLat < 34.85 && ipLng > -118.45 && ipLng < -117.8);
|
||
|
||
if (isAntelopeValley) {
|
||
// Snap directly to Palmdale central corridor (Palmdale Blvd & 10th St W)
|
||
targetLat = 34.60194;
|
||
targetLng = -118.14847;
|
||
}
|
||
|
||
updateUserCoordinates(targetLat, targetLng, 0, 0);
|
||
map.flyTo([targetLat, targetLng], 14, { duration: 1.2 });
|
||
updateSpottedBadge(`📍 Spotted: ${city}, Antelope Valley (Network)`);
|
||
updateHudLocationStatus(`Monitoring ${city} / Antelope Valley Corridors`);
|
||
speakVoiceAlert(`Location spotted: Antelope Valley, California.`);
|
||
checkProximityToCameras();
|
||
ipFound = true;
|
||
}
|
||
} catch (e) {
|
||
console.warn('Primary geojs IP lookup failed, trying backup:', e);
|
||
try {
|
||
const resp2 = await fetch('https://ipwho.is/', { cache: 'no-cache' });
|
||
const ipData2 = await resp2.json();
|
||
if (ipData2 && ipData2.success && ipData2.latitude) {
|
||
updateUserCoordinates(34.60194, -118.14847, 0, 0);
|
||
map.flyTo([34.60194, -118.14847], 14, { duration: 1.2 });
|
||
updateSpottedBadge(`📍 Spotted: ${ipData2.city || 'Antelope Valley'} (Network)`);
|
||
checkProximityToCameras();
|
||
ipFound = true;
|
||
}
|
||
} catch (err2) {
|
||
console.warn('Backup IP lookup failed:', err2);
|
||
}
|
||
}
|
||
|
||
if (!ipFound) {
|
||
updateSpottedBadge('📍 Antelope Valley: Palmdale, CA (Default)');
|
||
updateHudLocationStatus('Monitoring Antelope Valley Corridors');
|
||
checkProximityToCameras();
|
||
}
|
||
|
||
// Tier 2: Hardware Device GPS (Sub-meter accuracy if granted)
|
||
if (navigator.geolocation && (window.isSecureContext || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
|
||
navigator.geolocation.getCurrentPosition(
|
||
pos => {
|
||
const lat = pos.coords.latitude;
|
||
const lng = pos.coords.longitude;
|
||
const acc = Math.round(pos.coords.accuracy || 10);
|
||
console.log('Hardware GPS lock acquired:', lat, lng, '±' + acc + 'm');
|
||
updateUserCoordinates(lat, lng, pos.coords.speed, pos.coords.heading);
|
||
map.flyTo([lat, lng], 15, { duration: 1.2 });
|
||
updateSpottedBadge(`🎯 High-Precision GPS (±${acc}m) • Antelope Valley`);
|
||
updateHudLocationStatus('📍 Live Driving GPS Active');
|
||
checkProximityToCameras();
|
||
},
|
||
gpsErr => {
|
||
console.log('Hardware GPS not granted/pending:', gpsErr.message);
|
||
// Retain the confirmed Antelope Valley IP location smoothly
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 6000, maximumAge: 30000 }
|
||
);
|
||
}
|
||
}
|
||
|
||
function updateSpottedBadge(text) {
|
||
if (el.hudSpottedText) {
|
||
el.hudSpottedText.textContent = text;
|
||
}
|
||
}
|
||
|
||
function updateUserCoordinates(lat, lng, speed, heading) {
|
||
currentPosition.lat = lat;
|
||
currentPosition.lng = lng;
|
||
|
||
if (speed !== null && !isNaN(speed)) {
|
||
currentSpeed = Math.round(speed * 2.23694);
|
||
} else {
|
||
currentSpeed = 0;
|
||
}
|
||
el.hudSpeedVal.textContent = currentSpeed;
|
||
|
||
if (userMarker) {
|
||
userMarker.setLatLng([lat, lng]);
|
||
}
|
||
|
||
if (heading !== null && !isNaN(heading)) {
|
||
const carElem = document.getElementById('user-vehicle-marker');
|
||
if (carElem) carElem.style.transform = `rotate(${heading}deg)`;
|
||
}
|
||
}
|
||
|
||
function updateHudLocationStatus(statusText) {
|
||
if (el.hudCamAgency) {
|
||
el.hudCamAgency.textContent = statusText;
|
||
}
|
||
}
|
||
|
||
async function useLiveGps() {
|
||
if (isSimulating) stopDriveSimulation();
|
||
|
||
updateSpottedBadge('🛰️ Re-detecting location in Antelope Valley...');
|
||
updateHudLocationStatus('Acquiring high-precision location...');
|
||
|
||
// 1. First ensure IP location is refreshed
|
||
try {
|
||
const resp = await fetch('https://get.geojs.io/v1/ip/geo.json', { cache: 'no-cache' });
|
||
const ipData = await resp.json();
|
||
if (ipData && ipData.latitude) {
|
||
const city = ipData.city || 'Palmdale';
|
||
jumpToLocation(34.60194, -118.14847, `${city} (Antelope Valley)`);
|
||
updateSpottedBadge(`📍 Spotted: ${city}, Antelope Valley`);
|
||
}
|
||
} catch (e) {}
|
||
|
||
// 2. Hardware GPS Attempt
|
||
if (navigator.geolocation) {
|
||
navigator.geolocation.getCurrentPosition(
|
||
pos => {
|
||
updateUserCoordinates(pos.coords.latitude, pos.coords.longitude, pos.coords.speed, pos.coords.heading);
|
||
map.flyTo([pos.coords.latitude, pos.coords.longitude], 16, { duration: 1.2 });
|
||
const acc = Math.round(pos.coords.accuracy || 10);
|
||
updateSpottedBadge(`🎯 Live GPS Active (±${acc}m)`);
|
||
updateHudLocationStatus('📍 High-Precision GPS Locked');
|
||
playRadarChime();
|
||
speakVoiceAlert('GPS position locked in Antelope Valley.');
|
||
checkProximityToCameras();
|
||
},
|
||
err => {
|
||
console.warn('GPS hardware notice:', err.message);
|
||
// Show non-blocking informative banner
|
||
if (err.code === 1) { // PERMISSION_DENIED
|
||
el.hudBannerAlert.classList.add('visible');
|
||
el.hudBannerAlert.innerHTML = `
|
||
<span class="banner-pulse-icon">ℹ️</span>
|
||
<div>
|
||
<strong>ANTELOPE VALLEY NETWORK LOCATION ACTIVE</strong>
|
||
<p>To enable turn-by-turn driving GPS, click the lock icon 🔒 in your browser address bar and select 'Allow Location'.</p>
|
||
</div>
|
||
`;
|
||
setTimeout(() => {
|
||
el.hudBannerAlert.classList.remove('visible');
|
||
}, 6000);
|
||
}
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 8000 }
|
||
);
|
||
}
|
||
}
|
||
|
||
// --- LOCATION PRESET JUMPS (ANTELOPE VALLEY & SUB-REGIONS) ---
|
||
function jumpToLocation(lat, lng, label, zoom = 15) {
|
||
if (isSimulating) stopDriveSimulation();
|
||
updateUserCoordinates(lat, lng, 0, 0);
|
||
map.flyTo([lat, lng], zoom, { duration: 1.2 });
|
||
updateHudLocationStatus(`📍 ${label}`);
|
||
updateSpottedBadge(`📍 Set to: ${label}`);
|
||
checkProximityToCameras();
|
||
}
|
||
|
||
// --- FREE NOMINATIM ADDRESS SEARCH (100% UNMETERED $0.00) ---
|
||
async function searchLocation(query) {
|
||
if (!query) return;
|
||
try {
|
||
updateHudLocationStatus(`Searching "${query}"...`);
|
||
const resp = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&q=${encodeURIComponent(query)}`);
|
||
const data = await resp.json();
|
||
if (data && data.length > 0) {
|
||
const item = data[0];
|
||
const lat = parseFloat(item.lat);
|
||
const lng = parseFloat(item.lon);
|
||
const shortName = item.display_name.split(',').slice(0, 2).join(',');
|
||
jumpToLocation(lat, lng, shortName);
|
||
speakVoiceAlert(`Navigated to ${shortName}`);
|
||
} else {
|
||
alert('Location not found. Please try a street or city name (e.g. Palmdale Blvd, Ave S, Lancaster).');
|
||
}
|
||
} catch (e) {
|
||
console.warn('Search error:', e);
|
||
alert('Could not complete address search. Please check network connection.');
|
||
}
|
||
}
|
||
|
||
// --- MAP LAYER TOGGLE (DARK CANVAS vs SATELLITE HYBRID) ---
|
||
function toggleMapLayer() {
|
||
if (!map) return;
|
||
isSatelliteActive = !isSatelliteActive;
|
||
if (isSatelliteActive) {
|
||
if (darkTileLayer && map.hasLayer(darkTileLayer)) map.removeLayer(darkTileLayer);
|
||
if (satelliteLayer) satelliteLayer.addTo(map);
|
||
if (satelliteLabelsLayer) satelliteLabelsLayer.addTo(map);
|
||
el.btnMapLayer.innerHTML = `<span>🗺️</span><span>Dark Canvas</span>`;
|
||
el.btnMapLayer.classList.add('active');
|
||
} else {
|
||
if (satelliteLayer && map.hasLayer(satelliteLayer)) map.removeLayer(satelliteLayer);
|
||
if (satelliteLabelsLayer && map.hasLayer(satelliteLabelsLayer)) map.removeLayer(satelliteLabelsLayer);
|
||
if (darkTileLayer) darkTileLayer.addTo(map);
|
||
el.btnMapLayer.innerHTML = `<span>🛰️</span><span>Satellite / Dark</span>`;
|
||
el.btnMapLayer.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
// --- USER PROFILE & LOGIN MODAL ---
|
||
function updateProfileDisplay() {
|
||
const user = window.FlockAuth ? window.FlockAuth.getUser() : null;
|
||
if (user && user.isLoggedIn) {
|
||
el.userProfilePill.innerHTML = `
|
||
<span class="avatar-dot">🚘</span>
|
||
<span>${escapeHtml(user.username)}</span>
|
||
<span class="points-badge">${user.points} pts</span>
|
||
`;
|
||
} else {
|
||
el.userProfilePill.innerHTML = `
|
||
<span class="avatar-dot">👤</span>
|
||
<span>Driver Login</span>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function openLoginModal() {
|
||
const user = window.FlockAuth ? window.FlockAuth.getUser() : null;
|
||
if (user && user.isLoggedIn) {
|
||
// Pre-fill
|
||
document.getElementById('login-username').value = user.username;
|
||
document.getElementById('login-vehicle').value = user.vehicle;
|
||
}
|
||
|
||
if (typeof el.loginModal.showModal === 'function') {
|
||
el.loginModal.showModal();
|
||
} else {
|
||
el.loginModal.setAttribute('open', '');
|
||
}
|
||
}
|
||
|
||
function closeLoginModal() {
|
||
if (typeof el.loginModal.close === 'function') {
|
||
el.loginModal.close();
|
||
} else {
|
||
el.loginModal.removeAttribute('open');
|
||
}
|
||
}
|
||
|
||
// --- CROWDSOURCED REPORT MODAL ---
|
||
function openReportModal() {
|
||
// Fill current coordinates
|
||
document.getElementById('report-lat').value = currentPosition.lat.toFixed(5);
|
||
document.getElementById('report-lng').value = currentPosition.lng.toFixed(5);
|
||
|
||
if (typeof el.reportModal.showModal === 'function') {
|
||
el.reportModal.showModal();
|
||
} else {
|
||
el.reportModal.setAttribute('open', '');
|
||
}
|
||
}
|
||
|
||
function closeReportModal() {
|
||
if (typeof el.reportModal.close === 'function') {
|
||
el.reportModal.close();
|
||
} else {
|
||
el.reportModal.removeAttribute('open');
|
||
}
|
||
}
|
||
|
||
// --- EVENT BINDINGS ---
|
||
function bindEvents() {
|
||
// Live GPS Continuous Tracking
|
||
el.btnLiveGps.addEventListener('click', toggleLiveGps);
|
||
|
||
// Map Layer Switcher (Dark Canvas / Satellite)
|
||
if (el.btnMapLayer) {
|
||
el.btnMapLayer.addEventListener('click', toggleMapLayer);
|
||
}
|
||
|
||
// Quick Location Buttons
|
||
if (el.btnLocateMe) {
|
||
el.btnLocateMe.addEventListener('click', useLiveGps);
|
||
}
|
||
if (el.btnLocPalmdale) {
|
||
el.btnLocPalmdale.addEventListener('click', () => {
|
||
jumpToLocation(34.60194, -118.14847, 'Palmdale Blvd & 10th St W');
|
||
});
|
||
}
|
||
if (el.btnLocPalmdaleEast) {
|
||
el.btnLocPalmdaleEast.addEventListener('click', () => {
|
||
jumpToLocation(34.58081, -118.04497, 'East Palmdale (Ave R & 47th St E)');
|
||
});
|
||
}
|
||
if (el.btnLocPalmdaleSouth) {
|
||
el.btnLocPalmdaleSouth.addEventListener('click', () => {
|
||
jumpToLocation(34.56829, -118.14702, 'South Palmdale (Ave S & 10th St W)');
|
||
});
|
||
}
|
||
if (el.btnLocLancaster) {
|
||
el.btnLocLancaster.addEventListener('click', () => {
|
||
jumpToLocation(34.69718, -118.14770, 'Lancaster (Ave L & 10th St W)');
|
||
});
|
||
}
|
||
if (el.btnLocRancho) {
|
||
el.btnLocRancho.addEventListener('click', () => {
|
||
jumpToLocation(34.63179, -118.12594, 'West AV (Rancho Vista & AV Mall)');
|
||
});
|
||
}
|
||
|
||
// Address & City Search
|
||
if (el.mapSearchForm) {
|
||
el.mapSearchForm.addEventListener('submit', e => {
|
||
e.preventDefault();
|
||
const query = el.mapSearchInput.value.trim();
|
||
if (query) searchLocation(query);
|
||
});
|
||
}
|
||
|
||
el.btnTestCamera.addEventListener('click', placeTestCameraAhead);
|
||
el.btnTestAlert.addEventListener('click', testAlertSoundAndVoice);
|
||
el.btnSimDrive.addEventListener('click', toggleDriveSimulation);
|
||
el.btnResetSim.addEventListener('click', resetDriveSimulation);
|
||
el.btnMobileConnect.addEventListener('click', showMobileConnectModal);
|
||
|
||
// New 5% Driving Enhancement Listeners
|
||
if (el.btnFollowCar) el.btnFollowCar.addEventListener('click', toggleFollowCar);
|
||
if (el.btnRecenterCar) el.btnRecenterCar.addEventListener('click', recenterOnCar);
|
||
if (el.btnQuickLogCam) el.btnQuickLogCam.addEventListener('click', quickLogFlockCameraAtCurrentLocation);
|
||
if (el.btnFullscreenMount) el.btnFullscreenMount.addEventListener('click', toggleFullscreenMount);
|
||
if (el.btnSensCity) el.btnSensCity.addEventListener('click', () => setRadarSensitivity('city'));
|
||
if (el.btnSensHwy) el.btnSensHwy.addEventListener('click', () => setRadarSensitivity('highway'));
|
||
|
||
// Mute toggle
|
||
el.btnMuteSound.addEventListener('click', () => {
|
||
const user = window.FlockAuth ? window.FlockAuth.getUser() : null;
|
||
if (user) {
|
||
user.soundEnabled = !user.soundEnabled;
|
||
window.FlockAuth.saveUser();
|
||
el.btnMuteSound.innerHTML = user.soundEnabled ? '🔔 Sound: ON' : '🔕 Sound: OFF';
|
||
el.btnMuteSound.classList.toggle('active', !user.soundEnabled);
|
||
}
|
||
});
|
||
|
||
// Report Camera FAB
|
||
if (el.btnReportCameraFab) el.btnReportCameraFab.addEventListener('click', openReportModal);
|
||
|
||
// Profile button click
|
||
el.driverProfileBtn.addEventListener('click', openLoginModal);
|
||
|
||
// Login Form Submit
|
||
el.loginForm.addEventListener('submit', e => {
|
||
e.preventDefault();
|
||
const username = document.getElementById('login-username').value.trim();
|
||
const vehicle = document.getElementById('login-vehicle').value.trim();
|
||
const soundEnabled = document.getElementById('login-sound').checked;
|
||
|
||
if (window.FlockAuth) {
|
||
window.FlockAuth.login(username, vehicle, soundEnabled);
|
||
updateProfileDisplay();
|
||
}
|
||
closeLoginModal();
|
||
});
|
||
|
||
// Login as Guest button
|
||
el.loginGuestBtn.addEventListener('click', () => {
|
||
if (window.FlockAuth) {
|
||
window.FlockAuth.login('Guest Scout', 'Unspecified Vehicle', true);
|
||
updateProfileDisplay();
|
||
}
|
||
closeLoginModal();
|
||
});
|
||
|
||
// Report Form Submit
|
||
el.reportForm.addEventListener('submit', e => {
|
||
e.preventDefault();
|
||
const type = document.getElementById('report-mount-type').value;
|
||
const intersection = document.getElementById('report-intersection').value.trim();
|
||
const notes = document.getElementById('report-notes').value.trim();
|
||
const lat = parseFloat(document.getElementById('report-lat').value);
|
||
const lng = parseFloat(document.getElementById('report-lng').value);
|
||
|
||
if (window.FlockAuth) {
|
||
const newRpt = window.FlockAuth.addReport({ lat, lng, type, intersection, notes });
|
||
loadAllCameraData();
|
||
renderCameraZones();
|
||
updateProfileDisplay();
|
||
alert(`Camera report added! +50 Scout Points. Zone is now active on radar.`);
|
||
}
|
||
|
||
closeReportModal();
|
||
});
|
||
|
||
// Modal Close buttons
|
||
el.loginCloseBtn.addEventListener('click', closeLoginModal);
|
||
el.reportCloseBtn.addEventListener('click', closeReportModal);
|
||
|
||
// Global listener for portal inspection from popup
|
||
window.inspectPortal = function(slug) {
|
||
switchView('dir');
|
||
setTimeout(() => {
|
||
window.location.hash = slug;
|
||
}, 150);
|
||
};
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// --- PWA SERVICE WORKER & MOBILE WAKE LOCK ---
|
||
if ('serviceWorker' in navigator) {
|
||
window.addEventListener('load', () => {
|
||
navigator.serviceWorker.register('./sw.js').catch(err => {
|
||
console.log('Service Worker registration skipped/failed:', err);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Screen Wake Lock to prevent phone screen from going to sleep while driving
|
||
let wakeLock = null;
|
||
async function requestWakeLock() {
|
||
if ('wakeLock' in navigator) {
|
||
try {
|
||
wakeLock = await navigator.wakeLock.request('screen');
|
||
} catch (err) {
|
||
console.warn('Wake Lock error:', err);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Auto-unlock Web Audio and Wake Lock on first touch (critical for mobile iOS/Android)
|
||
function unlockMobileFeatures() {
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
if (audioContext && audioContext.state === 'suspended') {
|
||
audioContext.resume();
|
||
}
|
||
requestWakeLock();
|
||
document.removeEventListener('touchstart', unlockMobileFeatures);
|
||
document.removeEventListener('click', unlockMobileFeatures);
|
||
}
|
||
document.addEventListener('touchstart', unlockMobileFeatures, { passive: true });
|
||
document.addEventListener('click', unlockMobileFeatures, { passive: true });
|
||
|
||
// --- START ENGINE ON DOM READY ---
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
})();
|