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; | |
| // 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(); | |
| })(); | |