Sarolanda commited on
Commit
e1c6655
Β·
1 Parent(s): 32bbd17

add new ui

Browse files
Files changed (3) hide show
  1. app.py +81 -285
  2. index.html +579 -0
  3. requirements.txt +3 -4
app.py CHANGED
@@ -1,21 +1,21 @@
1
  """
2
- app.py β€” Animal Visto
3
  Build Small Hackathon Β· Backyard AI Track Β· Junho 2026
 
4
  """
5
  import json
6
  import logging
7
  import os
 
8
 
9
- import gradio_client.utils as _gcu
10
- _orig = _gcu._json_schema_to_python_type
11
- def _safe(schema, defs=None):
12
- if not isinstance(schema, dict): return "Any"
13
- return _orig(schema, defs)
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
- C_GREEN = "#388C59"
27
- C_GREEN_L = "#D9EBD9"
28
- C_TEXT = "#212121"
29
- C_RED = "#E53935"
30
- C_ORANGE = "#FB8C00"
31
 
32
- HEAD_HTML = """
33
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous">
34
- <script>
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
- function patchCameraInput() {
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
- CSS = """
106
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
107
- * { box-sizing: border-box; font-family: 'Inter', sans-serif; }
108
- .gradio-container { max-width: 480px !important; margin: 0 auto !important; padding: 0 !important; background: #f8f9fa !important; min-height: 100dvh; }
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
- def build_map_html(species="all", timeframe="all"):
153
- import base64 as _b64
 
 
 
154
  data = db.get_map_data(species, timeframe)
155
- # Base64 evita conflito de aspas e escaping no srcdoc
156
- data_b64 = _b64.b64encode(json.dumps(data, ensure_ascii=False).encode()).decode()
157
- # Leaflet via cdnjs (mais estΓ‘vel que unpkg no HuggingFace)
158
- inner = (
159
- '<!DOCTYPE html><html><head>'
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('&', '&amp;').replace('"', '&quot;')
191
- return f'<iframe srcdoc="{srcdoc}" style="width:100%;height:500px;border:none;"></iframe>'
192
 
193
 
194
- def build_animals_html():
 
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
- try:
201
- desc = json.loads(a.get("description") or "{}")
202
- except Exception:
203
- desc = {}
204
- is_dog = a["species"] == "dog"
205
- em = "πŸ•" if is_dog else "🐈"
206
- sp_pt = "Cao" if is_dog else "Gato"
207
- urgent = a.get("days_since", 0) > 30
208
- color = C_RED if urgent else C_GREEN
209
- badge_cls = "animal-badge urgent" if urgent else "animal-badge"
210
- breed = desc.get("breed_estimate", "raca desconhecida")
211
- color_coat = desc.get("primary_color", "")
212
- meta = f"{breed}{' Β· ' + color_coat if color_coat else ''}"
213
- count = a["sighting_count"]
214
- last_seen = a.get("last_seen_short", "")
215
- photo_url = a.get("last_photo_url") or ""
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
- def build_confirmation_html(animal_id, is_new, count, species, photo_url=""):
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
- progress(0.40, desc="Identificando com Nemotron...")
263
- description = ai.analyze_image(image)
264
  embedding = ai.get_embedding(description)
265
- progress(0.70, desc="Verificando avistamentos anteriores...")
266
- candidates = db.get_all_animals_with_embeddings()
267
- match = matcher.find_match(embedding, candidates)
268
  if match:
269
  animal_id, _score = match
270
- photo_path = db.save_photo(image, animal_id=animal_id)
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 = db.create_animal(description, embedding)
280
- photo_path = db.save_photo(image, animal_id=animal_id)
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
- with gr.Tabs(elem_classes="tabs"):
298
-
299
- with gr.Tab("πŸ—ΊοΈ Mapa"):
300
- with gr.Row(elem_classes="filter-row"):
301
- btn_all = gr.Button("Todos", elem_classes="filter-row")
302
- btn_dogs = gr.Button("πŸ• Caes", elem_classes="filter-row")
303
- btn_cats = gr.Button("🐈 Gatos", elem_classes="filter-row")
304
- btn_today = gr.Button("Hoje", elem_classes="filter-row secondary")
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
- import gradio.blocks as _gb_mod
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
- demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), show_error=True, show_api=False, allowed_paths=[str(DATA_DIR)])
 
 
 
 
 
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==4.44.0
2
- gradio_client==1.3.0
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]