Spaces:
Running
Running
| (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; | |
| // HistΓ³ria redigida pelo Nemotron 3 Nano β exibida no card de descriΓ§Γ£o abaixo. | |
| const story = (data.story || '').trim(); | |
| // 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); | |
| const lang = window.PAWMAP_LANG || 'en'; | |
| const tr = window.PAWMAP_I18N; | |
| let aiContent = ''; | |
| if (lang === 'pt' && tr) { | |
| // The AI prompt is always English. Rather than machine-translating the | |
| // free-text sentence, we rebuild a Portuguese description from the | |
| // structured fields the AI returned (species/size/color/marks). | |
| const sp = tr.species(isDog ? 'dog' : 'cat'); | |
| const sz = tr.size(size); | |
| const co = tr.color(color); | |
| let parts = sp; | |
| if (sz && sz !== 'β') parts += ` de porte ${sz.toLowerCase()}`; | |
| if (co && co !== 'β') parts += `, ${co.toLowerCase()}`; | |
| aiContent = parts; | |
| if (condition) aiContent += ` Β· Estado: ${tr.condition(condition)}`; | |
| if (marks.length) aiContent += ` Β· Marcas: ${marks.map(tr.mark).join(', ')}`; | |
| aiContent = aiContent.charAt(0).toUpperCase() + aiContent.slice(1); | |
| } else if (aiText) { | |
| aiContent = aiText.charAt(0).toUpperCase() + aiText.slice(1); | |
| if (condition) aiContent += ` Β· Condition: ${condition}`; | |
| if (marks.length) aiContent += ` Β· Marks: ${marks.join(', ')}`; | |
| } | |
| // Preferir a histΓ³ria escrita pelo Nemotron (NVIDIA). Sem ela, cai no resumo. | |
| const calloutText = story || aiContent; | |
| const labelEl = aiDescEl.querySelector('.ai-label'); | |
| if (labelEl) labelEl.textContent = story ? 'NVIDIA Nemotron' : 'AI'; | |
| if (calloutText) { | |
| aiTextEl.textContent = calloutText; | |
| 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 = { | |
| // English (novos valores dos dropdowns) | |
| black:'#222', white:'#f0f0f0', gray:'#999', brown:'#8B4513', chocolate:'#5C4033', | |
| caramel:'#D2691E', tan:'#D2B48C', golden:'#E8A317', cream:'#F5EBDC', fawn:'#E5AA70', | |
| orange:'#F28C28', ginger:'#E67E22', red:'#A52A2A', sable:'#6B4423', cinnamon:'#7B3F00', | |
| blue:'#6E7B8B', lilac:'#C8A2C8', tabby:'#8B7355', tuxedo:'#222', calico:'#D9A066', | |
| tortoiseshell:'#5C4033', tricolor:'#8B5A2B', bicolor:'#888', brindle:'#6B4423', | |
| merle:'#9DB2BF', spotted:'#bbb', mixed:'linear-gradient(135deg,#888 50%,#D2691E 50%)', | |
| // PortuguΓͺs (compatibilidade com seed.py e dados antigos) | |
| caramelo:'#D2691E', preto:'#222', branco:'#f0f0f0', cinza:'#999', marrom:'#8B4513', | |
| amarelo:'#F5C542', laranja:'#F28C28', dourado:'#E8A317', creme:'#F5EBDC', | |
| 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'); | |
| }); | |
| // ββ IMAGE SOURCE CHOOSER (camera vs gallery) ββββββββββββββββββββββββββββββββ | |
| // Shared bottom sheet so both the main and help flows can ask the user whether | |
| // to open the camera or pick from the gallery, instead of jumping straight in. | |
| let sourceTargetInput = null; | |
| const sourceOverlay = document.getElementById('source-overlay'); | |
| const sourceSheet = document.getElementById('source-sheet'); | |
| function openSourceSheet() { | |
| sourceOverlay.classList.remove('hidden'); | |
| sourceSheet.classList.remove('hidden'); | |
| void sourceSheet.offsetWidth; // reflow so the slide-in transition plays | |
| sourceSheet.classList.add('open'); | |
| } | |
| function closeSourceSheet() { | |
| sourceSheet.classList.remove('open'); | |
| sourceOverlay.classList.add('hidden'); | |
| setTimeout(() => sourceSheet.classList.add('hidden'), 300); | |
| } | |
| function triggerInput(useCamera) { | |
| const input = sourceTargetInput; | |
| closeSourceSheet(); | |
| if (!input) return; | |
| if (useCamera) input.setAttribute('capture', 'environment'); | |
| else input.removeAttribute('capture'); | |
| input.click(); | |
| } | |
| window.pickImageSource = function (inputEl) { | |
| sourceTargetInput = inputEl; | |
| openSourceSheet(); | |
| }; | |
| document.getElementById('source-camera').addEventListener('click', () => triggerInput(true)); | |
| document.getElementById('source-gallery').addEventListener('click', () => triggerInput(false)); | |
| document.getElementById('source-cancel').addEventListener('click', closeSourceSheet); | |
| sourceOverlay.addEventListener('click', closeSourceSheet); | |
| // ββ REGISTER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('shutter-btn').addEventListener('click', () => window.pickImageSource(photoInput)); | |
| 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(); | |
| extractPhotoGPS(file); | |
| }); | |
| // Central place to record a chosen location and reflect it in the UI. | |
| function setLocation(coords, sourceLabel) { | |
| gpsCoords = { lat: coords.lat, lng: coords.lng }; | |
| const txt = document.getElementById('gps-text'); | |
| txt.textContent = `${gpsCoords.lat.toFixed(4)}, ${gpsCoords.lng.toFixed(4)}${sourceLabel ? ' Β· ' + sourceLabel : ''}`; | |
| txt.classList.add('located'); | |
| document.getElementById('gps-btn').classList.add('located'); | |
| updateSubmitBtn(); | |
| } | |
| // Try to read GPS straight from the photo's EXIF metadata. Most photos shared | |
| // via messaging apps have it stripped, so we always offer the manual options | |
| // (current location / pick on map) regardless of whether EXIF was found. | |
| async function extractPhotoGPS(file) { | |
| const txt = document.getElementById('gps-text'); | |
| document.getElementById('locate-options').style.display = 'flex'; | |
| document.getElementById('gps-btn').classList.remove('located'); | |
| txt.textContent = 'Reading photo locationβ¦'; txt.classList.remove('located'); | |
| try { | |
| const gps = (typeof exifr !== 'undefined') ? await exifr.gps(file) : null; | |
| if (gps && Number.isFinite(gps.latitude) && Number.isFinite(gps.longitude)) { | |
| setLocation({ lat: gps.latitude, lng: gps.longitude }, 'from photo'); | |
| return; | |
| } | |
| } catch (err) { | |
| console.warn('EXIF GPS read failed:', err); | |
| } | |
| // No usable metadata β let the user set it manually. | |
| gpsCoords = null; | |
| txt.textContent = 'Sem localizaΓ§Γ£o na foto β defina abaixo'; | |
| txt.classList.remove('located'); | |
| } | |
| document.getElementById('gps-btn').addEventListener('click', requestGPS); | |
| document.getElementById('map-btn').addEventListener('click', () => openMapPicker(gpsCoords)); | |
| 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 => { | |
| setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }, 'localizaΓ§Γ£o atual'); | |
| btn.disabled = false; | |
| }, | |
| err => { | |
| const msgs = {1:'PermissΓ£o negada',2:'LocalizaΓ§Γ£o indisponΓvel',3:'Tempo esgotado'}; | |
| txt.textContent = msgs[err.code] || 'Erro de GPS'; btn.disabled = false; | |
| }, | |
| { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 } | |
| ); | |
| } | |
| // ββ MAP LOCATION PICKER βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Uber-style: a fixed center pin while the user pans the map underneath. | |
| let pickerMap = null, pickerSearchTimer = null; | |
| function openMapPicker(initial) { | |
| const modal = document.getElementById('map-picker'); | |
| modal.classList.remove('hidden'); | |
| const start = initial && Number.isFinite(initial.lat) | |
| ? [initial.lat, initial.lng] : [-15.7801, -47.9292]; // default: BrasΓlia | |
| if (!pickerMap) { | |
| pickerMap = L.map('map-picker-map', { zoomControl: true }).setView(start, initial ? 17 : 13); | |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| attribution: 'Β© <a href="https://openstreetmap.org">OSM</a>', maxZoom: 19 | |
| }).addTo(pickerMap); | |
| pickerMap.on('move', updatePickerCoords); | |
| } else { | |
| pickerMap.setView(start, initial ? 17 : 13); | |
| } | |
| setTimeout(() => { pickerMap.invalidateSize(); updatePickerCoords(); }, 80); | |
| } | |
| function closeMapPicker() { | |
| document.getElementById('map-picker').classList.add('hidden'); | |
| document.getElementById('map-picker-results').innerHTML = ''; | |
| document.getElementById('map-picker-input').value = ''; | |
| } | |
| function updatePickerCoords() { | |
| const c = pickerMap.getCenter(); | |
| document.getElementById('map-picker-coords').textContent = | |
| `${c.lat.toFixed(5)}, ${c.lng.toFixed(5)}`; | |
| } | |
| document.getElementById('map-picker-cancel').addEventListener('click', closeMapPicker); | |
| document.getElementById('map-picker-confirm').addEventListener('click', () => { | |
| const c = pickerMap.getCenter(); | |
| setLocation({ lat: c.lat, lng: c.lng }, 'marcado no mapa'); | |
| closeMapPicker(); | |
| }); | |
| document.getElementById('map-picker-locate').addEventListener('click', () => { | |
| if (!navigator.geolocation) return; | |
| navigator.geolocation.getCurrentPosition( | |
| pos => pickerMap.setView([pos.coords.latitude, pos.coords.longitude], 17), | |
| () => {}, { enableHighAccuracy: true, timeout: 15000 } | |
| ); | |
| }); | |
| // Address search via OpenStreetMap Nominatim (debounced). | |
| document.getElementById('map-picker-input').addEventListener('input', e => { | |
| const q = e.target.value.trim(); | |
| clearTimeout(pickerSearchTimer); | |
| const results = document.getElementById('map-picker-results'); | |
| if (q.length < 3) { results.innerHTML = ''; return; } | |
| pickerSearchTimer = setTimeout(async () => { | |
| try { | |
| const r = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=5&accept-language=pt-BR&q=${encodeURIComponent(q)}`, { headers: { 'Accept': 'application/json' } }); | |
| const list = await r.json(); | |
| results.innerHTML = list.map((p, i) => | |
| `<div class="map-result" data-lat="${p.lat}" data-lng="${p.lon}">${p.display_name}</div>`).join(''); | |
| } catch (err) { console.warn('geocode failed', err); } | |
| }, 400); | |
| }); | |
| document.getElementById('map-picker-results').addEventListener('click', e => { | |
| const row = e.target.closest('.map-result'); | |
| if (!row) return; | |
| pickerMap.setView([parseFloat(row.dataset.lat), parseFloat(row.dataset.lng)], 17); | |
| document.getElementById('map-picker-results').innerHTML = ''; | |
| document.getElementById('map-picker-input').value = ''; | |
| }); | |
| 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', mapColorOpt(desc.primary_color)); | |
| 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 = mapColorOpt(desc.primary_color); | |
| 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', 'German Shepherd', | |
| 'Bulldog', 'French Bulldog', 'Poodle', 'Beagle', 'Rottweiler', 'Pitbull', | |
| 'American Staffordshire Terrier', 'Dachshund', 'Yorkshire Terrier', 'Boxer', | |
| 'Chihuahua', 'Shih Tzu', 'Siberian Husky', 'Border Collie', 'Australian Shepherd', | |
| 'Cavalier King Charles Spaniel', 'Doberman', 'Great Dane', 'Maltese', 'Pug', | |
| 'Pomeranian', 'Bernese Mountain Dog', 'Cocker Spaniel', 'Shiba Inu', 'Akita', | |
| 'Chow Chow', 'Dalmatian', 'Bichon Frise', 'Mastiff', 'Cane Corso', 'Saint Bernard', | |
| 'Weimaraner', 'Basset Hound', 'Newfoundland', 'Bloodhound', 'Whippet', 'Greyhound', | |
| 'Vizsla', 'Samoyed', 'Jack Russell Terrier', 'Australian Cattle Dog', 'English Setter', | |
| 'Pekingese', 'Lhasa Apso', 'Schnauzer', 'Belgian Malinois', 'Rhodesian Ridgeback', | |
| 'Brazilian Terrier', 'Fila Brasileiro', 'Other', | |
| ]; | |
| const CAT_BREEDS = [ | |
| 'Domestic Shorthair', 'Domestic Longhair', 'Siamese', 'Persian', 'Maine Coon', | |
| 'Bengal', 'British Shorthair', 'Ragdoll', 'Scottish Fold', 'Turkish Angora', | |
| 'Sphynx', 'Abyssinian', 'Russian Blue', 'Norwegian Forest Cat', 'Birman', | |
| 'Oriental Shorthair', 'Devon Rex', 'Cornish Rex', 'American Shorthair', | |
| 'Exotic Shorthair', 'Burmese', 'Tonkinese', 'Himalayan', 'Manx', 'Savannah', | |
| 'Munchkin', 'Egyptian Mau', 'Somali', 'Balinese', 'Ocicat', 'Singapura', | |
| 'Chartreux', 'Selkirk Rex', 'Turkish Van', 'Bombay', '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'; } | |
| } | |
| // Mapeia o tamanho da IA (EN ou PT, texto livre) para uma opcao do dropdown. | |
| // Opcoes: Tiny, Small, Medium, Large, Extra Large, Giant. | |
| function mapSizeOpt(s) { | |
| if (!s) return 'Medium'; | |
| const l = s.toLowerCase(); | |
| if (l.includes('tiny') || l.includes('teacup') || l.includes('toy')) return 'Tiny'; | |
| if (l.includes('extra large') || l.includes('xl') || l.includes('x-large')) return 'Extra Large'; | |
| if (l.includes('giant')) return 'Giant'; | |
| if (l.includes('small') || l.includes('pequen')) return 'Small'; | |
| if (l.includes('large') || l.includes('grand')) return 'Large'; | |
| return 'Medium'; | |
| } | |
| // Mapeia a cor da IA (EN ou PT, texto livre) para uma opcao do dropdown. | |
| function mapColorOpt(c) { | |
| if (!c) return 'Mixed'; | |
| const l = c.toLowerCase(); | |
| if (l.includes('tuxedo')) return 'Tuxedo'; | |
| if (l.includes('calico')) return 'Calico'; | |
| if (l.includes('tortoise') || l.includes('tortie')) return 'Tortoiseshell'; | |
| if (l.includes('tricolor') || l.includes('tri-color') || l.includes('tricolour')) return 'Tricolor'; | |
| if (l.includes('bicolor') || l.includes('bi-color') || l.includes('two-tone')) return 'Bicolor'; | |
| if (l.includes('brindl')) return 'Brindle'; | |
| if (l.includes('merle')) return 'Merle'; | |
| if (l.includes('tabby')) return 'Tabby'; | |
| if (l.includes('spot') || l.includes('malhad') || l.includes('pintad')) return 'Spotted'; | |
| if (l.includes('choco')) return 'Chocolate'; | |
| if (l.includes('cinnamon') || l.includes('canela')) return 'Cinnamon'; | |
| if (l.includes('sable')) return 'Sable'; | |
| if (l.includes('lilac')) return 'Lilac'; | |
| if (l.includes('fawn')) return 'Fawn'; | |
| if (l.includes('ginger') || l.includes('ruiv')) return 'Ginger'; | |
| if (l.includes('orange') || l.includes('laranj')) return 'Orange'; | |
| if (l.includes('gold') || l.includes('dourad') || l.includes('amarel') || l.includes('yellow')) return 'Golden'; | |
| if (l.includes('cream') || l.includes('creme') || l.includes('beige') || l.includes('bege')) return 'Cream'; | |
| if (l.includes('caramel') || l.includes('caramelo')) return 'Caramel'; | |
| if (l.includes('tan')) return 'Tan'; | |
| if (l.includes('red') || l.includes('vermelh')) return 'Red'; | |
| if (l.includes('blue') || l.includes('azul')) return 'Blue'; | |
| if (l.includes('brown') || l.includes('marrom') || l.includes('brun')) return 'Brown'; | |
| if (l.includes('black') || l.includes('pret')) return 'Black'; | |
| if (l.includes('white') || l.includes('branc')) return 'White'; | |
| if (l.includes('gray') || l.includes('grey') || l.includes('cinz') || l.includes('silver') || l.includes('prat')) return 'Gray'; | |
| if (l.includes('mix') || l.includes('multi') || l.includes('mescl') || l.includes('patch')) return 'Mixed'; | |
| return 'Mixed'; | |
| } | |
| 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 = { | |
| Tiny:'β Tiny', Small:'β Small', Medium:'β Medium', Large:'β Large', | |
| 'Extra Large':'β Extra Large', Giant:'β Giant', | |
| 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 = 'Adicione uma foto para definir a localizaΓ§Γ£o'; | |
| document.getElementById('gps-text').classList.remove('located'); | |
| document.getElementById('locate-options').style.display = 'none'; | |
| const btn = document.getElementById('gps-btn'); | |
| btn.classList.remove('located'); | |
| 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(); | |
| })(); | |