feat(v27): directional FOV approach filter, dynamic ETA countdown, windshield mirror HUD, stealth AMOLED night mode, 1-tap driving sighting stamp, audio boost and steering haptics
This commit is contained in:
+502
-79
@@ -112,6 +112,15 @@
|
||||
let bgAudioElement = null;
|
||||
let activeDetourLayerGroup = null;
|
||||
|
||||
// --- V27 DRIVER-CENTRIC POLISH STATE ---
|
||||
let isHudMirrored = localStorage.getItem('flock_hud_mirror') === 'true';
|
||||
let isStealthAmoled = localStorage.getItem('flock_stealth_amoled') === 'true';
|
||||
let isAudioBoostEnabled = localStorage.getItem('flock_radar_audio_boost') === 'true';
|
||||
let isHapticBoostEnabled = localStorage.getItem('flock_radar_haptic_boost') !== 'false';
|
||||
let masterGainNode = null;
|
||||
let savedQuickStamps = [];
|
||||
let stampMarkers = [];
|
||||
|
||||
const SURVEILLANCE_CORRIDORS = {
|
||||
'palmdale-blvd': {
|
||||
name: 'Palmdale Blvd Corridor (SR-14 to 50th St E)',
|
||||
@@ -406,7 +415,25 @@
|
||||
btnGeneratePra: document.getElementById('btn-generate-pra'),
|
||||
btnCopyPraLetter: document.getElementById('btn-copy-pra-letter'),
|
||||
btnEmailPraLetter: document.getElementById('btn-email-pra-letter'),
|
||||
praLetterText: document.getElementById('pra-letter-text')
|
||||
praLetterText: document.getElementById('pra-letter-text'),
|
||||
|
||||
// Cockpit, FOV & Driver Polish (v27)
|
||||
hudApproachBadge: document.getElementById('hud-approach-badge'),
|
||||
hudEtaBox: document.getElementById('hud-eta-box'),
|
||||
hudEtaVal: document.getElementById('hud-eta-val'),
|
||||
hudEtaSub: document.getElementById('hud-eta-sub'),
|
||||
btnQuickMarkDash: document.getElementById('btn-quick-mark-dash'),
|
||||
btnMirrorHud: document.getElementById('btn-mirror-hud'),
|
||||
mirrorHudLabel: document.getElementById('mirror-hud-label'),
|
||||
btnStealthAmoled: document.getElementById('btn-stealth-amoled'),
|
||||
stealthAmoledLabel: document.getElementById('stealth-amoled-label'),
|
||||
btnToggleAudioBoost: document.getElementById('btn-toggle-audio-boost'),
|
||||
audioBoostLabel: document.getElementById('audio-boost-label'),
|
||||
btnToggleHapticBoost: document.getElementById('btn-toggle-haptic-boost'),
|
||||
hapticBoostLabel: document.getElementById('haptic-boost-label'),
|
||||
stampsCount: document.getElementById('stamps-count'),
|
||||
stampsListContainer: document.getElementById('stamps-list-container'),
|
||||
btnClearStamps: document.getElementById('btn-clear-stamps')
|
||||
};
|
||||
|
||||
// --- INITIALIZATION ---
|
||||
@@ -421,6 +448,13 @@
|
||||
updateProfileDisplay();
|
||||
checkSupporterReturn();
|
||||
|
||||
// Initialize v27 Driver Cockpit Preferences & Quick Stamps
|
||||
applyHudMirror(isHudMirrored, false);
|
||||
applyStealthAmoled(isStealthAmoled, false);
|
||||
applyAudioBoost(isAudioBoostEnabled, false);
|
||||
applyHapticBoost(isHapticBoostEnabled, false);
|
||||
loadQuickStamps();
|
||||
|
||||
// Auto-detect real location immediately (Palmdale / GPS)
|
||||
attemptAutoLocate();
|
||||
|
||||
@@ -780,6 +814,12 @@
|
||||
function setupHud() {
|
||||
updateHudSpeedAndHeading(0, null);
|
||||
el.hudDistVal.textContent = '—';
|
||||
if (el.hudEtaVal) el.hudEtaVal.textContent = '—';
|
||||
if (el.hudEtaSub) el.hudEtaSub.textContent = 'IDLE';
|
||||
if (el.hudApproachBadge) {
|
||||
el.hudApproachBadge.className = 'hud-approach-badge approach-none';
|
||||
el.hudApproachBadge.textContent = 'ALL CLEAR';
|
||||
}
|
||||
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';
|
||||
@@ -788,6 +828,38 @@
|
||||
updateVehicleSupporterAura();
|
||||
}
|
||||
|
||||
// --- DIRECTIONAL TRAVEL FILTER & APPROACH ANGLE CLASSIFIER (v27) ---
|
||||
function getBearing(lat1, lng1, lat2, lng2) {
|
||||
const toRad = deg => deg * Math.PI / 180;
|
||||
const toDeg = rad => rad * 180 / Math.PI;
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const lat1Rad = toRad(lat1);
|
||||
const lat2Rad = toRad(lat2);
|
||||
const y = Math.sin(dLng) * Math.cos(lat2Rad);
|
||||
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
|
||||
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng);
|
||||
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
||||
}
|
||||
|
||||
function classifyApproach(userLat, userLng, userHeading, camLat, camLng, speedMph) {
|
||||
if (speedMph < 3 || userHeading === null || isNaN(userHeading)) {
|
||||
return { type: 'proximity', label: 'RADAR PROXIMITY', angleDelta: 0, suppressAlert: false };
|
||||
}
|
||||
const camBearing = getBearing(userLat, userLng, camLat, camLng);
|
||||
const angleDelta = Math.abs((userHeading - camBearing + 180) % 360 - 180);
|
||||
|
||||
if (angleDelta < 50) {
|
||||
return { type: 'direct', label: 'DIRECT APPROACH', angleDelta, suppressAlert: false };
|
||||
} else if (angleDelta <= 130) {
|
||||
return { type: 'cross', label: 'CROSS-TRAFFIC', angleDelta, suppressAlert: false };
|
||||
} else {
|
||||
return { type: 'away', label: 'RECEDING / BEHIND', angleDelta, suppressAlert: true };
|
||||
}
|
||||
}
|
||||
|
||||
window.getBearing = getBearing;
|
||||
window.classifyApproach = classifyApproach;
|
||||
|
||||
// --- PROXIMITY CALCULATION & ALERT ENGINE ---
|
||||
function checkProximityToCameras() {
|
||||
if (!allCameras.length) return;
|
||||
@@ -809,7 +881,7 @@
|
||||
// Check if inside zone radius
|
||||
const insideZone = closestDistMeters <= (closestCam.radius || 300);
|
||||
|
||||
// Update HUD
|
||||
// Update HUD distance display
|
||||
if (distFt < 5280) {
|
||||
el.hudDistVal.textContent = distFt > 1000 ? (distFt / 1000).toFixed(1) + 'k' : distFt;
|
||||
el.hudDistSub.textContent = 'FT';
|
||||
@@ -822,18 +894,55 @@
|
||||
el.hudCamAgency.textContent = closestCam.name;
|
||||
}
|
||||
|
||||
// Update Graduated Radar Signal Strength Meter (Valentine One Style)
|
||||
updateRadarGauge(distFt, insideZone, closestCam);
|
||||
const approach = closestCam
|
||||
? classifyApproach(currentPosition.lat, currentPosition.lng, lastGpsHeading, closestCam.lat, closestCam.lng, currentSpeed)
|
||||
: { type: 'proximity', label: 'ALL CLEAR', angleDelta: 0, suppressAlert: false };
|
||||
|
||||
const cautionThreshold = (radarRangeMode === 'highway') ? 2500 : 1200;
|
||||
|
||||
// Update Directional Approach Angle Badge
|
||||
if (el.hudApproachBadge) {
|
||||
if (distFt > cautionThreshold || !closestCam) {
|
||||
el.hudApproachBadge.className = 'hud-approach-badge approach-none';
|
||||
el.hudApproachBadge.textContent = 'ALL CLEAR';
|
||||
} else {
|
||||
el.hudApproachBadge.className = `hud-approach-badge approach-${approach.type}`;
|
||||
el.hudApproachBadge.textContent = approach.label;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic Time-to-Zone Intercept Countdown
|
||||
if (el.hudEtaVal && el.hudEtaSub) {
|
||||
if (currentSpeed >= 4 && !approach.suppressAlert && distFt <= (radarRangeMode === 'highway' ? 3500 : 2000)) {
|
||||
const speedMps = currentSpeed * 0.44704;
|
||||
const etaSec = Math.round(closestDistMeters / speedMps);
|
||||
if (etaSec <= 60) {
|
||||
el.hudEtaVal.textContent = `~${etaSec}`;
|
||||
el.hudEtaSub.textContent = 'SEC';
|
||||
} else {
|
||||
el.hudEtaVal.textContent = `~${(etaSec / 60).toFixed(1)}`;
|
||||
el.hudEtaSub.textContent = 'MIN';
|
||||
}
|
||||
} else if (approach.suppressAlert && distFt <= cautionThreshold) {
|
||||
el.hudEtaVal.textContent = '—';
|
||||
el.hudEtaSub.textContent = 'AWAY';
|
||||
} else {
|
||||
el.hudEtaVal.textContent = '—';
|
||||
el.hudEtaSub.textContent = 'IDLE';
|
||||
}
|
||||
}
|
||||
|
||||
// Update Graduated Radar Signal Strength Meter
|
||||
updateRadarGauge(distFt, insideZone, closestCam, approach);
|
||||
|
||||
// Audio Proximity Geiger-Counter Chirps
|
||||
handleProximityChirps(distFt);
|
||||
handleProximityChirps(distFt, approach);
|
||||
|
||||
// State Transitions
|
||||
const cautionThreshold = (radarRangeMode === 'highway') ? 2500 : 1200;
|
||||
if (insideZone) {
|
||||
if (!isInZone) {
|
||||
// Just entered zone! Trigger Alert!
|
||||
triggerZoneAlert(closestCam, distFt);
|
||||
triggerZoneAlert(closestCam, distFt, approach);
|
||||
}
|
||||
isInZone = true;
|
||||
el.hudZoneBadge.className = 'hud-badge badge-danger';
|
||||
@@ -843,14 +952,22 @@
|
||||
<span class="banner-pulse-icon">${window.FlockIcons ? FlockIcons.alertTriangle('flock-svg-lg') : ''}</span>
|
||||
<div>
|
||||
<strong>FLOCK CAMERA ACTIVE ZONE</strong>
|
||||
<p>${escapeHtml(closestCam.agency)} • Vehicle Profile Logging</p>
|
||||
<p>${escapeHtml(closestCam.agency)} • ${approach.label}</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`;
|
||||
if (approach.suppressAlert) {
|
||||
el.hudZoneBadge.className = 'hud-badge badge-clear';
|
||||
el.hudZoneBadge.innerHTML = `<span class="badge-dot dot-clear"></span> RECEDING CAMERA`;
|
||||
} else if (approach.type === 'cross') {
|
||||
el.hudZoneBadge.className = 'hud-badge badge-warning';
|
||||
el.hudZoneBadge.innerHTML = `<span class="badge-dot dot-warning"></span> CROSS-TRAFFIC CAMERA`;
|
||||
} else {
|
||||
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
|
||||
@@ -862,7 +979,7 @@
|
||||
}
|
||||
|
||||
// --- 3-STAGE GRADUATED RADAR DETECTOR SIGNAL GAUGE ---
|
||||
function updateRadarGauge(distFt, insideZone, closestCam) {
|
||||
function updateRadarGauge(distFt, insideZone, closestCam, approach) {
|
||||
if (!el.hudGaugeState || !el.hudSignalMeter) return;
|
||||
|
||||
const isHighway = (radarRangeMode === 'highway');
|
||||
@@ -874,6 +991,14 @@
|
||||
b.className = 'signal-bar';
|
||||
});
|
||||
|
||||
// If moving away from camera past immediate zone, show subdued reassurance
|
||||
if (approach && approach.suppressAlert && distFt > 350) {
|
||||
el.hudGaugeState.className = 'hud-gauge-status status-green';
|
||||
el.hudGaugeState.textContent = `RECEDING (${distFt} FT)`;
|
||||
bars[0].classList.add('active-green');
|
||||
return;
|
||||
}
|
||||
|
||||
if (insideZone || distFt <= 450) {
|
||||
// Level 5: Red Alert In Zone
|
||||
el.hudGaugeState.className = 'hud-gauge-status status-red';
|
||||
@@ -882,7 +1007,9 @@
|
||||
} else if (distFt <= warnDist) {
|
||||
// Level 4: Immediate Proximity
|
||||
el.hudGaugeState.className = 'hud-gauge-status status-red';
|
||||
el.hudGaugeState.textContent = `ALERT: ${distFt} FT`;
|
||||
el.hudGaugeState.textContent = (approach && approach.type === 'cross')
|
||||
? `CROSS-TRAFFIC: ${distFt} FT`
|
||||
: `ALERT: ${distFt} FT`;
|
||||
bars[0].classList.add('active-red');
|
||||
bars[1].classList.add('active-red');
|
||||
bars[2].classList.add('active-red');
|
||||
@@ -890,16 +1017,21 @@
|
||||
} 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)`;
|
||||
el.hudGaugeState.textContent = (approach && approach.type === 'cross')
|
||||
? `CROSS-STREET (${Math.round(distFt / 100) * 100} FT)`
|
||||
: `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
|
||||
// Pre-alert announcement once per camera approach (only if not moving away)
|
||||
if (closestCam && lastCautionSpokenCamId !== closestCam.id) {
|
||||
lastCautionSpokenCamId = closestCam.id;
|
||||
playCautionBeep();
|
||||
speakVoiceAlert(`Caution. Approaching surveillance camera in ${Math.round(distFt / 100) * 100} feet.`);
|
||||
if (!approach || !approach.suppressAlert) {
|
||||
lastCautionSpokenCamId = closestCam.id;
|
||||
playCautionBeep();
|
||||
const approachDesc = (approach && approach.type === 'cross') ? 'cross-traffic ' : '';
|
||||
speakVoiceAlert(`Caution. Approaching ${approachDesc}surveillance camera in ${Math.round(distFt / 100) * 100} feet.`);
|
||||
}
|
||||
}
|
||||
} else if (distFt <= (cautionDist * 1.6)) {
|
||||
// Level 2: Grid Range
|
||||
@@ -919,27 +1051,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- AUDIO SYNTHESIZERS: CHIRPS & CAUTION BEEPS ---
|
||||
// --- AUDIO SYNTHESIZERS & MASTER GAIN BOOSTER (v27) ---
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume();
|
||||
}
|
||||
if (!masterGainNode) {
|
||||
masterGainNode = audioContext.createGain();
|
||||
masterGainNode.gain.setValueAtTime(isAudioBoostEnabled ? 1.6 : 1.0, audioContext.currentTime);
|
||||
masterGainNode.connect(audioContext.destination);
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
function getAudioOutput() {
|
||||
const ctx = getAudioContext();
|
||||
return masterGainNode || ctx.destination;
|
||||
}
|
||||
|
||||
function triggerHapticFeedback(pattern = [180, 70, 180, 70, 350]) {
|
||||
if (!isHapticBoostEnabled) return;
|
||||
if ('vibrate' in navigator) {
|
||||
try {
|
||||
navigator.vibrate(pattern);
|
||||
} catch (e) {
|
||||
// Ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
const ctx = getAudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.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.frequency.setValueAtTime(587.33, ctx.currentTime); // D5
|
||||
osc.frequency.setValueAtTime(880, ctx.currentTime + 0.1); // A5
|
||||
osc.frequency.setValueAtTime(1174.66, ctx.currentTime + 0.2); // D6
|
||||
gain.gain.setValueAtTime(0.35, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
||||
osc.connect(gain);
|
||||
gain.connect(audioContext.destination);
|
||||
gain.connect(getAudioOutput());
|
||||
osc.start();
|
||||
osc.stop(audioContext.currentTime + 0.4);
|
||||
osc.stop(ctx.currentTime + 0.4);
|
||||
} catch (e) {
|
||||
console.warn('Log chirp error:', e);
|
||||
}
|
||||
@@ -949,22 +1107,17 @@
|
||||
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();
|
||||
const ctx = getAudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.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.frequency.setValueAtTime(520, ctx.currentTime);
|
||||
gain.gain.setValueAtTime(0.25, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.22);
|
||||
osc.connect(gain);
|
||||
gain.connect(audioContext.destination);
|
||||
gain.connect(getAudioOutput());
|
||||
osc.start();
|
||||
osc.stop(audioContext.currentTime + 0.22);
|
||||
osc.stop(ctx.currentTime + 0.22);
|
||||
} catch (e) {
|
||||
console.warn('Caution beep error:', e);
|
||||
}
|
||||
@@ -1047,37 +1200,37 @@
|
||||
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();
|
||||
const ctx = getAudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, audioContext.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(freq * 1.15, audioContext.currentTime + 0.045);
|
||||
osc.frequency.setValueAtTime(freq, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(freq * 1.15, ctx.currentTime + 0.045);
|
||||
|
||||
gain.gain.setValueAtTime(volume, audioContext.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.05);
|
||||
gain.gain.setValueAtTime(volume, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.05);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(audioContext.destination);
|
||||
gain.connect(getAudioOutput());
|
||||
|
||||
osc.start();
|
||||
osc.stop(audioContext.currentTime + 0.05);
|
||||
osc.stop(ctx.currentTime + 0.05);
|
||||
} catch (e) {
|
||||
// AudioContext policy
|
||||
}
|
||||
}
|
||||
|
||||
function handleProximityChirps(distFt) {
|
||||
function handleProximityChirps(distFt, approach) {
|
||||
if (radarAudioMode === 'silent') return;
|
||||
const user = window.FlockAuth ? window.FlockAuth.getUser() : null;
|
||||
if (user && !user.soundEnabled) return;
|
||||
|
||||
// Suppress repeated geiger chirps when driving away from camera past 350ft
|
||||
if (approach && approach.suppressAlert && distFt > 350) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxChirpDist = (radarRangeMode === 'highway') ? 2200 : 1200;
|
||||
if (distFt > maxChirpDist || distFt <= 0) {
|
||||
stationaryChirpCount = 0;
|
||||
@@ -1202,30 +1355,25 @@
|
||||
lastChimeTime = now;
|
||||
|
||||
try {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume();
|
||||
}
|
||||
const ctx = getAudioContext();
|
||||
|
||||
// Synthesize clean two-tone radar alert
|
||||
const osc = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(880, audioContext.currentTime);
|
||||
osc.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.12);
|
||||
osc.frequency.setValueAtTime(880, audioContext.currentTime + 0.24);
|
||||
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
||||
osc.frequency.setValueAtTime(659.25, ctx.currentTime + 0.12);
|
||||
osc.frequency.setValueAtTime(880, ctx.currentTime + 0.24);
|
||||
|
||||
gain.gain.setValueAtTime(0.35, audioContext.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.45);
|
||||
gain.gain.setValueAtTime(0.35, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.45);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(audioContext.destination);
|
||||
gain.connect(getAudioOutput());
|
||||
|
||||
osc.start();
|
||||
osc.stop(audioContext.currentTime + 0.45);
|
||||
osc.stop(ctx.currentTime + 0.45);
|
||||
} catch (e) {
|
||||
console.warn('Audio chime error:', e);
|
||||
}
|
||||
@@ -1242,16 +1390,22 @@
|
||||
const utter = new SpeechSynthesisUtterance(text);
|
||||
utter.rate = 1.05;
|
||||
utter.pitch = 1.0;
|
||||
utter.volume = 1.0;
|
||||
utter.volume = isAudioBoostEnabled ? 1.0 : 0.9;
|
||||
window.speechSynthesis.speak(utter);
|
||||
} catch (e) {
|
||||
console.warn('Speech error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerZoneAlert(cam, distFt) {
|
||||
function triggerZoneAlert(cam, distFt, approach) {
|
||||
if (approach && approach.suppressAlert) return;
|
||||
playRadarChime();
|
||||
speakVoiceAlert("Warning. Flock camera detected ahead. You are entering a recorded surveillance zone.");
|
||||
triggerHapticFeedback([180, 70, 180, 70, 350]);
|
||||
if (approach && approach.type === 'cross') {
|
||||
speakVoiceAlert("Caution. Cross-traffic Flock camera active on perpendicular avenue.");
|
||||
} else {
|
||||
speakVoiceAlert("Warning. Flock camera detected ahead. You are entering a recorded surveillance zone.");
|
||||
}
|
||||
}
|
||||
|
||||
// --- VIRAL SHARING HELPER ---
|
||||
@@ -2413,6 +2567,267 @@ Generated via FlockRadar Civic Transparency Network (flockradar.org)`;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// v27 DRIVER-CENTRIC POLISH REFINEMENTS
|
||||
// 1. Instant 1-Tap Driving Sighting Stamp
|
||||
// 2. Windshield Reflection Mirror Mode
|
||||
// 3. Stealth AMOLED True Black Mode
|
||||
// 4. Cabin Audio Gain Booster (+6dB)
|
||||
// 5. Steering Haptic Vibration Feedback
|
||||
// ==========================================================================
|
||||
|
||||
// --- 1-TAP "MARK CAMERA WHILE DRIVING" INSTANT SIGHTING STAMP ---
|
||||
function loadQuickStamps() {
|
||||
try {
|
||||
const stored = localStorage.getItem('flock_quick_stamps');
|
||||
savedQuickStamps = stored ? JSON.parse(stored) : [];
|
||||
} catch (e) {
|
||||
savedQuickStamps = [];
|
||||
}
|
||||
renderStampedSightings();
|
||||
renderExistingStampMarkers();
|
||||
}
|
||||
|
||||
function saveQuickStamps() {
|
||||
try {
|
||||
localStorage.setItem('flock_quick_stamps', JSON.stringify(savedQuickStamps));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save quick stamps', e);
|
||||
}
|
||||
renderStampedSightings();
|
||||
}
|
||||
|
||||
function renderExistingStampMarkers() {
|
||||
if (!map) return;
|
||||
stampMarkers.forEach(m => map.removeLayer(m));
|
||||
stampMarkers = [];
|
||||
|
||||
savedQuickStamps.forEach(s => {
|
||||
const stampIcon = L.divIcon({
|
||||
className: 'stamp-marker-icon',
|
||||
html: `<div class="stamp-pulse-pin" style="width:24px; height:24px; font-size:12px;">⚡</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
const marker = L.marker([s.lat, s.lng], { icon: stampIcon }).addTo(map);
|
||||
marker.bindPopup(`
|
||||
<div style="font-family:inherit; min-width:180px; font-size:0.8rem; color:#0f172a; padding:4px;">
|
||||
<strong style="color:#d97706; display:flex; align-items:center; gap:4px;">
|
||||
⚡ Stamped Sighting (Draft)
|
||||
</strong>
|
||||
<p style="margin:4px 0 6px 0; font-size:0.75rem; color:#475569;">
|
||||
Logged at: <strong>${s.timeStr}</strong><br>
|
||||
Speed: <strong>${s.speed} MPH</strong> • Heading: <strong>${s.heading}°</strong><br>
|
||||
Coords: <code>${s.lat}, ${s.lng}</code>
|
||||
</p>
|
||||
<span style="font-size:0.72rem; color:#0284c7; font-weight:600;">Saved in Local Drafts</span>
|
||||
</div>
|
||||
`);
|
||||
stampMarkers.push(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function quickMarkAtGps() {
|
||||
const lat = currentPosition.lat;
|
||||
const lng = currentPosition.lng;
|
||||
const speed = currentSpeed || 0;
|
||||
const heading = lastGpsHeading || 0;
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
const stamp = {
|
||||
id: 'stamp_' + Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lng.toFixed(6)),
|
||||
speed: Math.round(speed),
|
||||
heading: Math.round(heading),
|
||||
timestamp: now.toISOString(),
|
||||
timeStr: timeStr
|
||||
};
|
||||
|
||||
savedQuickStamps.unshift(stamp);
|
||||
saveQuickStamps();
|
||||
|
||||
// Instant Audio Affirmation (Dual high-pitch chirp)
|
||||
playLogConfirmationChirp();
|
||||
|
||||
// Haptic steering vibration pulse
|
||||
triggerHapticFeedback([100, 50, 120]);
|
||||
|
||||
// Drop marker on Leaflet map
|
||||
if (map) {
|
||||
const stampIcon = L.divIcon({
|
||||
className: 'stamp-marker-icon',
|
||||
html: `<div class="stamp-pulse-pin" style="width:26px; height:26px;">⚡</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13]
|
||||
});
|
||||
const marker = L.marker([stamp.lat, stamp.lng], { icon: stampIcon }).addTo(map);
|
||||
marker.bindPopup(`
|
||||
<div style="font-family:inherit; min-width:180px; font-size:0.8rem; color:#0f172a; padding:4px;">
|
||||
<strong style="color:#d97706; display:flex; align-items:center; gap:4px;">
|
||||
⚡ Stamped Sighting (Draft)
|
||||
</strong>
|
||||
<p style="margin:4px 0 6px 0; font-size:0.75rem; color:#475569;">
|
||||
Logged at: <strong>${timeStr}</strong><br>
|
||||
Speed: <strong>${speed} MPH</strong> • Heading: <strong>${heading}°</strong><br>
|
||||
Coords: <code>${stamp.lat}, ${stamp.lng}</code>
|
||||
</p>
|
||||
<span style="font-size:0.72rem; color:#0284c7; font-weight:600;">Saved in Local Drafts</span>
|
||||
</div>
|
||||
`).openPopup();
|
||||
stampMarkers.push(marker);
|
||||
}
|
||||
|
||||
showToast(`⚡ Camera Sighting Stamped at ${stamp.lat}, ${stamp.lng}! Saved to drafts.`);
|
||||
}
|
||||
|
||||
function clearAllStamps() {
|
||||
if (!savedQuickStamps.length) return;
|
||||
if (confirm('Clear all saved camera sighting stamps?')) {
|
||||
savedQuickStamps = [];
|
||||
saveQuickStamps();
|
||||
stampMarkers.forEach(m => {
|
||||
if (map && m) map.removeLayer(m);
|
||||
});
|
||||
stampMarkers = [];
|
||||
showToast('Cleared all local camera stamps.');
|
||||
}
|
||||
}
|
||||
|
||||
function renderStampedSightings() {
|
||||
if (el.stampsCount) {
|
||||
el.stampsCount.textContent = savedQuickStamps.length;
|
||||
}
|
||||
if (!el.stampsListContainer) return;
|
||||
|
||||
if (!savedQuickStamps.length) {
|
||||
el.stampsListContainer.innerHTML = `<p class="stamps-empty-hint">No quick stamps yet. Tap ⚡ Stamp while driving to record GPS instantly.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
el.stampsListContainer.innerHTML = savedQuickStamps.map(s => `
|
||||
<div class="stamp-item-card" data-id="${s.id}">
|
||||
<div class="stamp-item-left">
|
||||
<span class="stamp-badge-zap">⚡</span>
|
||||
<div>
|
||||
<strong>${s.timeStr}</strong> (${s.speed} MPH)
|
||||
<div style="font-size:0.72rem; color:#94a3b8;">${s.lat.toFixed(4)}, ${s.lng.toFixed(4)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-mini-link" style="color:#38bdf8; background:none; border:none; cursor:pointer; font-size:0.72rem; font-weight:600;" onclick="window.zoomToStamp(${s.lat}, ${s.lng})">Zoom</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
window.zoomToStamp = function(lat, lng) {
|
||||
if (map) {
|
||||
map.setView([lat, lng], 17, { animate: true });
|
||||
showToast(`Zoomed to stamped camera at ${lat}, ${lng}`);
|
||||
}
|
||||
};
|
||||
|
||||
// --- WINDSHIELD REFLECTION MIRROR MODE ---
|
||||
function applyHudMirror(enabled, showFeedback = true) {
|
||||
isHudMirrored = enabled;
|
||||
localStorage.setItem('flock_hud_mirror', enabled ? 'true' : 'false');
|
||||
if (el.driverHud) {
|
||||
el.driverHud.classList.toggle('mirror-hud', enabled);
|
||||
}
|
||||
if (el.btnMirrorHud) {
|
||||
el.btnMirrorHud.classList.toggle('active', enabled);
|
||||
}
|
||||
if (el.mirrorHudLabel) {
|
||||
el.mirrorHudLabel.textContent = enabled ? 'Windshield Mirror: ON' : 'Windshield Mirror: OFF';
|
||||
}
|
||||
if (showFeedback) {
|
||||
if (enabled) {
|
||||
showToast('🪞 Windshield HUD Mirror ON: Lay phone flat on dashboard under glass!');
|
||||
} else {
|
||||
showToast('🪞 Windshield HUD Mirror OFF');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMirrorHud() {
|
||||
applyHudMirror(!isHudMirrored, true);
|
||||
}
|
||||
|
||||
// --- STEALTH AMOLED NIGHT MODE (TRUE #000000) ---
|
||||
function applyStealthAmoled(enabled, showFeedback = true) {
|
||||
isStealthAmoled = enabled;
|
||||
localStorage.setItem('flock_stealth_amoled', enabled ? 'true' : 'false');
|
||||
document.body.classList.toggle('stealth-amoled', enabled);
|
||||
if (el.btnStealthAmoled) {
|
||||
el.btnStealthAmoled.classList.toggle('active', enabled);
|
||||
}
|
||||
if (el.stealthAmoledLabel) {
|
||||
el.stealthAmoledLabel.textContent = enabled ? 'AMOLED Pitch Dark: ON' : 'AMOLED Pitch Dark: OFF';
|
||||
}
|
||||
if (showFeedback) {
|
||||
if (enabled) {
|
||||
showToast('🌙 AMOLED Stealth Mode Activated: Zero Cockpit Glare (True #000000)');
|
||||
} else {
|
||||
showToast('☀️ AMOLED Stealth Mode Deactivated');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStealthAmoled() {
|
||||
applyStealthAmoled(!isStealthAmoled, true);
|
||||
}
|
||||
|
||||
// --- CABIN AUDIO GAIN BOOSTER (+6dB) ---
|
||||
function applyAudioBoost(enabled, showFeedback = true) {
|
||||
isAudioBoostEnabled = enabled;
|
||||
localStorage.setItem('flock_radar_audio_boost', enabled ? 'true' : 'false');
|
||||
if (audioContext && masterGainNode) {
|
||||
masterGainNode.gain.setValueAtTime(enabled ? 1.6 : 1.0, audioContext.currentTime);
|
||||
}
|
||||
if (el.btnToggleAudioBoost) {
|
||||
el.btnToggleAudioBoost.classList.toggle('active', enabled);
|
||||
}
|
||||
if (el.audioBoostLabel) {
|
||||
el.audioBoostLabel.textContent = enabled ? 'Audio Boost (+6dB): ON' : 'Audio Boost (+6dB): OFF';
|
||||
}
|
||||
if (showFeedback) {
|
||||
playRadarTick(enabled ? 1100 : 700, 0.3);
|
||||
if (enabled) {
|
||||
showToast('🔊 Cabin Audio Boost (+6dB / 160%) ENABLED');
|
||||
} else {
|
||||
showToast('🔈 Audio Volume Set to Standard Level');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAudioBoost() {
|
||||
applyAudioBoost(!isAudioBoostEnabled, true);
|
||||
}
|
||||
|
||||
// --- HAPTIC STEERING VIBRATION FEEDBACK ---
|
||||
function applyHapticBoost(enabled, showFeedback = true) {
|
||||
isHapticBoostEnabled = enabled;
|
||||
localStorage.setItem('flock_radar_haptic_boost', enabled ? 'true' : 'false');
|
||||
if (el.btnToggleHapticBoost) {
|
||||
el.btnToggleHapticBoost.classList.toggle('active', enabled);
|
||||
}
|
||||
if (el.hapticBoostLabel) {
|
||||
el.hapticBoostLabel.textContent = enabled ? 'Steering Haptics: ON' : 'Steering Haptics: OFF';
|
||||
}
|
||||
if (showFeedback) {
|
||||
if (enabled) {
|
||||
triggerHapticFeedback([100, 50, 150]);
|
||||
showToast('📳 Haptic Steering Vibration Alerts ENABLED');
|
||||
} else {
|
||||
showToast('📴 Steering Haptic Alerts Disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleHapticBoost() {
|
||||
applyHapticBoost(!isHapticBoostEnabled, true);
|
||||
}
|
||||
|
||||
// --- SURVEILLANCE ZONE HARDWARE PROOF MODAL ---
|
||||
function openCameraDetailModal(cam) {
|
||||
if (!cam) return;
|
||||
@@ -2944,6 +3359,14 @@ Generated via FlockRadar Civic Transparency Network (flockradar.org)`;
|
||||
if (el.btnSensCity) el.btnSensCity.addEventListener('click', () => setRadarSensitivity('city'));
|
||||
if (el.btnSensHwy) el.btnSensHwy.addEventListener('click', () => setRadarSensitivity('highway'));
|
||||
|
||||
// v27 Driving Polish & Cockpit Listeners
|
||||
if (el.btnQuickMarkDash) el.btnQuickMarkDash.addEventListener('click', quickMarkAtGps);
|
||||
if (el.btnClearStamps) el.btnClearStamps.addEventListener('click', clearAllStamps);
|
||||
if (el.btnMirrorHud) el.btnMirrorHud.addEventListener('click', toggleMirrorHud);
|
||||
if (el.btnStealthAmoled) el.btnStealthAmoled.addEventListener('click', toggleStealthAmoled);
|
||||
if (el.btnToggleAudioBoost) el.btnToggleAudioBoost.addEventListener('click', toggleAudioBoost);
|
||||
if (el.btnToggleHapticBoost) el.btnToggleHapticBoost.addEventListener('click', toggleHapticBoost);
|
||||
|
||||
// Camera Hardware Proof & Voting Events
|
||||
if (el.camDetailCloseBtn) el.camDetailCloseBtn.addEventListener('click', closeCameraDetailModal);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user