feat: add manual photo proof capture, consensus voting, and anti-sabotage camera verification engine
This commit is contained in:
+214
-8
@@ -8,6 +8,8 @@
|
||||
|
||||
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 = {
|
||||
@@ -19,16 +21,21 @@
|
||||
vehicle: 'Sedan (Silver)',
|
||||
soundEnabled: true,
|
||||
alertsMuted: false,
|
||||
reportsCount: 0
|
||||
reportsCount: 0,
|
||||
photosCount: 0
|
||||
};
|
||||
|
||||
// State
|
||||
let currentUser = null;
|
||||
let communityReports = [];
|
||||
let cameraMetadata = {};
|
||||
let userVotes = { confirmed: {}, contested: {} };
|
||||
|
||||
function init() {
|
||||
loadUser();
|
||||
loadReports();
|
||||
loadMetadata();
|
||||
loadVotes();
|
||||
}
|
||||
|
||||
function loadUser() {
|
||||
@@ -53,17 +60,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
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: 250,
|
||||
points: (currentUser ? currentUser.points : 0) + 100,
|
||||
vehicle: vehicle || 'Standard Vehicle',
|
||||
soundEnabled: soundEnabled !== false,
|
||||
alertsMuted: false,
|
||||
reportsCount: communityReports.length
|
||||
reportsCount: communityReports.length,
|
||||
photosCount: currentUser ? (currentUser.photosCount || 0) : 0
|
||||
};
|
||||
saveUser();
|
||||
return currentUser;
|
||||
@@ -97,30 +140,186 @@
|
||||
}
|
||||
|
||||
function addReport(report) {
|
||||
const reportId = 'rpt-' + Date.now();
|
||||
const newReport = {
|
||||
id: 'rpt-' + Date.now(),
|
||||
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.username : 'Anonymous Scout',
|
||||
reportedBy: currentUser ? (currentUser.callsign || currentUser.username) : 'Anonymous Scout',
|
||||
timestamp: new Date().toISOString(),
|
||||
confirmations: 1
|
||||
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) + 50;
|
||||
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
|
||||
cameraMetadata[camId] = {
|
||||
confirmations: 3, // Baseline community confirmations for directory portals
|
||||
hasPhoto: false,
|
||||
photoUrl: null,
|
||||
photoDate: null,
|
||||
photoAuthor: null,
|
||||
isContested: false,
|
||||
contests: []
|
||||
};
|
||||
}
|
||||
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;
|
||||
@@ -139,7 +338,14 @@
|
||||
logout,
|
||||
saveUser,
|
||||
getReports,
|
||||
addReport
|
||||
addReport,
|
||||
getCameraMeta,
|
||||
confirmCamera,
|
||||
addPhotoProof,
|
||||
contestCamera,
|
||||
hasUserConfirmed,
|
||||
hasUserContested,
|
||||
compressImage
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user