pawmap / static /app.js
Sarolanda's picture
adds lotties
ff594d7
(function () {
'use strict';
// ── Onboarding ────────────────────────────────────────────────────────────
const splashEl = document.getElementById('screen-splash');
const obTrack = document.getElementById('ob-track');
const obDots = document.querySelectorAll('.ob-dot');
const obNext = document.getElementById('ob-next');
const obSkip = document.getElementById('ob-skip');
let obStep = 0;
const OB_TOTAL = 4;
if (localStorage.getItem('pawmap_onboarding_v2')) {
splashEl.style.display = 'none';
}
function obGoTo(i) {
obStep = i;
obTrack.style.transform = `translateX(${-i * 100}%)`;
obDots.forEach((d, idx) => d.classList.toggle('active', idx === i));
obNext.textContent = i === OB_TOTAL - 1 ? 'Get Started' : 'Next β†’';
}
function obDismiss() {
localStorage.setItem('pawmap_onboarding_v2', '1');
splashEl.classList.add('splash-out');
setTimeout(() => { splashEl.style.display = 'none'; }, 350);
}
function obShow() {
splashEl.style.display = 'flex';
// force reflow so the transition replays when re-opening
void splashEl.offsetWidth;
splashEl.classList.remove('splash-out');
obGoTo(0);
}
obNext.addEventListener('click', () => {
if (obStep < OB_TOTAL - 1) obGoTo(obStep + 1);
else obDismiss();
});
obSkip.addEventListener('click', obDismiss);
const menuBtn = document.getElementById('menu-btn');
if (menuBtn) menuBtn.addEventListener('click', obShow);
// Swipe support
let obTouchX = null;
obTrack.addEventListener('touchstart', e => { obTouchX = e.touches[0].clientX; }, { passive: true });
obTrack.addEventListener('touchend', e => {
if (obTouchX === null) return;
const dx = e.changedTouches[0].clientX - obTouchX;
obTouchX = null;
if (dx < -40 && obStep < OB_TOTAL - 1) obGoTo(obStep + 1);
if (dx > 40 && obStep > 0) obGoTo(obStep - 1);
}, { passive: true });
// ── Icon helper ───────────────────────────────────────────────────────────
function svgIcon(name, size=20, color='currentColor') {
const ic = (typeof lucide !== 'undefined') && lucide.icons && lucide.icons[name];
if (!ic) return '';
const attrs = `xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`;
const [,, children] = ic;
const paths = children.map(([tag, a]) => {
const attrStr = Object.entries(a).map(([k,v])=>`${k}="${v}"`).join(' ');
return `<${tag} ${attrStr}/>`;
}).join('');
return `<svg ${attrs}>${paths}</svg>`;
}
// ── State ──────────────────────────────────────────────────────────────────
let currentSpecies = 'all';
let currentTimeframe = 'all';
let map, markersLayer;
let trajectoryMap = null;
let activeAnimal = null;
let selectedFile = null;
let gpsCoords = null;
let sessionId = null;
const header = document.getElementById('header');
const filterRow = document.getElementById('filter-row');
const bottomNav = document.getElementById('bottom-nav');
const photoInput= document.getElementById('photo-input');
const FLOW_SCREENS = ['analysis', 'confirm', 'profile', 'help-proof'];
// ── Navigation ─────────────────────────────────────────────────────────────
function showScreen(name) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
const el = document.getElementById('screen-' + name);
if (el) el.classList.add('active');
const isFlow = FLOW_SCREENS.includes(name);
header.classList.toggle('hidden', isFlow);
bottomNav.classList.toggle('hidden', isFlow);
filterRow.style.display = name === 'map' ? 'flex' : 'none';
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`.nav-btn[data-screen="${name}"]`);
if (navBtn) navBtn.classList.add('active');
if (name === 'map' && map) setTimeout(() => map.invalidateSize(), 60);
if (name === 'sightings') loadAnimals();
}
document.querySelectorAll('.nav-btn').forEach(btn =>
btn.addEventListener('click', () => showScreen(btn.dataset.screen))
);
// ── MAP ────────────────────────────────────────────────────────────────────
function initMap() {
map = L.map('map', { zoomControl: false }).setView([-15.7801, -47.9292], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: 'Β© <a href="https://openstreetmap.org">OSM</a>', maxZoom: 19
}).addTo(map);
markersLayer = L.layerGroup().addTo(map);
L.control.zoom({ position: 'bottomright' }).addTo(map);
map.on('click', hideCard);
}
function makeIcon(a) {
const isDog = a.species === 'dog';
const urgent = a.days_since > 30;
const color = urgent ? '#E53935' : isDog ? '#388C59' : '#FB8C00';
const badge = a.count > 1
? `<span style="position:absolute;top:-5px;right:-5px;background:#fff;color:${color};border:1.5px solid ${color};border-radius:10px;min-width:16px;height:16px;font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 3px;">${a.count}</span>` : '';
const fallbackSvg = svgIcon(isDog ? 'dog' : 'cat', 22, '#fff');
const inner = a.photo_url
? `<img src="${a.photo_url}" style="width:38px;height:38px;border-radius:50%;object-fit:cover;" onerror="this.replaceWith(document.createRange().createContextualFragment(${JSON.stringify(fallbackSvg)}))"/>`
: fallbackSvg;
return L.divIcon({
html: `<div style="position:relative;background:${color};width:42px;height:42px;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 3px 10px rgba(0,0,0,.25);border:2.5px solid ${color};">${inner}${badge}</div>`,
className:'', iconSize:[42,42], iconAnchor:[21,21], popupAnchor:[0,-26]
});
}
function renderMarkers(data) {
markersLayer.clearLayers();
const pts = data.filter(a => a.lat && a.lng);
document.getElementById('map-empty').style.display = pts.length ? 'none' : 'block';
if (!pts.length) { hideCard(); return; }
pts.forEach(a => {
const m = L.marker([a.lat, a.lng], { icon: makeIcon(a) });
m.on('click', e => { e.originalEvent.stopPropagation(); showCard(a); });
markersLayer.addLayer(m);
});
if (typeof lucide !== 'undefined') lucide.createIcons();
showCard(pts[0]);
}
function showCard(a) {
activeAnimal = a;
const isDog = a.species === 'dog';
const urgent = a.days_since > 30;
const photo = document.getElementById('card-photo');
const animalIcon = svgIcon(isDog ? 'dog' : 'cat', 26, isDog ? '#388C59' : '#FB8C00');
photo.innerHTML = a.photo_url
? `<img src="${a.photo_url}" alt="photo" style="width:52px;height:52px;border-radius:50%;object-fit:cover;" onerror="this.parentNode.innerHTML=animalIcon">`
: animalIcon;
const badge = document.getElementById('card-badge');
badge.textContent = isDog ? 'Dog' : 'Cat';
badge.className = 'badge' + (urgent ? ' urgent' : (!isDog ? ' orange' : ''));
document.getElementById('card-time').textContent = a.days_since === 0 ? 'Today' : a.days_since === 1 ? 'Yesterday' : `${a.days_since}d ago`;
document.getElementById('card-location').textContent = a.desc || (isDog ? 'Dog spotted' : 'Cat spotted');
document.getElementById('card-sub').textContent = `${a.count} sighting${a.count!==1?'s':''} Β· last: ${a.last_seen}`;
document.getElementById('sighting-card').classList.remove('hidden');
}
function hideCard() {
document.getElementById('sighting-card').classList.add('hidden');
activeAnimal = null;
}
document.getElementById('card-btn').addEventListener('click', () => {
if (activeAnimal) openProfile(activeAnimal.id);
});
async function loadMapData() {
try {
const data = await fetch(`/api/map-data?species=${currentSpecies}&timeframe=${currentTimeframe}`).then(r => r.json());
renderMarkers(data);
} catch(e) { console.error(e); }
}
document.querySelectorAll('.chip[data-species]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.chip[data-species]').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); currentSpecies = btn.dataset.species; loadMapData();
});
});
document.querySelectorAll('.chip[data-timeframe]').forEach(btn => {
btn.addEventListener('click', () => {
const was = btn.classList.contains('active');
document.querySelectorAll('.chip[data-timeframe]').forEach(b => b.classList.remove('active'));
currentTimeframe = was ? 'all' : btn.dataset.timeframe;
if (!was) btn.classList.add('active');
loadMapData();
});
});
// ── SIGHTINGS LIST ─────────────────────────────────────────────────────────
let animalsCache = null;
async function loadAnimals(force = false) {
if (animalsCache && !force) { renderAnimalList(animalsCache); return; }
document.getElementById('animals-list').innerHTML = `<div style="padding:40px 16px;text-align:center;color:#aaa;font-size:14px;">Loading...</div>`;
try {
const data = await fetch('/api/animals').then(r => r.json());
animalsCache = data;
renderAnimalList(data);
} catch(e) {
document.getElementById('animals-list').innerHTML = `<div style="padding:40px 16px;text-align:center;color:#E53935;font-size:13px;">Failed to load. Try again.</div>`;
}
}
function renderAnimalList(animals) {
const list = document.getElementById('animals-list');
const count = document.getElementById('sightings-count');
count.textContent = `${animals.length} animal${animals.length !== 1 ? 's' : ''} on record`;
if (!animals.length) {
list.innerHTML = `<div class="animals-empty"><div class="ph-icon"><i data-lucide="paw-print"></i></div><p>No animals on record yet.<br>Go to the Report tab to get started!</p></div>`;
return;
}
list.innerHTML = animals.map(a => {
const isDog = a.species === 'dog';
const urgent = (a.days_since || 0) > 30;
const em = svgIcon(isDog ? 'dog' : 'cat', 28, isDog ? '#388C59' : '#FB8C00');
const name = isDog ? 'Dog' : 'Cat';
let desc = '';
try { const d = JSON.parse(a.description || '{}'); desc = [d.breed_estimate, d.primary_color].filter(Boolean).join(' Β· '); } catch(e){}
const photoHtml = a.photo_url
? `<img src="${a.photo_url}" alt="photo" style="width:58px;height:58px;object-fit:cover;border-radius:12px;" onerror="this.parentNode.innerHTML=\`${em}\`">`
: em;
return `
<div class="animal-item" data-id="${a.id}">
<div class="animal-item-photo">${photoHtml}</div>
<div class="animal-item-info">
<div class="animal-item-name">${name} #${a.id}</div>
<div class="animal-item-breed">${desc || 'Unknown breed'}</div>
<div class="animal-item-meta${urgent?' urgent':''}">
${urgent ? svgIcon('triangle-alert',13,'#E53935')+' Not seen for '+a.days_since+'d' : 'Seen '+formatDays(a.days_since)+' Β· '+a.sighting_count+'x spotted'}
</div>
</div>
<div class="animal-item-badge${urgent?' urgent':''}">
${a.sighting_count}x
</div>
</div>`;
}).join('');
if (typeof lucide !== 'undefined') lucide.createIcons();
list.querySelectorAll('.animal-item').forEach(el => {
el.addEventListener('click', () => openProfile(parseInt(el.dataset.id)));
});
}
function formatDays(d) {
if (!d || d === 0) return 'today';
if (d === 1) return 'yesterday';
return `${d} days ago`;
}
document.getElementById('refresh-btn').addEventListener('click', () => loadAnimals(true));
// ── PROFILE / FICHA ────────────────────────────────────────────────────────
let profileAnimalId = null;
async function openProfile(id) {
profileAnimalId = id;
window.profileAnimalId = id;
showScreen('profile');
// Reset
const heroImgEl = document.getElementById('profile-hero-img');
heroImgEl.src = '';
heroImgEl.style.display = 'block';
document.getElementById('profile-title').textContent = 'Loading...';
document.getElementById('profile-status-text').textContent = '';
document.getElementById('profile-badge-text').textContent = '...';
document.getElementById('profile-gallery').innerHTML = '';
document.getElementById('prof-species').textContent = 'β€”';
document.getElementById('prof-breed').textContent = 'β€”';
document.getElementById('prof-size').textContent = 'β€”';
document.getElementById('prof-color').innerHTML = 'β€”';
if (trajectoryMap) { trajectoryMap.remove(); trajectoryMap = null; }
try {
const data = await fetch(`/api/animal/${id}`).then(r => r.json());
const animal = data.animal;
const sightings = data.sightings || [];
const helpEvents = data.help_events || [];
let desc = {};
try { desc = JSON.parse(animal.description || '{}'); } catch(e){}
const isDog = animal.species === 'dog';
const breed = desc.breed_estimate || 'Unknown';
const color = desc.primary_color || 'β€”';
const size = desc.size || 'β€”';
const count = animal.sighting_count || sightings.length;
const daysSince = animal.days_since || 0;
// Hero image
const heroPhoto = sightings.find(s => s.photo_url)?.photo_url || '';
if (heroPhoto) {
const img = document.getElementById('profile-hero-img');
img.src = heroPhoto;
img.onerror = () => { img.style.display = 'none'; };
}
// Badge
document.getElementById('profile-badge-text').textContent = `${count} sighting${count !== 1 ? 's' : ''}`;
// Title & status β€” prefer user-given name
const colorCap = color.charAt(0).toUpperCase() + color.slice(1);
const autoName = `${isDog ? 'Dog' : 'Cat'} ${colorCap}`.trim();
const name = (animal.name && animal.name !== 'null') ? animal.name : autoName;
document.getElementById('profile-title').textContent = name;
const statusText = daysSince === 0 ? 'Seen today' : daysSince === 1 ? 'Seen yesterday' : `Seen ${daysSince} days ago`;
document.getElementById('profile-status-text').textContent = statusText;
// Identification
document.getElementById('prof-species').innerHTML = `${svgIcon(isDog?'dog':'cat',16)} ${isDog ? 'Dog' : 'Cat'}`;
document.getElementById('prof-breed').textContent = breed;
document.getElementById('prof-size').textContent = mapSizeLabel(size);
const colorDot = colorToHex(color);
document.getElementById('prof-color').innerHTML = colorDot
? `<span class="color-dot" style="background:${colorDot}"></span>${colorCap}`
: colorCap;
// AI description callout
const aiDescEl = document.getElementById('profile-ai-desc');
const aiTextEl = document.getElementById('profile-ai-text');
const aiText = desc.description_text || '';
const condition = desc.condition || '';
const marks = (desc.distinctive_marks || []).filter(Boolean);
if (aiText) {
let aiContent = aiText.charAt(0).toUpperCase() + aiText.slice(1);
if (condition) aiContent += ` Β· Condition: ${condition}`;
if (marks.length) aiContent += ` Β· Marks: ${marks.join(', ')}`;
aiTextEl.textContent = aiContent;
aiDescEl.style.display = 'flex';
} else {
aiDescEl.style.display = 'none';
}
// Helped banner
const helpedBanner = document.getElementById('profile-helped-banner');
helpedBanner.style.display = helpEvents.length ? 'flex' : 'none';
// Gallery
const gallery = document.getElementById('profile-gallery');
const photosWithUrl = sightings.filter(s => s.photo_url);
if (photosWithUrl.length) {
gallery.innerHTML = photosWithUrl.map(s => {
const dt = s.created_at ? new Date(s.created_at) : null;
const dateStr = dt ? dt.toLocaleDateString('en-US', {day:'2-digit', month:'2-digit'}) : '';
const timeStr = dt ? dt.toLocaleTimeString('en-US', {hour:'2-digit', minute:'2-digit'}) : '';
const locStr = (s.latitude && s.longitude) ? `${s.latitude.toFixed(3)}, ${s.longitude.toFixed(3)}` : 'Location not recorded';
return `
<div class="gallery-card">
<div class="gallery-card-img-wrap">
<img class="gallery-card-img" src="${s.photo_url}" alt="sighting"
onerror="this.style.background='#eee';this.src=''"/>
<button class="gallery-card-menu-btn" data-url="${s.photo_url}" data-date="${dateStr}" aria-label="Options">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="5" r="1.2" fill="currentColor" stroke="none"/>
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none"/>
<circle cx="12" cy="19" r="1.2" fill="currentColor" stroke="none"/>
</svg>
</button>
</div>
<div class="gallery-card-info">
<div class="gallery-card-date">
<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
${dateStr}${timeStr ? ', '+timeStr : ''}
</div>
<div class="gallery-card-loc">${locStr}</div>
</div>
</div>`;
}).join('');
if (typeof lucide !== 'undefined') lucide.createIcons();
} else {
gallery.innerHTML = `<div style="font-size:13px;color:#aaa;padding:8px 0;">No photos recorded.</div>`;
}
// Community Help section
const helpSection = document.getElementById('profile-help-section');
const helpList = document.getElementById('profile-help-list');
const helpTypeLabels = {
fed: '<i data-lucide="utensils"></i> Fed',
vet: '<i data-lucide="stethoscope"></i> Took to vet',
adopted: '<i data-lucide="home"></i> Adopted',
rescued: '<i data-lucide="shield-check"></i> Rescued',
other: '<i data-lucide="heart"></i> Helped',
};
if (helpEvents.length) {
helpSection.style.display = '';
helpList.innerHTML = helpEvents.map(h => {
const dt = h.created_at ? new Date(h.created_at) : null;
const dateStr = dt ? dt.toLocaleDateString('en-US', {day:'2-digit', month:'short', year:'numeric'}) : '';
const label = helpTypeLabels[h.help_type] || '<i data-lucide="heart"></i> Helped';
return '<div class="help-event-card">'
+ (h.photo_url ? '<img class="help-event-photo" src="' + h.photo_url + '" alt="help proof" onerror="this.style.display=\'none\'"/>' : '')
+ '<div class="help-event-body">'
+ '<div class="help-event-label">' + label + '</div>'
+ (h.notes ? '<div class="help-event-notes">' + h.notes + '</div>' : '')
+ (dateStr ? '<div class="help-event-date">' + dateStr + '</div>' : '')
+ '</div></div>';
}).join('');
if (typeof lucide !== 'undefined') lucide.createIcons();
} else {
helpSection.style.display = 'none';
}
// Trajectory mini-map
const pts = sightings.filter(s => s.latitude && s.longitude);
initTrajectoryMap(pts);
if (typeof lucide !== 'undefined') lucide.createIcons();
} catch(e) {
console.error(e);
document.getElementById('profile-title').textContent = 'Failed to load';
}
}
function initTrajectoryMap(points) {
const el = document.getElementById('trajectory-map');
if (trajectoryMap) { trajectoryMap.remove(); trajectoryMap = null; }
if (!points.length) {
el.innerHTML = `<div style="height:180px;display:flex;align-items:center;justify-content:center;font-size:13px;color:#aaa;border-radius:12px;background:#f5f5f5;">No locations recorded</div>`;
return;
}
el.innerHTML = '';
el.style.height = '180px';
const center = [points[0].latitude, points[0].longitude];
trajectoryMap = L.map(el, { zoomControl: false, attributionControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, touchZoom: false }).setView(center, 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(trajectoryMap);
const latlngs = points.map(p => [p.latitude, p.longitude]);
if (latlngs.length > 1) {
L.polyline(latlngs, { color: '#388C59', weight: 2.5, opacity: .7, dashArray: '6,4' }).addTo(trajectoryMap);
}
points.forEach((p, i) => {
const isLast = i === 0;
const icon = L.divIcon({
html: `<div style="width:${isLast?16:10}px;height:${isLast?16:10}px;border-radius:50%;background:${isLast?'#388C59':'#88c4a4'};border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.2);"></div>`,
className:'', iconSize:[isLast?16:10, isLast?16:10], iconAnchor:[isLast?8:5, isLast?8:5]
});
L.marker([p.latitude, p.longitude], { icon }).addTo(trajectoryMap);
});
if (latlngs.length > 1) {
trajectoryMap.fitBounds(latlngs, { padding: [20, 20] });
}
setTimeout(() => trajectoryMap && trajectoryMap.invalidateSize(), 100);
}
function mapSizeLabel(s) {
if (!s) return 'β€”';
const l = s.toLowerCase();
if (l.includes('small') || l.includes('pequen')) return '↕ Small';
if (l.includes('large') || l.includes('grand')) return '↕ Large';
if (l.includes('mΓ©dio') || l.includes('medio') || l.includes('medium')) return '↕ Medium';
return '↕ ' + s;
}
function colorToHex(color) {
const map = { caramelo:'#D2691E', preto:'#222', branco:'#f0f0f0', cinza:'#999', marrom:'#8B4513', amarelo:'#F5C542', mesclado:'linear-gradient(135deg,#888 50%,#D2691E 50%)' };
return map[(color||'').toLowerCase()] || null;
}
document.getElementById('profile-back-btn').addEventListener('click', () => {
if (trajectoryMap) { trajectoryMap.remove(); trajectoryMap = null; }
showScreen('sightings');
});
// ── REGISTER ───────────────────────────────────────────────────────────────
document.getElementById('shutter-btn').addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', e => {
const file = e.target.files[0]; if (!file) return;
selectedFile = file;
const url = URL.createObjectURL(file);
document.getElementById('photo-preview').src = url;
document.getElementById('photo-preview').style.display = 'block';
document.getElementById('camera-placeholder').style.display = 'none';
updateSubmitBtn();
});
document.getElementById('gps-pill').addEventListener('click', requestGPS);
document.getElementById('gps-btn').addEventListener('click', requestGPS);
function requestGPS() {
if (!navigator.geolocation) { document.getElementById('gps-text').textContent = 'GPS not available'; return; }
const txt = document.getElementById('gps-text');
const btn = document.getElementById('gps-btn');
txt.textContent = 'Detecting...'; txt.classList.remove('located'); btn.disabled = true;
navigator.geolocation.getCurrentPosition(
pos => {
gpsCoords = { lat: pos.coords.latitude, lng: pos.coords.longitude };
txt.textContent = `${gpsCoords.lat.toFixed(4)}, ${gpsCoords.lng.toFixed(4)}`; txt.classList.add('located');
btn.innerHTML = `<svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg> Location Added`;
btn.classList.add('located'); btn.disabled = false; updateSubmitBtn();
},
err => {
const msgs = {1:'Permission denied',2:'Location unavailable',3:'GPS timeout'};
txt.textContent = msgs[err.code] || 'GPS error'; btn.disabled = false;
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
);
}
function updateSubmitBtn() { document.getElementById('submit-reg-btn').disabled = !selectedFile; }
document.getElementById('submit-reg-btn').addEventListener('click', async () => {
if (!selectedFile) return;
showScreen('analysis');
await runAnalysis();
});
// ── GRADIO CLIENT ──────────────────────────────────────────────────────────
let _client = null, _handleFile = null;
async function getClient() {
if (!_client) {
const mod = await import('https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js');
_handleFile = mod.handle_file;
_client = await mod.Client.connect(window.location.origin);
}
return { client: _client, handleFile: _handleFile };
}
// ── ANALYSIS ───────────────────────────────────────────────────────────────
async function runAnalysis() {
document.getElementById('analysis-photo').src = URL.createObjectURL(selectedFile);
const aiBadge = document.getElementById('ai-badge');
aiBadge.className = ''; document.getElementById('ai-badge-text').textContent = 'AI Analyzing...';
document.getElementById('animal-result-badge').classList.remove('visible');
const confirmBtn = document.getElementById('confirm-btn');
confirmBtn.disabled = true; confirmBtn.innerHTML = '<div class="spinner"></div> Analyzing...';
try {
const { client, handleFile } = await getClient();
const res = await client.predict('/analyze_image', { image_path: handleFile(selectedFile) });
const data = res.data[0];
if (data.error) {
aiBadge.className = 'error';
document.getElementById('ai-badge-text').textContent = 'No animal found';
document.getElementById('result-badge-text').textContent = data.error;
document.getElementById('animal-result-badge').classList.add('visible', 'error');
confirmBtn.style.display = 'none';
document.getElementById('discard-btn').textContent = '← Take another photo';
return;
}
sessionId = data.session_id;
const desc = data.description || {};
setSelectVal('sel-species', desc.species === 'cat' ? 'cat' : 'dog');
setSelectVal('sel-breed', desc.breed_estimate || 'SRD');
setSelectVal('sel-color', desc.primary_color || 'Caramelo');
setSelectVal('sel-size', mapSizeOpt(desc.size));
aiBadge.className = 'done'; document.getElementById('ai-badge-text').textContent = 'AI Done';
const sp = desc.species === 'cat' ? 'Cat' : 'Dog';
const co = (desc.primary_color || '').charAt(0).toUpperCase() + (desc.primary_color || '').slice(1);
document.getElementById('result-badge-text').textContent = `${sp} ${co}`.trim();
document.getElementById('animal-result-badge').classList.add('visible');
renderSimilar(data.similar || []);
confirmBtn.disabled = false; confirmBtn.innerHTML = 'Confirm & Report β†’';
} catch(err) {
console.error(err);
document.getElementById('ai-badge-text').textContent = 'Analysis error';
confirmBtn.disabled = false; confirmBtn.innerHTML = 'Confirm without AI β†’';
}
}
// ── Breed lists ───────────────────────────────────────────────────────────
const DOG_BREEDS = [
'SRD', 'Labrador Retriever', 'Golden Retriever', 'Pitbull', 'Poodle',
'Shih Tzu', 'Rottweiler', 'German Shepherd', 'Bulldog', 'Dachshund',
'Chihuahua', 'Siberian Husky', 'Border Collie', 'Beagle', 'Boxer',
'Maltese', 'Chow Chow', 'Akita', 'Dalmatian', 'Doberman', 'Other',
];
const CAT_BREEDS = [
'Domestic Shorthair', 'Domestic Longhair', 'Siamese', 'Persian',
'Maine Coon', 'Bengal', 'British Shorthair', 'Ragdoll', 'Scottish Fold',
'Turkish Angora', 'Sphynx', 'Abyssinian', 'Other',
];
function populateBreeds(species) {
const sel = document.getElementById('sel-breed');
if (!sel) return;
const list = species === 'cat' ? CAT_BREEDS : DOG_BREEDS;
const current = sel.value;
sel.innerHTML = list.map(b => `<option value="${b}">${b}</option>`).join('');
// Keep current selection if it still exists
if (list.includes(current)) sel.value = current;
}
// Repopulate breeds when species changes
document.getElementById('sel-species').addEventListener('change', e => {
populateBreeds(e.target.value);
});
// Init with dogs
populateBreeds('dog');
function setSelectVal(id, val) {
const sel = document.getElementById(id); if (!sel) return;
// If setting species, repopulate breeds first
if (id === 'sel-species') populateBreeds(val);
for (const opt of sel.options) { if (opt.value.toLowerCase() === (val||'').toLowerCase()) { sel.value = opt.value; return; } }
// If breed not found in list, select "Other"
if (id === 'sel-breed') { sel.value = 'Other'; }
}
function mapSizeOpt(s) { if (!s) return 'MΓ©dio'; const l = s.toLowerCase(); if (l.includes('small')||l.includes('pequen')) return 'Pequeno'; if (l.includes('large')||l.includes('grand')) return 'Grande'; return 'MΓ©dio'; }
function renderSimilar(similar) {
const section = document.getElementById('similar-section');
const scroll = document.getElementById('similar-scroll');
if (!similar.length) { section.style.display = 'none'; return; }
section.style.display = '';
scroll.innerHTML = similar.map(m => `
<div class="similar-card">
<div style="position:relative;height:120px;display:flex;align-items:center;justify-content:center;font-size:36px;background:#f5f5f5;">
${m.photo_url ? `<img src="${m.photo_url}" style="width:100%;height:120px;object-fit:cover;" onerror="this.style.display='none'">` : svgIcon('paw-print',36,'#ccc')}
<span class="match-pct">${m.score_pct}%</span>
</div>
<div class="similar-card-info">
<div class="days">${m.days_ago ? m.days_ago+' days ago' : 'Registered'}</div>
<div class="dist">Animal #${m.id}</div>
</div>
</div>`).join('');
}
document.querySelectorAll('.cond-chip').forEach(b => b.addEventListener('click', () => b.classList.toggle('active')));
document.getElementById('analysis-back').addEventListener('click', () => showScreen('register'));
document.getElementById('discard-btn').addEventListener('click', () => {
const confirmBtn = document.getElementById('confirm-btn');
confirmBtn.style.display = '';
confirmBtn.disabled = false;
confirmBtn.innerHTML = 'Confirm & Report β†’';
document.getElementById('discard-btn').textContent = 'Discard Photo';
document.getElementById('animal-result-badge').classList.remove('visible','error');
resetRegister(); showScreen('register');
});
// ── CONFIRM ────────────────────────────────────────────────────────────────
document.getElementById('confirm-btn').addEventListener('click', async () => {
const btn = document.getElementById('confirm-btn');
btn.disabled = true; btn.innerHTML = '<div class="spinner"></div> Saving...';
const capturedSpecies = document.getElementById('sel-species').value;
const capturedBreed = document.getElementById('sel-breed').value;
const capturedColor = document.getElementById('sel-color').value;
const capturedSize = document.getElementById('sel-size').value;
const capturedCond = [...document.querySelectorAll('.cond-chip.active')].map(c => c.dataset.val).join(', ');
try {
const { client } = await getClient();
const gpsJson = gpsCoords ? JSON.stringify(gpsCoords) : '';
const notes = document.getElementById('notes-input').value.trim();
const animalName = (document.getElementById('animal-name-input').value || '').trim();
const res = await client.predict('/confirm_sighting', { session_id: sessionId||'', gps_json: gpsJson, notes, condition: capturedCond, animal_name: animalName });
const data = res.data[0];
if (data.error) { alert(data.error); btn.disabled=false; btn.innerHTML='Confirm & Report β†’'; return; }
document.getElementById('confirm-animal').textContent = data.name || 'β€”';
document.getElementById('confirm-local').textContent = data.location || 'β€”';
document.getElementById('confirm-hora').textContent = data.time || 'β€”';
const photoWrap = document.getElementById('confirm-photo');
const specIcon = svgIcon(data.species === 'dog' ? 'dog' : 'cat', 72, data.species === 'dog' ? '#388C59' : '#FB8C00');
photoWrap.innerHTML = data.photo_url
? `<img src="${data.photo_url}" style="width:160px;height:160px;object-fit:cover;border-radius:50%;" onerror="this.parentNode.innerHTML=\`${specIcon}\`">`
: specIcon;
const specLabel = capturedSpecies === 'dog' ? 'Dog' : 'Cat';
const sizeLabels = { Pequeno:'↕ Small', MΓ©dio:'↕ Medium', Grande:'↕ Large' };
const colorDot = colorToHex(capturedColor);
const colorSwatch = colorDot ? `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${colorDot};margin-right:4px;vertical-align:middle;border:1px solid rgba(0,0,0,.15)"></span>` : '';
document.getElementById('confirm-id-grid').innerHTML = `
<div class="cig-label">AI Identification</div>
<div class="cig-cells">
<div class="cig-cell"><div class="cig-key">Species</div><div class="cig-val">${svgIcon(capturedSpecies==='dog'?'dog':'cat',13)} ${specLabel}</div></div>
<div class="cig-cell"><div class="cig-key">Breed</div><div class="cig-val">${capturedBreed}</div></div>
<div class="cig-cell"><div class="cig-key">Size</div><div class="cig-val">${sizeLabels[capturedSize]||capturedSize}</div></div>
<div class="cig-cell"><div class="cig-key">Color</div><div class="cig-val">${colorSwatch}${capturedColor}</div></div>
${capturedCond ? `<div class="cig-cell cig-full"><div class="cig-key">Condition</div><div class="cig-val">${capturedCond}</div></div>` : ''}
</div>`;
animalsCache = null;
showScreen('confirm'); loadMapData();
} catch(err) {
console.error(err); alert('Error saving. Please try again.');
btn.disabled=false; btn.innerHTML='Confirm & Report β†’';
}
});
document.getElementById('register-another-btn').addEventListener('click', () => { resetRegister(); showScreen('register'); });
document.getElementById('go-map-btn').addEventListener('click', () => showScreen('map'));
function resetRegister() {
selectedFile = null; gpsCoords = null; sessionId = null;
document.getElementById('photo-preview').style.display = 'none';
document.getElementById('photo-preview').src = '';
document.getElementById('camera-placeholder').style.display = 'flex';
document.getElementById('photo-input').value = '';
document.getElementById('notes-input').value = '';
document.getElementById('animal-name-input').value = '';
document.getElementById('gps-text').textContent = 'Tap to detect location';
document.getElementById('gps-text').classList.remove('located');
const btn = document.getElementById('gps-btn');
btn.className = 'btn-secondary';
btn.innerHTML = `<svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg> Add Location`;
btn.disabled = false;
document.querySelectorAll('.cond-chip').forEach(c => c.classList.remove('active'));
updateSubmitBtn();
}
// ── PHOTO CONTEXT MENU ────────────────────────────────────────────────────
let photoMenuUrl = null;
const photoMenu = document.getElementById('photo-menu');
function openPhotoMenu(btn) {
photoMenuUrl = btn.dataset.url;
const rect = btn.getBoundingClientRect();
const appRect = document.getElementById('app').getBoundingClientRect();
photoMenu.classList.remove('hidden');
// Position below the button, clamped inside app
let top = rect.bottom - appRect.top + 4;
let left = rect.right - appRect.left - photoMenu.offsetWidth;
if (left < 8) left = 8;
photoMenu.style.top = top + 'px';
photoMenu.style.left = left + 'px';
}
function closePhotoMenu() { photoMenu.classList.add('hidden'); photoMenuUrl = null; }
document.getElementById('app').addEventListener('click', e => {
const btn = e.target.closest('.gallery-card-menu-btn');
if (btn) { e.stopPropagation(); openPhotoMenu(btn); return; }
if (!photoMenu.contains(e.target)) closePhotoMenu();
});
document.getElementById('photo-menu-download').addEventListener('click', () => {
if (!photoMenuUrl) return;
const a = document.createElement('a');
a.href = photoMenuUrl;
a.download = 'pawmap-photo.jpg';
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
closePhotoMenu();
});
// ── HELP SHEET ────────────────────────────────────────────────────────────
let helpAnimal = null;
function openHelpSheet(animalData) {
helpAnimal = animalData;
const isDog = animalData.species === 'dog';
// Populate header
const photoEl = document.getElementById('help-animal-photo');
const heroPhoto = (animalData.sightings || []).find(s => s.photo_url)?.photo_url || animalData.photo_url || '';
photoEl.innerHTML = heroPhoto
? `<img src="${heroPhoto}" onerror="this.style.display='none'">`
: `<span style="font-size:2rem;opacity:.4">${isDog ? 'Dog' : 'Cat'}</span>`;
let desc = {};
try { desc = JSON.parse(animalData.description || '{}'); } catch(e) {}
const color = (desc.primary_color || '').charAt(0).toUpperCase() + (desc.primary_color || '').slice(1);
document.getElementById('help-animal-name').textContent = `${isDog ? 'Dog' : 'Cat'} ${color}`.trim() || `Animal #${animalData.id}`;
document.getElementById('help-animal-sub').textContent = `${animalData.sighting_count || 1} sighting${(animalData.sighting_count||1)!==1?'s':''} Β· last seen ${animalData.last_seen || 'recently'}`;
document.getElementById('help-overlay').classList.remove('hidden');
document.getElementById('help-sheet').classList.remove('hidden');
requestAnimationFrame(() => document.getElementById('help-sheet').classList.add('open'));
}
function closeHelpSheet() {
const sheet = document.getElementById('help-sheet');
sheet.classList.remove('open');
setTimeout(() => {
sheet.classList.add('hidden');
document.getElementById('help-overlay').classList.add('hidden');
}, 300);
}
document.getElementById('btn-adopt').addEventListener('click', () => {
// Pass current profile animal data
fetch(`/api/animal/${profileAnimalId}`).then(r => r.json()).then(data => {
openHelpSheet({ ...data.animal, sightings: data.sightings });
});
});
document.getElementById('help-cancel').addEventListener('click', closeHelpSheet);
document.getElementById('help-overlay').addEventListener('click', closeHelpSheet);
// Share
document.getElementById('help-share').addEventListener('click', () => {
const url = `${window.location.origin}/#animal/${profileAnimalId}`;
const text = `Help this stray animal find a home! Track their location on PawMap: ${url}`;
if (navigator.share) {
navigator.share({ title: 'PawMap', text, url }).catch(() => {});
} else {
const wa = `https://wa.me/?text=${encodeURIComponent(text)}`;
window.open(wa, '_blank');
}
closeHelpSheet();
});
// Directions
document.getElementById('help-directions').addEventListener('click', () => {
if (!helpAnimal) return;
const sightings = helpAnimal.sightings || [];
const last = sightings.find(s => s.latitude && s.longitude);
if (last) {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${last.latitude},${last.longitude}`, '_blank');
} else {
alert('No location recorded for this animal yet.');
}
closeHelpSheet();
});
// I helped β†’ nova tela
document.getElementById('help-helped').addEventListener('click', () => {
closeHelpSheet();
window.openHelpProofScreen();
});
document.getElementById('helped-ok').addEventListener('click', () => {
const confirm = document.getElementById('helped-confirm');
confirm.classList.remove('open');
setTimeout(() => confirm.classList.add('hidden'), 300);
});
// ── Photo lightbox ────────────────────────────────────────────────────────
const lightbox = document.getElementById('photo-lightbox');
const lightboxImg = document.getElementById('lightbox-img');
function openLightbox(src) {
lightboxImg.src = src;
lightbox.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
lightbox.classList.add('hidden');
lightboxImg.src = '';
document.body.style.overflow = '';
}
document.getElementById('lightbox-close').addEventListener('click', closeLightbox);
document.querySelector('.lightbox-backdrop').addEventListener('click', closeLightbox);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
document.getElementById('profile-help-list').addEventListener('click', e => {
const img = e.target.closest('.help-event-photo');
if (img) openLightbox(img.src);
});
// Expor para help-proof.js
window.showScreen = showScreen;
window.openProfile = openProfile;
window.getGradioClient = getClient;
// ── BOOT ──────────────────────────────────────────────────────────────────
initMap();
loadMapData();
if (typeof lucide !== 'undefined') lucide.createIcons();
})();