Files

419 lines
13 KiB
JavaScript

/**
* FLOCKRADAR - USER AUTHENTICATION & COMMUNITY STATE MANAGER
* Waze-style driver profile, preferences, and crowdsourced reporting store.
*/
(function() {
'use strict';
const STORAGE_KEY_USER = 'flock_radar_user';
const STORAGE_KEY_REPORTS = 'flock_community_reports';
const STORAGE_KEY_METADATA = 'flock_camera_metadata';
const STORAGE_KEY_VOTES = 'flock_user_votes';
// Default Guest Profile
const defaultUser = {
isLoggedIn: false,
username: 'Guest Driver',
callsign: 'SCOUT-104',
level: 1,
points: 120,
vehicle: 'Sedan (Silver)',
soundEnabled: true,
alertsMuted: false,
reportsCount: 0,
photosCount: 0
};
// State
let currentUser = null;
let communityReports = [];
let cameraMetadata = {};
let userVotes = { confirmed: {}, contested: {} };
function init() {
loadUser();
loadReports();
loadMetadata();
loadVotes();
}
function loadUser() {
try {
const stored = localStorage.getItem(STORAGE_KEY_USER);
if (stored) {
currentUser = JSON.parse(stored);
} else {
currentUser = { ...defaultUser };
}
} catch (e) {
currentUser = { ...defaultUser };
}
}
function saveUser() {
try {
localStorage.setItem(STORAGE_KEY_USER, JSON.stringify(currentUser));
window.dispatchEvent(new CustomEvent('flock_user_updated', { detail: currentUser }));
} catch (e) {
console.error('Failed to save user session', e);
}
}
function loadVotes() {
try {
const stored = localStorage.getItem(STORAGE_KEY_VOTES);
userVotes = stored ? JSON.parse(stored) : { confirmed: {}, contested: {} };
} catch (e) {
userVotes = { confirmed: {}, contested: {} };
}
}
function saveVotes() {
try {
localStorage.setItem(STORAGE_KEY_VOTES, JSON.stringify(userVotes));
} catch (e) {
console.error('Failed to save votes', e);
}
}
function loadMetadata() {
try {
const stored = localStorage.getItem(STORAGE_KEY_METADATA);
cameraMetadata = stored ? JSON.parse(stored) : {};
} catch (e) {
cameraMetadata = {};
}
}
function saveMetadata() {
try {
localStorage.setItem(STORAGE_KEY_METADATA, JSON.stringify(cameraMetadata));
window.dispatchEvent(new CustomEvent('flock_meta_updated', { detail: cameraMetadata }));
} catch (e) {
console.error('Failed to save camera metadata', e);
}
}
function login(username, vehicle, soundEnabled) {
currentUser = {
isLoggedIn: true,
username: username || 'Citizen Scout',
callsign: 'RADAR-' + Math.floor(100 + Math.random() * 900),
level: 2,
points: (currentUser ? currentUser.points : 0) + 100,
vehicle: vehicle || 'Standard Vehicle',
soundEnabled: soundEnabled !== false,
alertsMuted: false,
reportsCount: communityReports.length,
photosCount: currentUser ? (currentUser.photosCount || 0) : 0
};
saveUser();
return currentUser;
}
function logout() {
currentUser = { ...defaultUser };
saveUser();
}
function loadReports() {
try {
const stored = localStorage.getItem(STORAGE_KEY_REPORTS);
if (stored) {
communityReports = JSON.parse(stored);
// Automatically prune any unverified test marks or pins without photo proof
const beforeCount = communityReports.length;
communityReports = communityReports.filter(r =>
r &&
r.photoUrl &&
!r.id.startsWith('usr-pin-') &&
r.notes !== '1-Tap Mobile Driver Pin' &&
r.type !== 'Fixed ALPR Camera'
);
if (communityReports.length !== beforeCount) {
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify(communityReports));
}
} else {
communityReports = [];
}
} catch (e) {
communityReports = [];
}
}
function clearUnverifiedReports() {
try {
communityReports = communityReports.filter(r =>
r &&
r.photoUrl &&
!r.id.startsWith('usr-pin-') &&
r.notes !== '1-Tap Mobile Driver Pin' &&
r.type !== 'Fixed ALPR Camera'
);
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify(communityReports));
Object.keys(cameraMetadata).forEach(id => {
if (id.startsWith('usr-pin-') || !cameraMetadata[id].photoUrl) {
delete cameraMetadata[id];
}
});
localStorage.setItem(STORAGE_KEY_METADATA, JSON.stringify(cameraMetadata));
window.dispatchEvent(new CustomEvent('flock_reports_updated', { detail: communityReports }));
} catch (e) {
console.warn('Could not clear unverified reports', e);
}
}
function removeReport(reportId) {
communityReports = communityReports.filter(r => r.id !== reportId);
if (cameraMetadata[reportId]) delete cameraMetadata[reportId];
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify(communityReports));
localStorage.setItem(STORAGE_KEY_METADATA, JSON.stringify(cameraMetadata));
window.dispatchEvent(new CustomEvent('flock_reports_updated', { detail: communityReports }));
return true;
}
function clearAllPlacedReports() {
communityReports = [];
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify([]));
// Clean up metadata for custom placed pins
Object.keys(cameraMetadata).forEach(id => {
if (id.startsWith('rpt-') || id.startsWith('usr-pin-') || id.startsWith('test-') || id.startsWith('cam-test-')) {
delete cameraMetadata[id];
}
});
localStorage.setItem(STORAGE_KEY_METADATA, JSON.stringify(cameraMetadata));
window.dispatchEvent(new CustomEvent('flock_reports_updated', { detail: [] }));
return true;
}
function saveReports() {
try {
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify(communityReports));
window.dispatchEvent(new CustomEvent('flock_reports_updated', { detail: communityReports }));
} catch (e) {
console.error('Failed to save community reports', e);
}
}
function addReport(report) {
const reportId = 'rpt-' + Date.now();
const newReport = {
id: reportId,
lat: report.lat,
lng: report.lng,
type: report.type || 'Solar Pole Mount',
intersection: report.intersection || 'Reported Location',
notes: report.notes || 'Spotted by community driver',
reportedBy: currentUser ? (currentUser.callsign || currentUser.username) : 'Anonymous Scout',
timestamp: new Date().toISOString(),
confirmations: 1,
photoUrl: report.photoUrl || null
};
communityReports.push(newReport);
saveReports();
// If report has photo, initialize metadata
if (report.photoUrl) {
cameraMetadata[reportId] = {
confirmations: 1,
hasPhoto: true,
photoUrl: report.photoUrl,
photoDate: new Date().toISOString(),
photoAuthor: currentUser ? (currentUser.callsign || currentUser.username) : 'Scout',
isContested: false,
contests: []
};
saveMetadata();
}
if (currentUser) {
currentUser.points = (currentUser.points || 0) + (report.photoUrl ? 150 : 50);
currentUser.reportsCount = (currentUser.reportsCount || 0) + 1;
if (report.photoUrl) {
currentUser.photosCount = (currentUser.photosCount || 0) + 1;
}
saveUser();
}
return newReport;
}
// --- CAMERA METADATA, CONFIRMATION & PHOTO PROOF API ---
function getCameraMeta(camId) {
if (!cameraMetadata[camId]) {
// Default baseline
const isVerifiedPalmdale = (camId === 'cam-plm-1' || camId === 'cam-plm-9');
cameraMetadata[camId] = {
confirmations: isVerifiedPalmdale ? 18 : 3, // Baseline community confirmations for directory portals
hasPhoto: isVerifiedPalmdale,
photoUrl: isVerifiedPalmdale ? 'assets/img/flock_pole_cam_palmdale.jpg' : null,
photoDate: isVerifiedPalmdale ? '2026-09-12' : null,
photoAuthor: isVerifiedPalmdale ? 'Driver Scout #814' : null,
isContested: false,
contests: []
};
} else if ((camId === 'cam-plm-1' || camId === 'cam-plm-9') && !cameraMetadata[camId].photoUrl) {
cameraMetadata[camId].hasPhoto = true;
cameraMetadata[camId].photoUrl = 'assets/img/flock_pole_cam_palmdale.jpg';
cameraMetadata[camId].photoDate = '2026-09-12';
cameraMetadata[camId].photoAuthor = 'Driver Scout #814';
}
return cameraMetadata[camId];
}
function confirmCamera(camId) {
if (userVotes.confirmed[camId]) {
return { success: false, message: 'You have already confirmed this camera is active!' };
}
const meta = getCameraMeta(camId);
meta.confirmations = (meta.confirmations || 0) + 1;
userVotes.confirmed[camId] = new Date().toISOString();
saveMetadata();
saveVotes();
if (currentUser) {
currentUser.points = (currentUser.points || 0) + 25;
saveUser();
}
return { success: true, count: meta.confirmations };
}
function addPhotoProof(camId, photoDataUrl, notes) {
const meta = getCameraMeta(camId);
meta.hasPhoto = true;
meta.photoUrl = photoDataUrl;
meta.photoDate = new Date().toISOString();
meta.photoAuthor = currentUser ? (currentUser.callsign || currentUser.username) : 'Driver Scout';
meta.confirmations = (meta.confirmations || 0) + 2; // Adding photo boosts verification confidence
saveMetadata();
if (currentUser) {
currentUser.points = (currentUser.points || 0) + 100;
currentUser.photosCount = (currentUser.photosCount || 0) + 1;
saveUser();
}
return { success: true, meta };
}
function contestCamera(camId, reason, notes) {
if (userVotes.contested[camId]) {
return { success: false, message: 'You have already submitted a report for this location.' };
}
const meta = getCameraMeta(camId);
if (!meta.contests) meta.contests = [];
meta.contests.push({
reason: reason || 'Camera reported missing',
notes: notes || '',
author: currentUser ? (currentUser.callsign || currentUser.username) : 'Scout',
timestamp: new Date().toISOString()
});
userVotes.contested[camId] = new Date().toISOString();
// Anti-Sabotage Rule: It requires at least 3 distinct driver contests to flag a camera
if (meta.contests.length >= 3) {
meta.isContested = true;
}
saveMetadata();
saveVotes();
return { success: true, contestCount: meta.contests.length, isContested: meta.isContested };
}
function hasUserConfirmed(camId) {
return !!(userVotes.confirmed && userVotes.confirmed[camId]);
}
function hasUserContested(camId) {
return !!(userVotes.contested && userVotes.contested[camId]);
}
// --- CLIENT-SIDE FAST IMAGE COMPRESSION (CANVAS) ---
// Compresses 4MB-10MB phone camera photos down to ~60-80KB WebP/JPEG in milliseconds
function compressImage(file, maxWidth = 1000, quality = 0.72) {
return new Promise((resolve, reject) => {
if (!file || !file.type.startsWith('image/')) {
return reject(new Error('Invalid image file.'));
}
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
// Export as WebP if supported, fallback to JPEG
let dataUrl = canvas.toDataURL('image/webp', quality);
if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) {
dataUrl = canvas.toDataURL('image/jpeg', quality);
}
resolve(dataUrl);
} catch (err) {
reject(err);
}
};
img.onerror = () => reject(new Error('Failed to decode image data.'));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error('Failed to read file.'));
reader.readAsDataURL(file);
});
}
function getUser() {
if (!currentUser) loadUser();
return currentUser;
}
function getReports() {
if (!communityReports) loadReports();
return communityReports;
}
// Export API to global scope
window.FlockAuth = {
init,
getUser,
login,
logout,
saveUser,
getReports,
addReport,
removeReport,
clearAllPlacedReports,
clearUnverifiedReports,
getCameraMeta,
confirmCamera,
addPhotoProof,
contestCamera,
hasUserConfirmed,
hasUserContested,
compressImage
};
init();
})();