Spaces:
Runtime error
Runtime error
add new ui
Browse files- app.py +81 -285
- index.html +579 -0
- requirements.txt +3 -4
app.py
CHANGED
|
@@ -1,21 +1,21 @@
|
|
| 1 |
"""
|
| 2 |
-
app.py β
|
| 3 |
Build Small Hackathon Β· Backyard AI Track Β· Junho 2026
|
|
|
|
| 4 |
"""
|
| 5 |
import json
|
| 6 |
import logging
|
| 7 |
import os
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
_gcu._json_schema_to_python_type = _safe
|
| 15 |
|
| 16 |
-
import gradio as gr
|
| 17 |
from core.ai import AnimalAI
|
| 18 |
-
from core.database import Database
|
| 19 |
from core.matcher import AnimalMatcher
|
| 20 |
|
| 21 |
logging.basicConfig(level=logging.INFO)
|
|
@@ -23,252 +23,86 @@ db = Database()
|
|
| 23 |
ai = AnimalAI()
|
| 24 |
matcher = AnimalMatcher()
|
| 25 |
|
| 26 |
-
|
| 27 |
-
C_GREEN_L = "#D9EBD9"
|
| 28 |
-
C_TEXT = "#212121"
|
| 29 |
-
C_RED = "#E53935"
|
| 30 |
-
C_ORANGE = "#FB8C00"
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
(function() {
|
| 36 |
-
function updateGradioTextbox(elementId, value) {
|
| 37 |
-
var attempts = 0;
|
| 38 |
-
function tryUpdate() {
|
| 39 |
-
var container = document.getElementById(elementId);
|
| 40 |
-
if (!container) { if (attempts++ < 20) setTimeout(tryUpdate, 300); return; }
|
| 41 |
-
var tb = container.querySelector('textarea') || container.querySelector('input[type="text"]');
|
| 42 |
-
if (!tb) { if (attempts++ < 20) setTimeout(tryUpdate, 300); return; }
|
| 43 |
-
var proto = (tb.tagName === 'TEXTAREA') ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
| 44 |
-
var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
| 45 |
-
setter.call(tb, value);
|
| 46 |
-
tb.dispatchEvent(new Event('input', { bubbles: true }));
|
| 47 |
-
tb.dispatchEvent(new Event('change', { bubbles: true }));
|
| 48 |
-
}
|
| 49 |
-
tryUpdate();
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
function requestGPS() {
|
| 53 |
-
var icon = document.getElementById('gps-icon');
|
| 54 |
-
var text = document.getElementById('gps-text');
|
| 55 |
-
var btn = document.getElementById('gps-btn');
|
| 56 |
-
if (!icon || !text || !btn) return;
|
| 57 |
-
if (!navigator.geolocation) { text.textContent = 'GPS nao disponivel'; return; }
|
| 58 |
-
btn.disabled = true;
|
| 59 |
-
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Detectando...';
|
| 60 |
-
icon.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="color:#388C59"></i>';
|
| 61 |
-
text.textContent = 'Aguardando GPS...';
|
| 62 |
-
navigator.geolocation.getCurrentPosition(
|
| 63 |
-
function(pos) {
|
| 64 |
-
var lat = parseFloat(pos.coords.latitude.toFixed(5));
|
| 65 |
-
var lng = parseFloat(pos.coords.longitude.toFixed(5));
|
| 66 |
-
icon.innerHTML = '<i class="fa-solid fa-check" style="color:#388C59"></i>';
|
| 67 |
-
text.innerHTML = '<strong>Localizacao:</strong> ' + lat + ', ' + lng;
|
| 68 |
-
btn.innerHTML = '<i class="fa-solid fa-rotate"></i> Atualizar';
|
| 69 |
-
btn.disabled = false;
|
| 70 |
-
btn.style.background = '#2d7a4a';
|
| 71 |
-
updateGradioTextbox('gps-coords', JSON.stringify({ lat: lat, lng: lng }));
|
| 72 |
-
},
|
| 73 |
-
function(err) {
|
| 74 |
-
icon.innerHTML = '<i class="fa-solid fa-triangle-exclamation" style="color:#E53935"></i>';
|
| 75 |
-
btn.innerHTML = '<i class="fa-solid fa-location-dot"></i> Tentar novamente';
|
| 76 |
-
btn.disabled = false;
|
| 77 |
-
btn.style.background = '#E53935';
|
| 78 |
-
var msgs = { 1:'Permissao negada.', 2:'Localizacao indisponivel.', 3:'Timeout GPS.' };
|
| 79 |
-
text.textContent = msgs[err.code] || 'Erro: ' + err.message;
|
| 80 |
-
updateGradioTextbox('gps-coords', JSON.stringify({}));
|
| 81 |
-
},
|
| 82 |
-
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
|
| 83 |
-
);
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
window.animalVistoRequestGPS = requestGPS;
|
| 87 |
|
| 88 |
-
document.addEventListener('click', function(e) {
|
| 89 |
-
var tabBtn = e.target.closest('.tab-nav button');
|
| 90 |
-
if (tabBtn && tabBtn.textContent.includes('Registrar')) setTimeout(requestGPS, 600);
|
| 91 |
-
});
|
| 92 |
|
| 93 |
-
|
| 94 |
-
var el = document.getElementById('photo-upload');
|
| 95 |
-
if (!el) { setTimeout(patchCameraInput, 800); return; }
|
| 96 |
-
var inp = el.querySelector('input[type=file]');
|
| 97 |
-
if (inp) { inp.setAttribute('capture', 'environment'); inp.setAttribute('accept', 'image/*'); }
|
| 98 |
-
else setTimeout(patchCameraInput, 800);
|
| 99 |
-
}
|
| 100 |
-
setTimeout(patchCameraInput, 1500);
|
| 101 |
-
})();
|
| 102 |
-
</script>
|
| 103 |
-
"""
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
.
|
| 109 |
-
footer { display: none !important; }
|
| 110 |
-
#top-bar { background: #388C59; color: white; padding: 14px 16px 12px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 8px rgba(0,0,0,.15); }
|
| 111 |
-
#top-bar h1 { margin: 0; font-size: 18px; font-weight: 700; }
|
| 112 |
-
#top-bar small { font-size: 12px; opacity: .75; }
|
| 113 |
-
.tabs > .tab-nav { background: #fff; border-top: 1px solid #eee; border-bottom: none !important; display: flex; position: sticky; bottom: 0; z-index: 100; box-shadow: 0 -2px 10px rgba(0,0,0,.08); padding: 0; gap: 0; }
|
| 114 |
-
.tabs > .tab-nav button { flex: 1 !important; padding: 10px 4px 8px !important; border-radius: 0 !important; border: none !important; background: transparent !important; color: #999 !important; font-size: 13px !important; font-weight: 500 !important; border-top: 3px solid transparent !important; }
|
| 115 |
-
.tabs > .tab-nav button.selected { color: #388C59 !important; border-top: 3px solid #388C59 !important; font-weight: 700 !important; }
|
| 116 |
-
.filter-row { display: flex; gap: 6px; padding: 10px 12px; overflow-x: auto; scrollbar-width: none; background: white; border-bottom: 1px solid #f0f0f0; }
|
| 117 |
-
.filter-row button { border-radius: 20px !important; border: 1.5px solid #ddd !important; background: white !important; color: #212121 !important; font-size: 13px !important; padding: 5px 14px !important; white-space: nowrap; font-weight: 500 !important; min-width: unset !important; }
|
| 118 |
-
.filter-row button:hover { background: #388C59 !important; border-color: #388C59 !important; color: white !important; }
|
| 119 |
-
#register-tab { padding: 16px 16px 80px; }
|
| 120 |
-
.form-card { background: #ffffff !important; border-radius: 16px !important; padding: 20px 16px !important; box-shadow: 0 4px 14px rgba(0,0,0,0.06) !important; border: 1px solid #eaeaea !important; }
|
| 121 |
-
.reg-label { font-size: 14px; font-weight: 600; color: #212121; margin: 12px 0 8px; display: flex; align-items: center; gap: 8px; }
|
| 122 |
-
.reg-label i { color: #388C59; }
|
| 123 |
-
#photo-upload { border: 2px dashed #cfcfcf !important; border-radius: 12px !important; background: #f9f9f9 !important; min-height: 180px; }
|
| 124 |
-
#submit-btn { margin-top: 10px !important; width: 100% !important; background: #388C59 !important; border: none !important; border-radius: 12px !important; font-size: 16px !important; font-weight: 600 !important; padding: 14px !important; color: white !important; }
|
| 125 |
-
#status-card { margin-top: 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
|
| 126 |
-
#status-card.ok { background: #e8f5e9; color: #2e7d32; border-left: 4px solid #388C59; padding: 14px 16px; }
|
| 127 |
-
#status-card.err { background: #ffebee; color: #b71c1c; border-left: 4px solid #E53935; padding: 14px 16px; }
|
| 128 |
-
#gps-coords { display: none !important; }
|
| 129 |
-
#animals-tab { padding: 0 0 80px; background: white; }
|
| 130 |
-
.animal-card { display: flex; align-items: center; gap: 12px; background: white; border-radius: 12px; padding: 14px 16px; margin: 8px 12px; box-shadow: 0 2px 8px rgba(0,0,0,.07); border: 1px solid #f0f0f0; }
|
| 131 |
-
.animal-thumb { width: 52px; height: 52px; border-radius: 10px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 24px; overflow: hidden; background: #eee; }
|
| 132 |
-
.animal-info { flex: 1; min-width: 0; }
|
| 133 |
-
.animal-name { font-weight: 600; font-size: 14px; color: #212121; }
|
| 134 |
-
.animal-meta { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 135 |
-
.animal-badge { background: #D9EBD9; color: #388C59; border-radius: 20px; padding: 3px 9px; font-size: 12px; font-weight: 700; flex-shrink: 0; }
|
| 136 |
-
.animal-badge.urgent { background: #ffebee; color: #E53935; }
|
| 137 |
-
.section-header { padding: 14px 16px 6px; font-size: 13px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .5px; }
|
| 138 |
-
"""
|
| 139 |
|
| 140 |
-
GPS_HTML = """
|
| 141 |
-
<div id="gps-box" style="display:flex;align-items:center;gap:10px;background:#D9EBD9;border-radius:10px;padding:12px 14px;font-size:13px;color:#2e7d32;font-weight:500;border:1px solid #c8e6c9;">
|
| 142 |
-
<span id="gps-icon" style="font-size:18px"><i class="fa-solid fa-location-crosshairs"></i></span>
|
| 143 |
-
<span id="gps-text" style="flex:1">Toque para detectar localizacao</span>
|
| 144 |
-
<button id="gps-btn" onclick="window.animalVistoRequestGPS && window.animalVistoRequestGPS()"
|
| 145 |
-
style="background:#388C59;color:white;border:none;padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;">
|
| 146 |
-
<i class="fa-solid fa-location-dot"></i> Localizar
|
| 147 |
-
</button>
|
| 148 |
-
</div>
|
| 149 |
-
"""
|
| 150 |
|
|
|
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
| 154 |
data = db.get_map_data(species, timeframe)
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
'<meta charset="utf-8"/>'
|
| 161 |
-
'<meta name="viewport" content="width=device-width,initial-scale=1"/>'
|
| 162 |
-
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>'
|
| 163 |
-
'<style>body{margin:0;padding:0;}#map{height:100vh;width:100%;}'
|
| 164 |
-
'.popup-photo{width:100%;height:80px;object-fit:cover;border-radius:6px;margin-bottom:6px;}'
|
| 165 |
-
'</style></head><body>'
|
| 166 |
-
'<div id="map"></div>'
|
| 167 |
-
'<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>'
|
| 168 |
-
'<script>'
|
| 169 |
-
'var animals=JSON.parse(atob("' + data_b64 + '"));'
|
| 170 |
-
'var map=L.map("map").setView([-23.0316,-46.9785],13);'
|
| 171 |
-
'L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{attribution:"OSM",maxZoom:19}).addTo(map);'
|
| 172 |
-
'if(!animals.length){'
|
| 173 |
-
' var info=L.control({position:"topright"});'
|
| 174 |
-
' info.onAdd=function(){var d=L.DomUtil.create("div");d.style="background:white;padding:8px 12px;border-radius:8px;font-size:13px;color:#888;";d.innerHTML="Nenhum avistamento ainda";return d;};'
|
| 175 |
-
' info.addTo(map);'
|
| 176 |
-
'}'
|
| 177 |
-
'animals.forEach(function(a){'
|
| 178 |
-
' var color=a.days_since>30?"#E53935":a.count>1?"#FB8C00":"#388C59";'
|
| 179 |
-
' var em=a.species==="dog"?"D":"G";'
|
| 180 |
-
' var badge=a.count>1?"<span style=\'position:absolute;top:-5px;right:-5px;background:white;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;\'>"+a.count+"</span>":"";'
|
| 181 |
-
' var ico=L.divIcon({html:"<div style=\'position:relative;background:"+color+";width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700;color:white;box-shadow:0 2px 8px rgba(0,0,0,.3);border:2.5px solid white;\'>"+em+badge+"</div>",className:"",iconSize:[38,38],iconAnchor:[19,19],popupAnchor:[0,-22]});'
|
| 182 |
-
' var sp=a.species==="dog"?"Cao":"Gato";'
|
| 183 |
-
' var urg=a.days_since>30?"<br><small style=\'color:#E53935\'>Nao visto ha "+a.days_since+" dias</small>":"";'
|
| 184 |
-
' var popup="<b>"+sp+" #"+a.id+"</b>"+(a.desc?"<br><small>"+a.desc+"</small>":"")+"<br><small>"+a.count+"x - "+a.last_seen+"</small>"+urg;'
|
| 185 |
-
' L.marker([a.lat,a.lng],{icon:ico}).addTo(map).bindPopup(popup,{maxWidth:220});'
|
| 186 |
-
'});'
|
| 187 |
-
'</script></body></html>'
|
| 188 |
-
)
|
| 189 |
-
# srcdoc: sΓ³ escapa & e " (NΓO escapa < e > para nΓ£o quebrar o HTML)
|
| 190 |
-
srcdoc = inner.replace('&', '&').replace('"', '"')
|
| 191 |
-
return f'<iframe srcdoc="{srcdoc}" style="width:100%;height:500px;border:none;"></iframe>'
|
| 192 |
|
| 193 |
|
| 194 |
-
|
|
|
|
| 195 |
animals = db.get_recent_animals(limit=30)
|
| 196 |
-
if not animals:
|
| 197 |
-
return '<div style="padding:40px 16px;text-align:center;color:#aaa;"><div style="font-size:48px">πΎ</div><div style="font-size:15px;font-weight:500;margin-top:12px;">Nenhum animal registrado ainda</div></div>'
|
| 198 |
-
cards = []
|
| 199 |
for a in animals:
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
if photo_url:
|
| 217 |
-
thumb = f'<img src="{photo_url}" style="width:52px;height:52px;border-radius:10px;object-fit:cover;" onerror="this.parentElement.innerHTML=\'{em}\'">'
|
| 218 |
-
else:
|
| 219 |
-
thumb = em
|
| 220 |
-
urgent_icon = '<i class="fa-solid fa-triangle-exclamation"></i> ' if urgent else ''
|
| 221 |
-
cards.append(f'''
|
| 222 |
-
<div class="animal-card">
|
| 223 |
-
<div class="animal-thumb">{thumb}</div>
|
| 224 |
-
<div class="animal-info">
|
| 225 |
-
<div class="animal-name">{sp_pt} #{a["id"]}</div>
|
| 226 |
-
<div class="animal-meta">{meta}</div>
|
| 227 |
-
<div class="animal-meta" style="color:{color};">{urgent_icon}Visto {count}x Β· ultimo: {last_seen}</div>
|
| 228 |
-
</div>
|
| 229 |
-
<div class="{badge_cls}">{count}x</div>
|
| 230 |
-
</div>''')
|
| 231 |
-
total_a = db.total_animals()
|
| 232 |
-
total_s = db.total_sightings()
|
| 233 |
-
return f'<div class="section-header">πΎ {total_a} animais Β· {total_s} avistamentos</div>' + "".join(cards)
|
| 234 |
|
| 235 |
|
| 236 |
-
|
| 237 |
-
em = "π" if species == "dog" else "π"
|
| 238 |
-
sp_pt = "cao" if species == "dog" else "gato"
|
| 239 |
-
title = f"Novo {sp_pt} registrado!" if is_new else f"{em} Animal reconhecido!"
|
| 240 |
-
sub = "1o avistamento β obrigada por registrar!" if is_new else f"Este {sp_pt} ja foi avistado <b>{count}x</b> na regiao."
|
| 241 |
-
photo_html = f'<img src="{photo_url}" style="width:100%;max-height:160px;object-fit:cover;border-radius:8px;margin-bottom:8px;" onerror="this.style.display=\'none\'">' if photo_url else ""
|
| 242 |
-
return f'''
|
| 243 |
-
<div id="status-card" class="ok">
|
| 244 |
-
{photo_html}
|
| 245 |
-
<div style="font-size:24px;margin-bottom:6px;color:#388C59;">β
{em}</div>
|
| 246 |
-
<div style="font-weight:700;font-size:15px;">{title}</div>
|
| 247 |
-
<div>{sub}</div>
|
| 248 |
-
<div style="font-size:12px;margin-top:6px;color:#555;">ID #{animal_id} Β· Avistamento salvo</div>
|
| 249 |
-
</div>'''
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
-
def process_sighting(image, gps_json, notes, progress=gr.Progress()):
|
| 253 |
-
if image is None:
|
| 254 |
-
return gr.update(), '<div id="status-card" class="err">Tire uma foto do animal antes de registrar.</div>'
|
| 255 |
-
progress(0.15, desc="Analisando imagem...")
|
| 256 |
try:
|
| 257 |
coords = json.loads(gps_json) if gps_json and gps_json.strip() else {}
|
| 258 |
except Exception:
|
| 259 |
coords = {}
|
|
|
|
| 260 |
lat = round(float(coords["lat"]), 5) if coords.get("lat") else None
|
| 261 |
lng = round(float(coords["lng"]), 5) if coords.get("lng") else None
|
| 262 |
-
|
| 263 |
-
description = ai.analyze_image(
|
| 264 |
embedding = ai.get_embedding(description)
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
if match:
|
| 269 |
animal_id, _score = match
|
| 270 |
-
photo_path = db.save_photo(
|
| 271 |
-
photo_url = db.photo_url(photo_path) or ""
|
| 272 |
db.add_sighting(animal_id, photo_path, lat, lng, notes)
|
| 273 |
db.update_animal(animal_id)
|
| 274 |
animal = db.get_animal(animal_id)
|
|
@@ -276,68 +110,30 @@ def process_sighting(image, gps_json, notes, progress=gr.Progress()):
|
|
| 276 |
species = animal["species"]
|
| 277 |
is_new = False
|
| 278 |
else:
|
| 279 |
-
animal_id
|
| 280 |
-
photo_path = db.save_photo(
|
| 281 |
-
photo_url = db.photo_url(photo_path) or ""
|
| 282 |
db.add_sighting(animal_id, photo_path, lat, lng, notes)
|
| 283 |
count = 1
|
| 284 |
species = description.get("species", "dog")
|
| 285 |
is_new = True
|
| 286 |
-
progress(1.0, desc="Salvo!")
|
| 287 |
-
return gr.update(value=build_map_html()), build_confirmation_html(animal_id, is_new, count, species, photo_url)
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
with gr.Blocks(css=CSS, head=HEAD_HTML, theme=gr.themes.Base(), title="Animal Visto πΎ") as demo:
|
| 291 |
-
|
| 292 |
-
gr.HTML('<div id="top-bar"><h1>πΎ Animal Visto</h1><small>Vinhedo, SP</small></div>')
|
| 293 |
-
|
| 294 |
-
species_st = gr.State("all")
|
| 295 |
-
timeframe_st = gr.State("all")
|
| 296 |
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
btn_week = gr.Button("Esta semana", elem_classes="filter-row secondary")
|
| 306 |
-
map_html = gr.HTML(build_map_html(), elem_id="map-container")
|
| 307 |
-
|
| 308 |
-
with gr.Tab("π· Registrar", elem_id="register-tab"):
|
| 309 |
-
with gr.Column(elem_classes="form-card"):
|
| 310 |
-
gr.HTML('<div class="reg-label"><i class="fa-solid fa-location-dot"></i> Passo 1: Localizacao</div>')
|
| 311 |
-
gr.HTML(GPS_HTML)
|
| 312 |
-
gps_coords = gr.Textbox(value="", elem_id="gps-coords", interactive=True, visible=False, label="gps-coords")
|
| 313 |
-
gr.HTML('<div class="reg-label"><i class="fa-solid fa-camera"></i> Passo 2: Foto do Animal</div>')
|
| 314 |
-
photo_input = gr.Image(label="", type="pil", sources=["upload", "webcam"], interactive=True, show_label=False, elem_id="photo-upload")
|
| 315 |
-
gr.HTML('<div class="reg-label"><i class="fa-solid fa-file-signature"></i> Passo 3: Observacoes (opcional)</div>')
|
| 316 |
-
notes_input = gr.Textbox(label="", placeholder="Ex.: parece ferido, tem coleira...", lines=2, max_lines=4, show_label=False)
|
| 317 |
-
submit_btn = gr.Button("Registrar avistamento", variant="primary", elem_id="submit-btn")
|
| 318 |
-
status_html = gr.HTML("")
|
| 319 |
-
|
| 320 |
-
with gr.Tab("πΎ Avistados", elem_id="animals-tab"):
|
| 321 |
-
refresh_btn = gr.Button("Atualizar lista", size="sm", variant="secondary")
|
| 322 |
-
animals_display = gr.HTML(build_animals_html())
|
| 323 |
|
| 324 |
-
submit_btn.click(process_sighting, inputs=[photo_input, gps_coords, notes_input], outputs=[map_html, status_html])
|
| 325 |
-
btn_all.click(lambda: (build_map_html("all","all"),"all","all"), outputs=[map_html, species_st, timeframe_st])
|
| 326 |
-
btn_dogs.click(lambda t: (build_map_html("dog",t),"dog",t), inputs=[timeframe_st], outputs=[map_html, species_st, timeframe_st])
|
| 327 |
-
btn_cats.click(lambda t: (build_map_html("cat",t),"cat",t), inputs=[timeframe_st], outputs=[map_html, species_st, timeframe_st])
|
| 328 |
-
btn_today.click(lambda s: (build_map_html(s,"today"),s,"today"), inputs=[species_st], outputs=[map_html, species_st, timeframe_st])
|
| 329 |
-
btn_week.click(lambda s: (build_map_html(s,"week"),s,"week"), inputs=[species_st], outputs=[map_html, species_st, timeframe_st])
|
| 330 |
-
refresh_btn.click(build_animals_html, outputs=[animals_display])
|
| 331 |
-
demo.load(build_map_html, outputs=[map_html])
|
| 332 |
|
|
|
|
| 333 |
|
| 334 |
if __name__ == "__main__":
|
| 335 |
-
|
| 336 |
-
_orig_gai = _gb_mod.Blocks.get_api_info
|
| 337 |
-
def _safe_get_api_info(self):
|
| 338 |
-
try: return _orig_gai(self)
|
| 339 |
-
except Exception as _e: logging.warning(f"get_api_info: {_e}"); return {}
|
| 340 |
-
_gb_mod.Blocks.get_api_info = _safe_get_api_info
|
| 341 |
-
from core.database import DATA_DIR, PHOTOS_DIR
|
| 342 |
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
app.py β PawMap
|
| 3 |
Build Small Hackathon Β· Backyard AI Track Β· Junho 2026
|
| 4 |
+
Custom frontend via gradio.Server
|
| 5 |
"""
|
| 6 |
import json
|
| 7 |
import logging
|
| 8 |
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
|
| 11 |
+
from gradio import Server
|
| 12 |
+
from gradio.data_classes import FileData
|
| 13 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 14 |
+
from fastapi import Query
|
| 15 |
+
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 16 |
|
|
|
|
| 17 |
from core.ai import AnimalAI
|
| 18 |
+
from core.database import Database, DATA_DIR, PHOTOS_DIR
|
| 19 |
from core.matcher import AnimalMatcher
|
| 20 |
|
| 21 |
logging.basicConfig(level=logging.INFO)
|
|
|
|
| 23 |
ai = AnimalAI()
|
| 24 |
matcher = AnimalMatcher()
|
| 25 |
|
| 26 |
+
app = Server()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
# Serve photos as static files at /photos/...
|
| 29 |
+
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
app.mount("/photos", StaticFiles(directory=str(PHOTOS_DIR)), name="photos")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
+
# βββ Frontend βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
@app.get("/", response_class=HTMLResponse)
|
| 36 |
+
async def homepage():
|
| 37 |
+
html_path = Path(__file__).parent / "index.html"
|
| 38 |
+
return html_path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
# βββ Data APIs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
|
| 43 |
+
@app.get("/api/map-data")
|
| 44 |
+
async def get_map_data(
|
| 45 |
+
species: str = Query("all"),
|
| 46 |
+
timeframe: str = Query("all"),
|
| 47 |
+
):
|
| 48 |
data = db.get_map_data(species, timeframe)
|
| 49 |
+
for item in data:
|
| 50 |
+
pp = item.get("last_photo") or ""
|
| 51 |
+
item["photo_url"] = f"/photos/{pp}" if pp else ""
|
| 52 |
+
item.pop("last_photo", None)
|
| 53 |
+
return JSONResponse(content=data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
+
@app.get("/api/animals")
|
| 57 |
+
async def get_animals():
|
| 58 |
animals = db.get_recent_animals(limit=30)
|
|
|
|
|
|
|
|
|
|
| 59 |
for a in animals:
|
| 60 |
+
pp = a.get("last_photo_path") or ""
|
| 61 |
+
a["photo_url"] = f"/photos/{pp}" if pp else ""
|
| 62 |
+
a.pop("last_photo_path", None)
|
| 63 |
+
a.pop("embedding", None)
|
| 64 |
+
return JSONResponse(content=animals)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@app.get("/api/animal/{animal_id}")
|
| 68 |
+
async def get_animal(animal_id: int):
|
| 69 |
+
detail = db.get_animal_detail(animal_id)
|
| 70 |
+
if not detail:
|
| 71 |
+
return JSONResponse(content={"error": "not found"}, status_code=404)
|
| 72 |
+
for s in detail.get("sightings", []):
|
| 73 |
+
pp = s.get("photo_path") or ""
|
| 74 |
+
s["photo_url"] = f"/photos/{pp}" if pp else ""
|
| 75 |
+
return JSONResponse(content=detail)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
+
# βββ ML API (queued via Gradio) βββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
@app.api(name="process_sighting")
|
| 81 |
+
def process_sighting(
|
| 82 |
+
image_path: FileData,
|
| 83 |
+
gps_json: str = "",
|
| 84 |
+
notes: str = "",
|
| 85 |
+
) -> dict:
|
| 86 |
+
from PIL import Image as PILImage
|
| 87 |
+
|
| 88 |
+
img = PILImage.open(image_path["path"]).convert("RGB")
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
try:
|
| 91 |
coords = json.loads(gps_json) if gps_json and gps_json.strip() else {}
|
| 92 |
except Exception:
|
| 93 |
coords = {}
|
| 94 |
+
|
| 95 |
lat = round(float(coords["lat"]), 5) if coords.get("lat") else None
|
| 96 |
lng = round(float(coords["lng"]), 5) if coords.get("lng") else None
|
| 97 |
+
|
| 98 |
+
description = ai.analyze_image(img)
|
| 99 |
embedding = ai.get_embedding(description)
|
| 100 |
+
candidates = db.get_all_animals_with_embeddings()
|
| 101 |
+
match = matcher.find_match(embedding, candidates)
|
| 102 |
+
|
| 103 |
if match:
|
| 104 |
animal_id, _score = match
|
| 105 |
+
photo_path = db.save_photo(img, animal_id=animal_id)
|
|
|
|
| 106 |
db.add_sighting(animal_id, photo_path, lat, lng, notes)
|
| 107 |
db.update_animal(animal_id)
|
| 108 |
animal = db.get_animal(animal_id)
|
|
|
|
| 110 |
species = animal["species"]
|
| 111 |
is_new = False
|
| 112 |
else:
|
| 113 |
+
animal_id = db.create_animal(description, embedding)
|
| 114 |
+
photo_path = db.save_photo(img, animal_id=animal_id)
|
|
|
|
| 115 |
db.add_sighting(animal_id, photo_path, lat, lng, notes)
|
| 116 |
count = 1
|
| 117 |
species = description.get("species", "dog")
|
| 118 |
is_new = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
+
return {
|
| 121 |
+
"animal_id": animal_id,
|
| 122 |
+
"is_new": is_new,
|
| 123 |
+
"count": count,
|
| 124 |
+
"species": species,
|
| 125 |
+
"photo_url": f"/photos/{photo_path}" if photo_path else "",
|
| 126 |
+
"description": description,
|
| 127 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
+
# βββ Launch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 131 |
|
| 132 |
if __name__ == "__main__":
|
| 133 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 135 |
+
app.launch(
|
| 136 |
+
server_name="0.0.0.0",
|
| 137 |
+
server_port=int(os.environ.get("PORT", 7860)),
|
| 138 |
+
show_error=True,
|
| 139 |
+
)
|
index.html
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="pt-BR">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
|
| 6 |
+
<title>PawMap πΎ</title>
|
| 7 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400;0,14..32,500;0,14..32,600;0,14..32,700;1,14..32,400&display=swap" rel="stylesheet"/>
|
| 10 |
+
<style>
|
| 11 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 12 |
+
|
| 13 |
+
:root {
|
| 14 |
+
--green: #388C59;
|
| 15 |
+
--green-dark: #2d7249;
|
| 16 |
+
--green-pale: #D9EBD9;
|
| 17 |
+
--green-soft: #EDF6EE;
|
| 18 |
+
--orange: #FB8C00;
|
| 19 |
+
--red: #E53935;
|
| 20 |
+
--text: #1A1A1A;
|
| 21 |
+
--text-muted: #888;
|
| 22 |
+
--border: #EBEBEB;
|
| 23 |
+
--bg: #F4F6F4;
|
| 24 |
+
--white: #FFFFFF;
|
| 25 |
+
--nav-h: 62px;
|
| 26 |
+
--header-h: 54px;
|
| 27 |
+
--filter-h: 48px;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
html, body {
|
| 31 |
+
height: 100%;
|
| 32 |
+
font-family: 'Inter', system-ui, sans-serif;
|
| 33 |
+
background: var(--bg);
|
| 34 |
+
color: var(--text);
|
| 35 |
+
overflow: hidden;
|
| 36 |
+
-webkit-tap-highlight-color: transparent;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* ββ App shell ββ */
|
| 40 |
+
#app {
|
| 41 |
+
display: flex;
|
| 42 |
+
flex-direction: column;
|
| 43 |
+
height: 100dvh;
|
| 44 |
+
max-width: 480px;
|
| 45 |
+
margin: 0 auto;
|
| 46 |
+
position: relative;
|
| 47 |
+
background: var(--white);
|
| 48 |
+
box-shadow: 0 0 40px rgba(0,0,0,.12);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* ββ Header ββ */
|
| 52 |
+
#header {
|
| 53 |
+
height: var(--header-h);
|
| 54 |
+
background: var(--green);
|
| 55 |
+
color: #fff;
|
| 56 |
+
display: flex;
|
| 57 |
+
align-items: center;
|
| 58 |
+
justify-content: space-between;
|
| 59 |
+
padding: 0 16px;
|
| 60 |
+
flex-shrink: 0;
|
| 61 |
+
z-index: 200;
|
| 62 |
+
}
|
| 63 |
+
#header .logo {
|
| 64 |
+
font-size: 17px;
|
| 65 |
+
font-weight: 700;
|
| 66 |
+
letter-spacing: -.3px;
|
| 67 |
+
}
|
| 68 |
+
.icon-btn {
|
| 69 |
+
background: transparent;
|
| 70 |
+
border: none;
|
| 71 |
+
color: #fff;
|
| 72 |
+
cursor: pointer;
|
| 73 |
+
width: 36px;
|
| 74 |
+
height: 36px;
|
| 75 |
+
border-radius: 50%;
|
| 76 |
+
display: flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
justify-content: center;
|
| 79 |
+
font-size: 18px;
|
| 80 |
+
transition: background .15s;
|
| 81 |
+
}
|
| 82 |
+
.icon-btn:hover { background: rgba(255,255,255,.15); }
|
| 83 |
+
.icon-btn svg { width: 20px; height: 20px; fill: none; stroke: #fff; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
| 84 |
+
|
| 85 |
+
/* ββ Filter chips ββ */
|
| 86 |
+
#filter-row {
|
| 87 |
+
height: var(--filter-h);
|
| 88 |
+
display: flex;
|
| 89 |
+
align-items: center;
|
| 90 |
+
gap: 6px;
|
| 91 |
+
padding: 0 14px;
|
| 92 |
+
overflow-x: auto;
|
| 93 |
+
scrollbar-width: none;
|
| 94 |
+
background: var(--white);
|
| 95 |
+
border-bottom: 1px solid var(--border);
|
| 96 |
+
flex-shrink: 0;
|
| 97 |
+
z-index: 100;
|
| 98 |
+
}
|
| 99 |
+
#filter-row::-webkit-scrollbar { display: none; }
|
| 100 |
+
|
| 101 |
+
.chip {
|
| 102 |
+
border-radius: 20px;
|
| 103 |
+
border: 1.5px solid var(--border);
|
| 104 |
+
background: var(--white);
|
| 105 |
+
color: var(--text-muted);
|
| 106 |
+
font-size: 13px;
|
| 107 |
+
font-weight: 500;
|
| 108 |
+
padding: 5px 14px;
|
| 109 |
+
white-space: nowrap;
|
| 110 |
+
cursor: pointer;
|
| 111 |
+
font-family: inherit;
|
| 112 |
+
transition: all .15s;
|
| 113 |
+
flex-shrink: 0;
|
| 114 |
+
}
|
| 115 |
+
.chip:hover { border-color: var(--green); color: var(--green); }
|
| 116 |
+
.chip.active {
|
| 117 |
+
background: var(--green);
|
| 118 |
+
border-color: var(--green);
|
| 119 |
+
color: #fff;
|
| 120 |
+
font-weight: 600;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* ββ Screen system ββ */
|
| 124 |
+
.screen {
|
| 125 |
+
display: none;
|
| 126 |
+
flex: 1;
|
| 127 |
+
flex-direction: column;
|
| 128 |
+
overflow: hidden;
|
| 129 |
+
position: relative;
|
| 130 |
+
}
|
| 131 |
+
.screen.active { display: flex; }
|
| 132 |
+
|
| 133 |
+
/* ββ Map screen ββ */
|
| 134 |
+
#map {
|
| 135 |
+
flex: 1;
|
| 136 |
+
width: 100%;
|
| 137 |
+
z-index: 1;
|
| 138 |
+
}
|
| 139 |
+
.leaflet-control-zoom {
|
| 140 |
+
border: none !important;
|
| 141 |
+
box-shadow: 0 2px 12px rgba(0,0,0,.15) !important;
|
| 142 |
+
}
|
| 143 |
+
.leaflet-control-zoom a {
|
| 144 |
+
border-radius: 8px !important;
|
| 145 |
+
border: none !important;
|
| 146 |
+
font-weight: 600 !important;
|
| 147 |
+
color: var(--text) !important;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* ββ Floating sighting card ββ */
|
| 151 |
+
#sighting-card {
|
| 152 |
+
position: absolute;
|
| 153 |
+
bottom: 14px;
|
| 154 |
+
left: 14px;
|
| 155 |
+
right: 14px;
|
| 156 |
+
background: var(--white);
|
| 157 |
+
border-radius: 16px;
|
| 158 |
+
padding: 14px 16px;
|
| 159 |
+
box-shadow: 0 4px 24px rgba(0,0,0,.14);
|
| 160 |
+
z-index: 400;
|
| 161 |
+
display: flex;
|
| 162 |
+
align-items: center;
|
| 163 |
+
gap: 12px;
|
| 164 |
+
transition: opacity .2s, transform .2s;
|
| 165 |
+
}
|
| 166 |
+
#sighting-card.hidden {
|
| 167 |
+
opacity: 0;
|
| 168 |
+
pointer-events: none;
|
| 169 |
+
transform: translateY(8px);
|
| 170 |
+
}
|
| 171 |
+
#sighting-card .card-photo {
|
| 172 |
+
width: 52px;
|
| 173 |
+
height: 52px;
|
| 174 |
+
border-radius: 50%;
|
| 175 |
+
object-fit: cover;
|
| 176 |
+
flex-shrink: 0;
|
| 177 |
+
background: var(--bg);
|
| 178 |
+
display: flex;
|
| 179 |
+
align-items: center;
|
| 180 |
+
justify-content: center;
|
| 181 |
+
font-size: 24px;
|
| 182 |
+
overflow: hidden;
|
| 183 |
+
}
|
| 184 |
+
#sighting-card .card-photo img {
|
| 185 |
+
width: 100%;
|
| 186 |
+
height: 100%;
|
| 187 |
+
object-fit: cover;
|
| 188 |
+
}
|
| 189 |
+
#sighting-card .card-info { flex: 1; min-width: 0; }
|
| 190 |
+
#sighting-card .card-top {
|
| 191 |
+
display: flex;
|
| 192 |
+
align-items: center;
|
| 193 |
+
gap: 6px;
|
| 194 |
+
margin-bottom: 3px;
|
| 195 |
+
}
|
| 196 |
+
.badge {
|
| 197 |
+
background: var(--green-pale);
|
| 198 |
+
color: var(--green);
|
| 199 |
+
border-radius: 20px;
|
| 200 |
+
padding: 2px 10px;
|
| 201 |
+
font-size: 11px;
|
| 202 |
+
font-weight: 700;
|
| 203 |
+
}
|
| 204 |
+
.badge.urgent { background: #ffebee; color: var(--red); }
|
| 205 |
+
.badge.orange { background: #fff3e0; color: var(--orange); }
|
| 206 |
+
#sighting-card .card-time { font-size: 11px; color: var(--text-muted); }
|
| 207 |
+
#sighting-card .card-location {
|
| 208 |
+
font-weight: 600;
|
| 209 |
+
font-size: 13px;
|
| 210 |
+
color: var(--text);
|
| 211 |
+
white-space: nowrap;
|
| 212 |
+
overflow: hidden;
|
| 213 |
+
text-overflow: ellipsis;
|
| 214 |
+
}
|
| 215 |
+
#sighting-card .card-sub {
|
| 216 |
+
font-size: 12px;
|
| 217 |
+
color: var(--text-muted);
|
| 218 |
+
margin-top: 1px;
|
| 219 |
+
}
|
| 220 |
+
.btn-ficha {
|
| 221 |
+
background: var(--green);
|
| 222 |
+
color: #fff;
|
| 223 |
+
border: none;
|
| 224 |
+
border-radius: 10px;
|
| 225 |
+
padding: 8px 14px;
|
| 226 |
+
font-size: 12px;
|
| 227 |
+
font-weight: 600;
|
| 228 |
+
white-space: nowrap;
|
| 229 |
+
cursor: pointer;
|
| 230 |
+
font-family: inherit;
|
| 231 |
+
flex-shrink: 0;
|
| 232 |
+
transition: background .15s;
|
| 233 |
+
}
|
| 234 |
+
.btn-ficha:hover { background: var(--green-dark); }
|
| 235 |
+
|
| 236 |
+
/* ββ Bottom nav ββ */
|
| 237 |
+
#bottom-nav {
|
| 238 |
+
height: var(--nav-h);
|
| 239 |
+
background: var(--white);
|
| 240 |
+
border-top: 1px solid var(--border);
|
| 241 |
+
display: flex;
|
| 242 |
+
align-items: stretch;
|
| 243 |
+
flex-shrink: 0;
|
| 244 |
+
z-index: 500;
|
| 245 |
+
}
|
| 246 |
+
.nav-btn {
|
| 247 |
+
flex: 1;
|
| 248 |
+
display: flex;
|
| 249 |
+
flex-direction: column;
|
| 250 |
+
align-items: center;
|
| 251 |
+
justify-content: center;
|
| 252 |
+
gap: 3px;
|
| 253 |
+
background: transparent;
|
| 254 |
+
border: none;
|
| 255 |
+
border-top: 2.5px solid transparent;
|
| 256 |
+
color: var(--text-muted);
|
| 257 |
+
font-size: 11px;
|
| 258 |
+
font-weight: 500;
|
| 259 |
+
cursor: pointer;
|
| 260 |
+
font-family: inherit;
|
| 261 |
+
transition: all .15s;
|
| 262 |
+
padding-bottom: 4px;
|
| 263 |
+
}
|
| 264 |
+
.nav-btn.active {
|
| 265 |
+
color: var(--green);
|
| 266 |
+
border-top-color: var(--green);
|
| 267 |
+
font-weight: 700;
|
| 268 |
+
}
|
| 269 |
+
.nav-icon { font-size: 20px; line-height: 1; }
|
| 270 |
+
|
| 271 |
+
/* ββ Register & Sightings screens (placeholder) ββ */
|
| 272 |
+
.screen-placeholder {
|
| 273 |
+
flex: 1;
|
| 274 |
+
display: flex;
|
| 275 |
+
flex-direction: column;
|
| 276 |
+
align-items: center;
|
| 277 |
+
justify-content: center;
|
| 278 |
+
gap: 12px;
|
| 279 |
+
color: var(--text-muted);
|
| 280 |
+
padding: 40px 24px;
|
| 281 |
+
text-align: center;
|
| 282 |
+
}
|
| 283 |
+
.screen-placeholder .ph-icon { font-size: 56px; opacity: .35; }
|
| 284 |
+
.screen-placeholder h2 { font-size: 16px; font-weight: 600; color: var(--text); }
|
| 285 |
+
.screen-placeholder p { font-size: 13px; line-height: 1.5; }
|
| 286 |
+
|
| 287 |
+
/* ββ Empty map state ββ */
|
| 288 |
+
#map-empty {
|
| 289 |
+
position: absolute;
|
| 290 |
+
top: 50%;
|
| 291 |
+
left: 50%;
|
| 292 |
+
transform: translate(-50%, -50%);
|
| 293 |
+
background: var(--white);
|
| 294 |
+
border-radius: 12px;
|
| 295 |
+
padding: 14px 20px;
|
| 296 |
+
font-size: 13px;
|
| 297 |
+
color: var(--text-muted);
|
| 298 |
+
box-shadow: 0 2px 12px rgba(0,0,0,.1);
|
| 299 |
+
z-index: 500;
|
| 300 |
+
display: none;
|
| 301 |
+
pointer-events: none;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
/* ββ Loading pulse ββ */
|
| 305 |
+
@keyframes pulse { 0%,100%{opacity:.4} 50%{opacity:1} }
|
| 306 |
+
.loading { animation: pulse 1.4s ease-in-out infinite; }
|
| 307 |
+
</style>
|
| 308 |
+
</head>
|
| 309 |
+
<body>
|
| 310 |
+
<div id="app">
|
| 311 |
+
|
| 312 |
+
<!-- ββ HEADER ββ -->
|
| 313 |
+
<header id="header">
|
| 314 |
+
<button class="icon-btn" id="menu-btn" aria-label="Menu">
|
| 315 |
+
<svg viewBox="0 0 24 24"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
| 316 |
+
</button>
|
| 317 |
+
<span class="logo">PawMap</span>
|
| 318 |
+
<button class="icon-btn" id="options-btn" aria-label="OpΓ§Γ΅es">
|
| 319 |
+
<svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="1.5" fill="#fff" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="#fff" stroke="none"/><circle cx="12" cy="19" r="1.5" fill="#fff" stroke="none"/></svg>
|
| 320 |
+
</button>
|
| 321 |
+
</header>
|
| 322 |
+
|
| 323 |
+
<!-- ββ FILTER ROW ββ (only for map screen) -->
|
| 324 |
+
<div id="filter-row">
|
| 325 |
+
<button class="chip active" data-species="all">Todos</button>
|
| 326 |
+
<button class="chip" data-species="dog">π CΓ£es</button>
|
| 327 |
+
<button class="chip" data-species="cat">π Gatos</button>
|
| 328 |
+
<button class="chip" data-timeframe="today">Hoje</button>
|
| 329 |
+
<button class="chip" data-timeframe="week">Esta Semana</button>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<!-- ββ MAP SCREEN ββ -->
|
| 333 |
+
<div id="screen-map" class="screen active">
|
| 334 |
+
<div id="map"></div>
|
| 335 |
+
<div id="map-empty">Nenhum avistamento ainda πΎ</div>
|
| 336 |
+
|
| 337 |
+
<!-- Floating card -->
|
| 338 |
+
<div id="sighting-card" class="hidden">
|
| 339 |
+
<div class="card-photo" id="card-photo">πΎ</div>
|
| 340 |
+
<div class="card-info">
|
| 341 |
+
<div class="card-top">
|
| 342 |
+
<span class="badge" id="card-badge">CΓ£o</span>
|
| 343 |
+
<span class="card-time" id="card-time"></span>
|
| 344 |
+
</div>
|
| 345 |
+
<div class="card-location" id="card-location">Avistamento registrado</div>
|
| 346 |
+
<div class="card-sub" id="card-sub"></div>
|
| 347 |
+
</div>
|
| 348 |
+
<button class="btn-ficha" id="card-btn">Ver ficha</button>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
|
| 352 |
+
<!-- ββ REGISTER SCREEN ββ -->
|
| 353 |
+
<div id="screen-register" class="screen">
|
| 354 |
+
<div class="screen-placeholder">
|
| 355 |
+
<div class="ph-icon">π·</div>
|
| 356 |
+
<h2>Registrar Avistamento</h2>
|
| 357 |
+
<p>Tire uma foto do animal para que a IA identifique a espΓ©cie, cor e porte β e salve o avistamento no mapa.</p>
|
| 358 |
+
<p style="margin-top:8px;font-size:12px;opacity:.6;">Em construΓ§Γ£o β prΓ³xima tela</p>
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
|
| 362 |
+
<!-- ββ SIGHTINGS SCREEN ββ -->
|
| 363 |
+
<div id="screen-sightings" class="screen">
|
| 364 |
+
<div class="screen-placeholder">
|
| 365 |
+
<div class="ph-icon">ποΈ</div>
|
| 366 |
+
<h2>Animais Avistados</h2>
|
| 367 |
+
<p>Veja todos os animais catalogados na regiΓ£o e acompanhe sua trajetΓ³ria ao longo do tempo.</p>
|
| 368 |
+
<p style="margin-top:8px;font-size:12px;opacity:.6;">Em construΓ§Γ£o β prΓ³xima tela</p>
|
| 369 |
+
</div>
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
<!-- ββ BOTTOM NAV ββ -->
|
| 373 |
+
<nav id="bottom-nav">
|
| 374 |
+
<button class="nav-btn active" data-screen="map">
|
| 375 |
+
<span class="nav-icon">πΊοΈ</span>
|
| 376 |
+
<span>Mapa</span>
|
| 377 |
+
</button>
|
| 378 |
+
<button class="nav-btn" data-screen="register">
|
| 379 |
+
<span class="nav-icon">π·</span>
|
| 380 |
+
<span>Registrar</span>
|
| 381 |
+
</button>
|
| 382 |
+
<button class="nav-btn" data-screen="sightings">
|
| 383 |
+
<span class="nav-icon">ποΈ</span>
|
| 384 |
+
<span>Avistados</span>
|
| 385 |
+
</button>
|
| 386 |
+
</nav>
|
| 387 |
+
|
| 388 |
+
</div><!-- #app -->
|
| 389 |
+
|
| 390 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
| 391 |
+
<script>
|
| 392 |
+
(function () {
|
| 393 |
+
'use strict';
|
| 394 |
+
|
| 395 |
+
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 396 |
+
let currentSpecies = 'all';
|
| 397 |
+
let currentTimeframe = 'all';
|
| 398 |
+
let map, markersLayer;
|
| 399 |
+
let mapData = [];
|
| 400 |
+
let activeAnimal = null;
|
| 401 |
+
|
| 402 |
+
// ββ Navigation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 403 |
+
const filterRow = document.getElementById('filter-row');
|
| 404 |
+
|
| 405 |
+
function showScreen(name) {
|
| 406 |
+
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
| 407 |
+
document.getElementById('screen-' + name).classList.add('active');
|
| 408 |
+
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
| 409 |
+
document.querySelector(`.nav-btn[data-screen="${name}"]`).classList.add('active');
|
| 410 |
+
|
| 411 |
+
// Show/hide filter row only on map
|
| 412 |
+
filterRow.style.display = name === 'map' ? 'flex' : 'none';
|
| 413 |
+
|
| 414 |
+
// Leaflet needs a size refresh after being hidden
|
| 415 |
+
if (name === 'map' && map) setTimeout(() => map.invalidateSize(), 60);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
document.querySelectorAll('.nav-btn').forEach(btn => {
|
| 419 |
+
btn.addEventListener('click', () => showScreen(btn.dataset.screen));
|
| 420 |
+
});
|
| 421 |
+
|
| 422 |
+
// ββ Map init βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 423 |
+
function initMap() {
|
| 424 |
+
map = L.map('map', {
|
| 425 |
+
zoomControl: false,
|
| 426 |
+
attributionControl: true,
|
| 427 |
+
}).setView([-23.0316, -46.9785], 13);
|
| 428 |
+
|
| 429 |
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
| 430 |
+
attribution: 'Β© <a href="https://openstreetmap.org">OSM</a>',
|
| 431 |
+
maxZoom: 19,
|
| 432 |
+
}).addTo(map);
|
| 433 |
+
|
| 434 |
+
markersLayer = L.layerGroup().addTo(map);
|
| 435 |
+
|
| 436 |
+
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
| 437 |
+
|
| 438 |
+
// Close card on map click
|
| 439 |
+
map.on('click', () => hideCard());
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
// ββ Marker builder βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 443 |
+
function makeIcon(animal) {
|
| 444 |
+
const isDog = animal.species === 'dog';
|
| 445 |
+
const urgent = animal.days_since > 30;
|
| 446 |
+
const color = urgent ? '#E53935' : isDog ? '#388C59' : '#FB8C00';
|
| 447 |
+
const countBadge = animal.count > 1
|
| 448 |
+
? `<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;">${animal.count}</span>`
|
| 449 |
+
: '';
|
| 450 |
+
|
| 451 |
+
return L.divIcon({
|
| 452 |
+
html: `<div style="position:relative;background:${color};width:42px;height:42px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:20px;box-shadow:0 3px 10px rgba(0,0,0,.25);border:2.5px solid #fff;">${isDog ? 'π' : 'π'}${countBadge}</div>`,
|
| 453 |
+
className: '',
|
| 454 |
+
iconSize: [42, 42],
|
| 455 |
+
iconAnchor: [21, 21],
|
| 456 |
+
popupAnchor: [0, -26],
|
| 457 |
+
});
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
// ββ Render markers βββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½βββββββββββββββββββββββββ
|
| 461 |
+
function renderMarkers(data) {
|
| 462 |
+
markersLayer.clearLayers();
|
| 463 |
+
const emptyEl = document.getElementById('map-empty');
|
| 464 |
+
|
| 465 |
+
const withCoords = data.filter(a => a.lat && a.lng);
|
| 466 |
+
|
| 467 |
+
if (!withCoords.length) {
|
| 468 |
+
emptyEl.style.display = 'block';
|
| 469 |
+
hideCard();
|
| 470 |
+
return;
|
| 471 |
+
}
|
| 472 |
+
emptyEl.style.display = 'none';
|
| 473 |
+
|
| 474 |
+
withCoords.forEach(animal => {
|
| 475 |
+
const marker = L.marker([animal.lat, animal.lng], { icon: makeIcon(animal) });
|
| 476 |
+
marker.on('click', e => {
|
| 477 |
+
e.originalEvent.stopPropagation();
|
| 478 |
+
showCard(animal);
|
| 479 |
+
});
|
| 480 |
+
markersLayer.addLayer(marker);
|
| 481 |
+
});
|
| 482 |
+
|
| 483 |
+
// Show most recent in card
|
| 484 |
+
showCard(withCoords[0]);
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
// ββ Floating card ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 488 |
+
function showCard(animal) {
|
| 489 |
+
activeAnimal = animal;
|
| 490 |
+
const card = document.getElementById('sighting-card');
|
| 491 |
+
const isDog = animal.species === 'dog';
|
| 492 |
+
const urgent = animal.days_since > 30;
|
| 493 |
+
|
| 494 |
+
// Photo
|
| 495 |
+
const photoEl = document.getElementById('card-photo');
|
| 496 |
+
if (animal.photo_url) {
|
| 497 |
+
photoEl.innerHTML = `<img src="${animal.photo_url}" alt="foto" style="width:52px;height:52px;border-radius:50%;object-fit:cover;" onerror="this.outerHTML='<span style=font-size:26px>${isDog ? 'π' : 'π'}</span>'">`;
|
| 498 |
+
} else {
|
| 499 |
+
photoEl.innerHTML = `<span style="font-size:26px;">${isDog ? 'π' : 'π'}</span>`;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
// Badge
|
| 503 |
+
const badgeEl = document.getElementById('card-badge');
|
| 504 |
+
badgeEl.textContent = isDog ? 'CΓ£o' : 'Gato';
|
| 505 |
+
badgeEl.className = 'badge' + (urgent ? ' urgent' : (!isDog ? ' orange' : ''));
|
| 506 |
+
|
| 507 |
+
// Time
|
| 508 |
+
const timeEl = document.getElementById('card-time');
|
| 509 |
+
timeEl.textContent = animal.days_since === 0
|
| 510 |
+
? 'Hoje'
|
| 511 |
+
: animal.days_since === 1
|
| 512 |
+
? 'Ontem'
|
| 513 |
+
: `${animal.days_since}d atrΓ‘s`;
|
| 514 |
+
|
| 515 |
+
// Location / desc
|
| 516 |
+
const locEl = document.getElementById('card-location');
|
| 517 |
+
locEl.textContent = animal.desc || (isDog ? 'CΓ£o avistado na Γ‘rea' : 'Gato avistado na Γ‘rea');
|
| 518 |
+
|
| 519 |
+
const subEl = document.getElementById('card-sub');
|
| 520 |
+
subEl.textContent = `${animal.count} avistamento${animal.count !== 1 ? 's' : ''} Β· ΓΊltimo: ${animal.last_seen}`;
|
| 521 |
+
|
| 522 |
+
card.classList.remove('hidden');
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
function hideCard() {
|
| 526 |
+
document.getElementById('sighting-card').classList.add('hidden');
|
| 527 |
+
activeAnimal = null;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
document.getElementById('card-btn').addEventListener('click', () => {
|
| 531 |
+
if (activeAnimal) {
|
| 532 |
+
// TODO: navigate to animal profile screen
|
| 533 |
+
alert(`Animal #${activeAnimal.id} β tela de perfil em breve!`);
|
| 534 |
+
}
|
| 535 |
+
});
|
| 536 |
+
|
| 537 |
+
// ββ Load data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 538 |
+
async function loadMapData() {
|
| 539 |
+
try {
|
| 540 |
+
const url = `/api/map-data?species=${currentSpecies}&timeframe=${currentTimeframe}`;
|
| 541 |
+
const data = await fetch(url).then(r => r.json());
|
| 542 |
+
mapData = data;
|
| 543 |
+
renderMarkers(data);
|
| 544 |
+
} catch (err) {
|
| 545 |
+
console.error('Erro ao carregar mapa:', err);
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
// ββ Filter chips βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 550 |
+
document.querySelectorAll('.chip[data-species]').forEach(btn => {
|
| 551 |
+
btn.addEventListener('click', () => {
|
| 552 |
+
document.querySelectorAll('.chip[data-species]').forEach(b => b.classList.remove('active'));
|
| 553 |
+
btn.classList.add('active');
|
| 554 |
+
currentSpecies = btn.dataset.species;
|
| 555 |
+
loadMapData();
|
| 556 |
+
});
|
| 557 |
+
});
|
| 558 |
+
|
| 559 |
+
document.querySelectorAll('.chip[data-timeframe]').forEach(btn => {
|
| 560 |
+
btn.addEventListener('click', () => {
|
| 561 |
+
const isActive = btn.classList.contains('active');
|
| 562 |
+
document.querySelectorAll('.chip[data-timeframe]').forEach(b => b.classList.remove('active'));
|
| 563 |
+
if (!isActive) {
|
| 564 |
+
btn.classList.add('active');
|
| 565 |
+
currentTimeframe = btn.dataset.timeframe;
|
| 566 |
+
} else {
|
| 567 |
+
currentTimeframe = 'all';
|
| 568 |
+
}
|
| 569 |
+
loadMapData();
|
| 570 |
+
});
|
| 571 |
+
});
|
| 572 |
+
|
| 573 |
+
// ββ Boot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 574 |
+
initMap();
|
| 575 |
+
loadMapData();
|
| 576 |
+
})();
|
| 577 |
+
</script>
|
| 578 |
+
</body>
|
| 579 |
+
</html>
|
requirements.txt
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
-
gradio=
|
| 2 |
-
|
| 3 |
-
huggingface_hub==0.23.4
|
| 4 |
-
starlette<0.28.0
|
| 5 |
pillow>=10.0.0
|
| 6 |
sentence-transformers>=2.7.0
|
| 7 |
numpy>=1.26.0
|
| 8 |
openai>=1.0.0
|
|
|
|
|
|
| 1 |
+
gradio>=6.0.0
|
| 2 |
+
huggingface_hub>=0.23.4
|
|
|
|
|
|
|
| 3 |
pillow>=10.0.0
|
| 4 |
sentence-transformers>=2.7.0
|
| 5 |
numpy>=1.26.0
|
| 6 |
openai>=1.0.0
|
| 7 |
+
httpx[socks]
|