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

add new ui

Browse files
Files changed (3) hide show
  1. app.py +124 -24
  2. core/matcher.py +24 -0
  3. index.html +879 -329
app.py CHANGED
@@ -6,6 +6,9 @@ Custom frontend via gradio.Server
6
  import json
7
  import logging
8
  import os
 
 
 
9
  from pathlib import Path
10
 
11
  from gradio import Server
@@ -23,6 +26,9 @@ db = Database()
23
  ai = AnimalAI()
24
  matcher = AnimalMatcher()
25
 
 
 
 
26
  app = Server()
27
 
28
  # Serve photos as static files at /photos/...
@@ -38,7 +44,7 @@ async def homepage():
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(
@@ -75,58 +81,152 @@ async def get_animal(animal_id: int):
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)
109
  count = animal["sighting_count"]
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__":
 
6
  import json
7
  import logging
8
  import os
9
+ import tempfile
10
+ import time
11
+ import uuid
12
  from pathlib import Path
13
 
14
  from gradio import Server
 
26
  ai = AnimalAI()
27
  matcher = AnimalMatcher()
28
 
29
+ # In-memory session store for analyze β†’ confirm two-step flow
30
+ _pending: dict[str, dict] = {}
31
+
32
  app = Server()
33
 
34
  # Serve photos as static files at /photos/...
 
44
  return html_path.read_text(encoding="utf-8")
45
 
46
 
47
+ # ─── Data APIs (FastAPI routes, no queuing needed) ────────────────────────────
48
 
49
  @app.get("/api/map-data")
50
  async def get_map_data(
 
81
  return JSONResponse(content=detail)
82
 
83
 
84
+ # ─── ML APIs (queued via Gradio) ──────────────────────────────────────────────
85
 
86
+ @app.api(name="analyze_image")
87
+ def analyze_image(image_path: FileData) -> dict:
88
+ """
89
+ Step 1: Analyze photo with AI, find similar animals.
90
+ Returns session_id + AI description + top matches (no DB write yet).
91
+ """
92
  from PIL import Image as PILImage
93
 
94
  img = PILImage.open(image_path["path"]).convert("RGB")
95
 
96
+ description = ai.analyze_image(img)
97
+ embedding = ai.get_embedding(description)
98
+ candidates = db.get_all_animals_with_embeddings()
99
+ top_matches = matcher.find_top_matches(embedding, candidates, top_n=3)
100
+
101
+ # Enrich matches with photo URLs and sighting info
102
+ similar = []
103
+ for m in top_matches:
104
+ sightings = db.get_animal_sightings(m["id"])
105
+ photo_path = next(
106
+ (s["photo_path"] for s in sightings if s.get("photo_path")), None
107
+ )
108
+ latest = sightings[0] if sightings else {}
109
+ similar.append({
110
+ "id": m["id"],
111
+ "score_pct": round(m["score"] * 100),
112
+ "photo_url": f"/photos/{photo_path}" if photo_path else "",
113
+ "days_ago": latest.get("days_ago", ""),
114
+ })
115
+
116
+ # Save image to temp file for the confirm step
117
+ tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, dir=DATA_DIR)
118
+ img.save(tmp.name, format="JPEG", quality=85)
119
+ tmp.close()
120
+
121
+ session_id = uuid.uuid4().hex
122
+ _pending[session_id] = {
123
+ "temp_path": tmp.name,
124
+ "description": description,
125
+ "embedding": embedding,
126
+ "timestamp": time.time(),
127
+ }
128
+ _cleanup_sessions()
129
+
130
+ return {
131
+ "session_id": session_id,
132
+ "description": description,
133
+ "similar": similar,
134
+ }
135
+
136
+
137
+ @app.api(name="confirm_sighting")
138
+ def confirm_sighting(
139
+ session_id: str,
140
+ gps_json: str = "",
141
+ notes: str = "",
142
+ condition: str = "",
143
+ ) -> dict:
144
+ """
145
+ Step 2: User reviewed/edited the AI results β†’ save sighting to DB.
146
+ """
147
+ import datetime
148
+ from PIL import Image as PILImage
149
+
150
+ session = _pending.pop(session_id, None)
151
+ if not session:
152
+ return {"error": "SessΓ£o expirada. Tire a foto novamente."}
153
+
154
+ img = PILImage.open(session["temp_path"]).convert("RGB")
155
+ description = session["description"]
156
+ embedding = session["embedding"]
157
+
158
+ # Clean up temp file
159
+ try:
160
+ os.unlink(session["temp_path"])
161
+ except Exception:
162
+ pass
163
+
164
+ # Parse GPS
165
  try:
166
  coords = json.loads(gps_json) if gps_json and gps_json.strip() else {}
167
  except Exception:
168
  coords = {}
 
169
  lat = round(float(coords["lat"]), 5) if coords.get("lat") else None
170
  lng = round(float(coords["lng"]), 5) if coords.get("lng") else None
171
 
172
+ # Append condition to notes
173
+ full_notes = notes
174
+ if condition:
175
+ full_notes = (notes + f" [CondiΓ§Γ£o: {condition}]").strip()
176
+
177
+ candidates = db.get_all_animals_with_embeddings()
178
+ match = matcher.find_match(embedding, candidates)
179
 
180
  if match:
181
+ animal_id, _ = match
182
+ photo_path = db.save_photo(img, animal_id=animal_id)
183
+ db.add_sighting(animal_id, photo_path, lat, lng, full_notes)
184
  db.update_animal(animal_id)
185
  animal = db.get_animal(animal_id)
186
  count = animal["sighting_count"]
187
  species = animal["species"]
188
+ desc_obj = json.loads(animal.get("description") or "{}")
189
  is_new = False
190
  else:
191
  animal_id = db.create_animal(description, embedding)
192
  photo_path = db.save_photo(img, animal_id=animal_id)
193
+ db.add_sighting(animal_id, photo_path, lat, lng, full_notes)
194
+ count = 1
195
+ species = description.get("species", "dog")
196
+ desc_obj = description
197
+ is_new = True
198
+
199
+ breed = desc_obj.get("breed_estimate", "")
200
+ color = desc_obj.get("primary_color", "")
201
+ name = " ".join(filter(None, [
202
+ "CΓ£o" if species == "dog" else "Gato",
203
+ color.capitalize() if color else "",
204
+ breed if breed and breed.lower() not in ("srd", "unknown", "") else "",
205
+ ])).strip() or ("CΓ£o" if species == "dog" else "Gato")
206
 
207
  return {
208
  "animal_id": animal_id,
209
+ "is_new": is_new,
210
+ "count": count,
211
+ "species": species,
212
+ "name": name,
213
  "photo_url": f"/photos/{photo_path}" if photo_path else "",
214
+ "location": f"Lat {lat:.4f}, Lng {lng:.4f}" if lat and lng else "LocalizaΓ§Γ£o nΓ£o registrada",
215
+ "time": datetime.datetime.now().strftime("%H:%M"),
216
  }
217
 
218
 
219
+ def _cleanup_sessions():
220
+ cutoff = time.time() - 1800 # 30 min
221
+ for k in list(_pending.keys()):
222
+ if _pending[k]["timestamp"] < cutoff:
223
+ try:
224
+ os.unlink(_pending[k]["temp_path"])
225
+ except Exception:
226
+ pass
227
+ _pending.pop(k, None)
228
+
229
+
230
  # ─── Launch ───────────────────────────────────────────────────────────────────
231
 
232
  if __name__ == "__main__":
core/matcher.py CHANGED
@@ -45,6 +45,30 @@ class AnimalMatcher:
45
  return best_id, best_score
46
  return None
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  @staticmethod
49
  def _cosine(a: np.ndarray, b: np.ndarray) -> float:
50
  norm_a = np.linalg.norm(a)
 
45
  return best_id, best_score
46
  return None
47
 
48
+ def find_top_matches(
49
+ self,
50
+ new_embedding: list,
51
+ candidates: list[dict],
52
+ top_n: int = 3,
53
+ ) -> list[dict]:
54
+ """
55
+ Retorna os top_n animais mais similares (sem threshold mΓ­nimo),
56
+ ordenados por score decrescente.
57
+ Cada item: {'id': int, 'score': float}
58
+ """
59
+ if not candidates or not new_embedding:
60
+ return []
61
+ new_vec = np.array(new_embedding, dtype=np.float32)
62
+ scores = []
63
+ for animal in candidates:
64
+ emb = animal.get("embedding")
65
+ if not emb:
66
+ continue
67
+ score = self._cosine(new_vec, np.array(emb, dtype=np.float32))
68
+ scores.append({"id": animal["id"], "score": score})
69
+ scores.sort(key=lambda x: x["score"], reverse=True)
70
+ return scores[:top_n]
71
+
72
  @staticmethod
73
  def _cosine(a: np.ndarray, b: np.ndarray) -> float:
74
  norm_a = np.linalg.norm(a)
index.html CHANGED
@@ -18,9 +18,9 @@
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;
@@ -30,7 +30,7 @@
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;
@@ -45,10 +45,11 @@
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);
@@ -59,25 +60,15 @@
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; }
@@ -85,242 +76,414 @@
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>
@@ -329,12 +492,12 @@
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">
@@ -349,17 +512,195 @@
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>
@@ -372,16 +713,13 @@
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
 
@@ -396,22 +734,37 @@
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
 
@@ -419,107 +772,58 @@
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() {
@@ -528,25 +832,16 @@
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'));
@@ -558,19 +853,274 @@
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
  })();
 
18
  --orange: #FB8C00;
19
  --red: #E53935;
20
  --text: #1A1A1A;
21
+ --text-muted: #777;
22
  --border: #EBEBEB;
23
+ --bg: #F5F5F5;
24
  --white: #FFFFFF;
25
  --nav-h: 62px;
26
  --header-h: 54px;
 
30
  html, body {
31
  height: 100%;
32
  font-family: 'Inter', system-ui, sans-serif;
33
+ background: #e0e0e0;
34
  color: var(--text);
35
  overflow: hidden;
36
  -webkit-tap-highlight-color: transparent;
 
45
  margin: 0 auto;
46
  position: relative;
47
  background: var(--white);
48
+ box-shadow: 0 0 40px rgba(0,0,0,.15);
49
+ overflow: hidden;
50
  }
51
 
52
+ /* ── Main header ── */
53
  #header {
54
  height: var(--header-h);
55
  background: var(--green);
 
60
  padding: 0 16px;
61
  flex-shrink: 0;
62
  z-index: 200;
63
+ transition: height .2s;
64
  }
65
+ #header.hidden { display: none; }
66
+ #header .logo { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
 
 
 
67
  .icon-btn {
68
+ background: transparent; border: none; color: #fff; cursor: pointer;
69
+ width: 36px; height: 36px; border-radius: 50%;
70
+ display: flex; align-items: center; justify-content: center;
71
+ font-size: 18px; transition: background .15s;
 
 
 
 
 
 
 
 
72
  }
73
  .icon-btn:hover { background: rgba(255,255,255,.15); }
74
  .icon-btn svg { width: 20px; height: 20px; fill: none; stroke: #fff; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
 
76
  /* ── Filter chips ── */
77
  #filter-row {
78
  height: var(--filter-h);
79
+ display: flex; align-items: center; gap: 6px; padding: 0 14px;
80
+ overflow-x: auto; scrollbar-width: none;
81
+ background: var(--white); border-bottom: 1px solid var(--border);
82
+ flex-shrink: 0; z-index: 100;
 
 
 
 
 
 
83
  }
84
  #filter-row::-webkit-scrollbar { display: none; }
 
85
  .chip {
86
+ border-radius: 20px; border: 1.5px solid var(--border);
87
+ background: var(--white); color: var(--text-muted);
88
+ font-size: 13px; font-weight: 500; padding: 5px 14px;
89
+ white-space: nowrap; cursor: pointer; font-family: inherit;
90
+ transition: all .15s; flex-shrink: 0;
 
 
 
 
 
 
 
91
  }
92
  .chip:hover { border-color: var(--green); color: var(--green); }
93
+ .chip.active { background: var(--green); border-color: var(--green); color: #fff; font-weight: 600; }
 
 
 
 
 
94
 
95
  /* ── Screen system ── */
96
+ .screen { display: none; flex: 1; flex-direction: column; overflow: hidden; position: relative; }
 
 
 
 
 
 
97
  .screen.active { display: flex; }
98
 
99
+ /* ── Bottom nav ── */
100
+ #bottom-nav {
101
+ height: var(--nav-h); background: var(--white);
102
+ border-top: 1px solid var(--border);
103
+ display: flex; align-items: stretch; flex-shrink: 0; z-index: 500;
 
 
 
 
104
  }
105
+ #bottom-nav.hidden { display: none; }
106
+ .nav-btn {
107
+ flex: 1; display: flex; flex-direction: column; align-items: center;
108
+ justify-content: center; gap: 3px; background: transparent; border: none;
109
+ border-top: 2.5px solid transparent; color: var(--text-muted);
110
+ font-size: 11px; font-weight: 500; cursor: pointer; font-family: inherit;
111
+ transition: all .15s; padding-bottom: 4px;
112
  }
113
+ .nav-btn.active { color: var(--green); border-top-color: var(--green); font-weight: 700; }
114
+ .nav-icon { font-size: 20px; line-height: 1; }
115
+
116
+ /* ════════════════════════════════════
117
+ MAP SCREEN
118
+ ════════════════════════════════════ */
119
+ #map { flex: 1; width: 100%; z-index: 1; }
120
+ .leaflet-control-zoom { border: none !important; box-shadow: 0 2px 12px rgba(0,0,0,.15) !important; }
121
+ .leaflet-control-zoom a { border-radius: 8px !important; border: none !important; font-weight: 600 !important; color: var(--text) !important; }
122
 
 
123
  #sighting-card {
124
+ position: absolute; bottom: 14px; left: 14px; right: 14px;
125
+ background: var(--white); border-radius: 16px; padding: 14px 16px;
126
+ box-shadow: 0 4px 24px rgba(0,0,0,.14); z-index: 400;
127
+ display: flex; align-items: center; gap: 12px;
 
 
 
 
 
 
 
 
128
  transition: opacity .2s, transform .2s;
129
  }
130
+ #sighting-card.hidden { opacity: 0; pointer-events: none; transform: translateY(8px); }
 
 
 
 
131
  #sighting-card .card-photo {
132
+ width: 52px; height: 52px; border-radius: 50%; object-fit: cover;
133
+ flex-shrink: 0; background: var(--bg);
134
+ display: flex; align-items: center; justify-content: center;
135
+ font-size: 24px; overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
136
  }
137
+ #sighting-card .card-photo img { width: 100%; height: 100%; object-fit: cover; }
138
  #sighting-card .card-info { flex: 1; min-width: 0; }
139
+ #sighting-card .card-top { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
140
+ .badge { background: var(--green-pale); color: var(--green); border-radius: 20px; padding: 2px 10px; font-size: 11px; font-weight: 700; }
 
 
 
 
 
 
 
 
 
 
 
 
141
  .badge.urgent { background: #ffebee; color: var(--red); }
142
  .badge.orange { background: #fff3e0; color: var(--orange); }
143
  #sighting-card .card-time { font-size: 11px; color: var(--text-muted); }
144
+ #sighting-card .card-location { font-weight: 600; font-size: 13px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
145
+ #sighting-card .card-sub { font-size: 12px; color: var(--text-muted); margin-top: 1px; }
146
+ .btn-ficha {
147
+ background: var(--green); color: #fff; border: none; border-radius: 10px;
148
+ padding: 8px 14px; font-size: 12px; font-weight: 600; white-space: nowrap;
149
+ cursor: pointer; font-family: inherit; flex-shrink: 0; transition: background .15s;
 
150
  }
151
+ .btn-ficha:hover { background: var(--green-dark); }
152
+ #map-empty {
153
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
154
+ background: var(--white); border-radius: 12px; padding: 14px 20px;
155
+ font-size: 13px; color: var(--text-muted); box-shadow: 0 2px 12px rgba(0,0,0,.1);
156
+ z-index: 500; display: none; pointer-events: none;
157
  }
158
+
159
+ /* ════════════════════════════════════
160
+ REGISTER SCREEN
161
+ ════════════════════════════════════ */
162
+ #screen-register { background: #111; }
163
+
164
+ #viewfinder {
165
+ flex: 1; position: relative; background: #111; overflow: hidden;
166
+ display: flex; align-items: center; justify-content: center;
167
+ min-height: 0;
168
+ }
169
+ #photo-preview {
170
+ width: 100%; height: 100%; object-fit: cover;
171
+ display: none;
172
+ }
173
+ #camera-placeholder {
174
+ display: flex; flex-direction: column; align-items: center;
175
+ justify-content: center; gap: 12px; color: rgba(255,255,255,.35);
176
+ }
177
+ #camera-placeholder svg { width: 56px; height: 56px; stroke: rgba(255,255,255,.3); fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
178
+ #camera-placeholder p { font-size: 14px; }
179
+
180
+ /* GPS pill overlay */
181
+ #gps-pill {
182
+ position: absolute; top: 14px; left: 16px; right: 16px;
183
+ background: rgba(255,255,255,.92); backdrop-filter: blur(8px);
184
+ border-radius: 12px; padding: 10px 14px;
185
+ display: flex; align-items: center; gap: 10px;
186
+ font-size: 13px; font-weight: 500; color: var(--text);
187
+ box-shadow: 0 2px 12px rgba(0,0,0,.15); cursor: pointer;
188
  transition: background .15s;
189
  }
190
+ #gps-pill:hover { background: rgba(255,255,255,.98); }
191
+ #gps-pill svg { width: 16px; height: 16px; flex-shrink: 0; stroke: var(--green); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
192
+ #gps-text { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted); }
193
+ #gps-text.located { color: var(--text); font-weight: 600; }
194
 
195
+ /* Shutter button */
196
+ #shutter-btn {
197
+ position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
198
+ width: 72px; height: 72px; border-radius: 50%;
199
+ background: var(--green); border: 4px solid rgba(255,255,255,.9);
200
+ box-shadow: 0 4px 20px rgba(0,0,0,.4);
201
+ display: flex; align-items: center; justify-content: center;
202
+ cursor: pointer; transition: transform .1s, background .15s;
203
+ }
204
+ #shutter-btn:active { transform: translateX(-50%) scale(.93); }
205
+ #shutter-btn svg { width: 28px; height: 28px; fill: none; stroke: #fff; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
206
+ #photo-input { display: none; }
207
+
208
+ /* Bottom action sheet */
209
+ #reg-sheet {
210
+ background: var(--white); border-radius: 20px 20px 0 0;
211
+ padding: 10px 16px 16px; flex-shrink: 0;
212
+ box-shadow: 0 -4px 20px rgba(0,0,0,.12);
213
+ }
214
+ .drag-handle {
215
+ width: 40px; height: 5px; border-radius: 3px;
216
+ background: #DDD; margin: 0 auto 14px;
217
+ }
218
+ #notes-row {
219
+ display: flex; align-items: center; gap: 10px;
220
+ background: var(--bg); border-radius: 10px; padding: 10px 14px;
221
+ margin-bottom: 12px;
222
+ }
223
+ #notes-row svg { width: 16px; height: 16px; flex-shrink: 0; stroke: var(--text-muted); fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
224
+ #notes-input {
225
+ flex: 1; border: none; background: transparent; font-family: inherit;
226
+ font-size: 13px; color: var(--text); resize: none; outline: none; line-height: 1.4;
227
+ max-height: 60px; overflow-y: auto;
228
+ }
229
+ #notes-input::placeholder { color: var(--text-muted); }
230
+
231
+ .btn-secondary {
232
+ width: 100%; padding: 14px; border-radius: 12px; font-size: 15px; font-weight: 600;
233
+ font-family: inherit; cursor: pointer; transition: all .15s; margin-bottom: 10px;
234
+ display: flex; align-items: center; justify-content: center; gap: 8px;
235
+ background: var(--green-pale); color: var(--green); border: 1.5px solid var(--green-pale);
236
+ }
237
+ .btn-secondary:hover { background: var(--green-soft); border-color: var(--green); }
238
+ .btn-secondary svg { width: 18px; height: 18px; fill: none; stroke: var(--green); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
239
+ .btn-secondary.located { background: var(--green); color: #fff; border-color: var(--green); }
240
+ .btn-secondary.located svg { stroke: #fff; }
241
+
242
+ .btn-primary {
243
+ width: 100%; padding: 15px; border-radius: 12px; font-size: 15px; font-weight: 700;
244
+ font-family: inherit; cursor: pointer; transition: all .15s;
245
+ display: flex; align-items: center; justify-content: center; gap: 8px;
246
+ background: var(--green); color: #fff; border: none;
247
+ }
248
+ .btn-primary:hover { background: var(--green-dark); }
249
+ .btn-primary:disabled { background: #ccc; cursor: not-allowed; }
250
+ .btn-primary svg { width: 18px; height: 18px; fill: none; stroke: #fff; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
251
+
252
+ /* ════════════════════════════════════
253
+ ANALYSIS SCREEN
254
+ ════════════════════════════════════ */
255
+ #screen-analysis {
256
+ background: var(--bg);
257
+ overflow-y: auto;
258
+ }
259
+
260
+ .flow-header {
261
+ position: sticky; top: 0; z-index: 100;
262
+ background: var(--white); border-bottom: 1px solid var(--border);
263
+ display: flex; align-items: center; gap: 12px;
264
+ padding: 0 16px; height: var(--header-h); flex-shrink: 0;
265
+ }
266
+ .flow-header .back-btn {
267
+ background: transparent; border: none; cursor: pointer; padding: 8px;
268
+ border-radius: 50%; display: flex; align-items: center; justify-content: center;
269
+ margin-left: -8px; transition: background .15s;
270
+ }
271
+ .flow-header .back-btn:hover { background: var(--bg); }
272
+ .flow-header .back-btn svg { width: 20px; height: 20px; fill: none; stroke: var(--text); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
273
+ .flow-header h2 { font-size: 16px; font-weight: 700; color: var(--text); }
274
+
275
+ /* Photo section */
276
+ #analysis-photo-wrap {
277
+ position: relative; background: #111; flex-shrink: 0;
278
+ }
279
+ #analysis-photo {
280
+ width: 100%; max-height: 340px; object-fit: cover;
281
+ display: block;
282
+ }
283
+ #ai-badge {
284
+ position: absolute; top: 12px; right: 12px;
285
+ background: rgba(0,0,0,.6); backdrop-filter: blur(4px);
286
+ color: #fff; border-radius: 20px; padding: 5px 12px;
287
+ font-size: 12px; font-weight: 600;
288
+ display: flex; align-items: center; gap: 6px;
289
+ }
290
+ #ai-badge .dot {
291
+ width: 8px; height: 8px; border-radius: 50%; background: var(--green);
292
+ animation: pulse-dot 1.2s ease-in-out infinite;
293
+ }
294
+ @keyframes pulse-dot { 0%,100%{opacity:.4;transform:scale(.8)} 50%{opacity:1;transform:scale(1)} }
295
+ #ai-badge.done { background: rgba(56,140,89,.9); }
296
+ #ai-badge.done .dot { animation: none; background: #fff; }
297
+
298
+ #animal-result-badge {
299
+ position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
300
+ background: rgba(56,140,89,.92); backdrop-filter: blur(4px);
301
+ color: #fff; border-radius: 20px; padding: 7px 18px;
302
+ font-size: 13px; font-weight: 700;
303
+ display: flex; align-items: center; gap: 8px;
304
+ white-space: nowrap; opacity: 0; transition: opacity .3s;
305
+ }
306
+ #animal-result-badge.visible { opacity: 1; }
307
+ #animal-result-badge svg { width: 16px; height: 16px; fill: none; stroke: #fff; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
308
+
309
+ /* Details card */
310
+ .analysis-card {
311
+ background: var(--white); border-radius: 16px; margin: 12px 16px;
312
+ padding: 16px; box-shadow: 0 1px 6px rgba(0,0,0,.06);
313
+ }
314
+ .analysis-card h3 {
315
+ font-size: 16px; font-weight: 700; color: var(--text); margin-bottom: 4px;
316
+ display: flex; align-items: center; justify-content: space-between;
317
+ }
318
+ .analysis-card h3 button {
319
+ background: transparent; border: none; cursor: pointer; padding: 4px;
320
+ color: var(--text-muted); font-size: 16px;
321
+ }
322
+ .analysis-card .subtitle {
323
+ font-size: 13px; color: var(--text-muted); margin-bottom: 14px; line-height: 1.4;
324
+ }
325
+
326
+ /* 2x2 dropdown grid */
327
+ .dropdowns-grid {
328
+ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px;
329
+ }
330
+ .dropdown-field label {
331
+ display: block; font-size: 11px; font-weight: 600; color: var(--text-muted);
332
+ text-transform: uppercase; letter-spacing: .4px; margin-bottom: 5px;
333
+ }
334
+ .dropdown-field select {
335
+ width: 100%; padding: 10px 12px; border: 1.5px solid var(--border);
336
+ border-radius: 10px; font-family: inherit; font-size: 13px; font-weight: 500;
337
+ color: var(--text); background: var(--bg); cursor: pointer; outline: none;
338
+ appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23888' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
339
+ background-repeat: no-repeat; background-position: right 12px center;
340
+ padding-right: 32px;
341
+ }
342
+ .dropdown-field select:focus { border-color: var(--green); background-color: var(--white); }
343
+
344
+ /* Condition chips */
345
+ .condition-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 8px; }
346
+ .condition-chips { display: flex; flex-wrap: wrap; gap: 8px; }
347
+ .cond-chip {
348
+ border-radius: 20px; border: 1.5px solid var(--border);
349
+ background: var(--white); color: var(--text); padding: 6px 14px;
350
+ font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit;
351
+ transition: all .15s; display: flex; align-items: center; gap: 5px;
352
+ }
353
+ .cond-chip.active { background: var(--green); border-color: var(--green); color: #fff; }
354
+ .cond-chip.active .check { display: inline; }
355
+ .cond-chip .check { display: none; font-size: 12px; }
356
+
357
+ /* Similar animals */
358
+ .similar-scroll {
359
+ display: flex; gap: 12px; overflow-x: auto; padding: 4px 0 8px;
360
+ scrollbar-width: none;
361
+ }
362
+ .similar-scroll::-webkit-scrollbar { display: none; }
363
+ .similar-card {
364
+ flex-shrink: 0; width: 148px; border-radius: 12px; overflow: hidden;
365
+ border: 1.5px solid var(--border); background: var(--white);
366
+ cursor: pointer; transition: border-color .15s;
367
+ }
368
+ .similar-card:hover { border-color: var(--green); }
369
+ .similar-card.selected { border-color: var(--green); box-shadow: 0 0 0 2px var(--green); }
370
+ .similar-card-img {
371
+ width: 100%; height: 120px; object-fit: cover;
372
+ background: var(--bg); display: flex; align-items: center; justify-content: center;
373
+ font-size: 36px; position: relative;
374
+ }
375
+ .similar-card-img img { width: 100%; height: 100%; object-fit: cover; }
376
+ .match-pct {
377
+ position: absolute; bottom: 6px; left: 6px;
378
+ background: rgba(0,0,0,.65); color: #fff;
379
+ border-radius: 6px; padding: 2px 7px; font-size: 11px; font-weight: 700;
380
+ }
381
+ .similar-card-info { padding: 8px 10px; }
382
+ .similar-card-info .days { font-size: 12px; font-weight: 600; color: var(--text); }
383
+ .similar-card-info .dist { font-size: 11px; color: var(--text-muted); }
384
+
385
+ /* Analysis bottom buttons */
386
+ #analysis-actions {
387
+ padding: 12px 16px 20px; background: var(--white);
388
  border-top: 1px solid var(--border);
389
+ display: flex; flex-direction: column; gap: 10px; flex-shrink: 0;
390
+ position: sticky; bottom: 0; z-index: 50;
 
 
391
  }
392
+ .btn-outline {
393
+ width: 100%; padding: 14px; border-radius: 12px; font-size: 15px; font-weight: 600;
394
+ font-family: inherit; cursor: pointer; transition: all .15s;
395
+ background: transparent; color: var(--text-muted); border: 1.5px solid var(--border);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  }
397
+ .btn-outline:hover { border-color: var(--text-muted); color: var(--text); }
398
 
399
+ /* ════════════════════════════════════
400
+ CONFIRM SCREEN
401
+ ════════════════════════════════════ */
402
+ #screen-confirm {
403
+ background: var(--bg);
404
+ align-items: center; justify-content: center;
405
+ overflow-y: auto;
406
+ }
407
+ #confirm-inner {
408
+ width: 100%; max-width: 360px; padding: 40px 24px 32px;
409
+ display: flex; flex-direction: column; align-items: center; gap: 0;
410
+ }
411
+ #confirm-photo-wrap {
412
+ position: relative; margin-bottom: 20px;
413
+ }
414
+ #confirm-photo {
415
+ width: 160px; height: 160px; border-radius: 50%; object-fit: cover;
416
+ border: 4px solid var(--white); box-shadow: 0 4px 20px rgba(0,0,0,.15);
417
+ background: var(--bg); display: flex; align-items: center; justify-content: center;
418
+ font-size: 64px;
419
+ }
420
+ #confirm-photo img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
421
+ #confirm-check {
422
+ position: absolute; bottom: 4px; right: 4px;
423
+ width: 44px; height: 44px; border-radius: 50%;
424
+ background: var(--green); border: 3px solid var(--white);
425
+ display: flex; align-items: center; justify-content: center;
426
+ }
427
+ #confirm-check svg { width: 20px; height: 20px; fill: none; stroke: #fff; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
428
+
429
+ #confirm-title {
430
+ font-size: 26px; font-weight: 800; color: var(--text);
431
+ text-align: center; line-height: 1.2; margin-bottom: 24px;
432
+ }
433
+ #confirm-card {
434
+ width: 100%; background: var(--white); border-radius: 16px;
435
+ overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,.07); margin-bottom: 24px;
436
+ }
437
+ .confirm-row {
438
+ display: flex; align-items: center; justify-content: space-between;
439
+ padding: 14px 18px; gap: 12px;
440
+ }
441
+ .confirm-row + .confirm-row { border-top: 1px solid var(--border); }
442
+ .confirm-row-label {
443
+ display: flex; align-items: center; gap: 8px;
444
+ font-size: 14px; color: var(--text-muted); font-weight: 500;
445
+ }
446
+ .confirm-row-label svg { width: 16px; height: 16px; fill: none; stroke: var(--text-muted); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
447
+ .confirm-row-value { font-size: 14px; font-weight: 700; color: var(--text); text-align: right; }
448
+ .confirm-btns { width: 100%; display: flex; flex-direction: column; gap: 10px; }
449
+
450
+ /* ════════════════════════════════════
451
+ SIGHTINGS SCREEN (placeholder)
452
+ ════��═══════════════════════════════ */
453
  .screen-placeholder {
454
+ flex: 1; display: flex; flex-direction: column;
455
+ align-items: center; justify-content: center;
456
+ gap: 12px; color: var(--text-muted); padding: 40px 24px; text-align: center;
 
 
 
 
 
 
457
  }
458
  .screen-placeholder .ph-icon { font-size: 56px; opacity: .35; }
459
  .screen-placeholder h2 { font-size: 16px; font-weight: 600; color: var(--text); }
460
  .screen-placeholder p { font-size: 13px; line-height: 1.5; }
461
 
462
+ /* ── Loading / animations ── */
463
+ @keyframes spin { to { transform: rotate(360deg); } }
464
+ .spinner {
465
+ width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3);
466
+ border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite;
 
 
 
 
 
 
 
 
 
 
467
  }
468
+ @keyframes pulse-bg { 0%,100%{opacity:.6} 50%{opacity:1} }
469
+ .pulsing { animation: pulse-bg 1.2s ease-in-out infinite; }
 
 
470
  </style>
471
  </head>
472
  <body>
473
  <div id="app">
474
 
475
+ <!-- ══ MAIN HEADER (map / register / sightings) ══ -->
476
  <header id="header">
477
+ <button class="icon-btn" aria-label="Menu">
478
  <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>
479
  </button>
480
  <span class="logo">PawMap</span>
481
+ <button class="icon-btn" aria-label="OpΓ§Γ΅es">
482
  <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>
483
  </button>
484
  </header>
485
 
486
+ <!-- ══ FILTER ROW (map only) ══ -->
487
  <div id="filter-row">
488
  <button class="chip active" data-species="all">Todos</button>
489
  <button class="chip" data-species="dog">πŸ• CΓ£es</button>
 
492
  <button class="chip" data-timeframe="week">Esta Semana</button>
493
  </div>
494
 
495
+ <!-- ══════════════════════════════════
496
+ SCREEN: MAP
497
+ ══════════════════════════════════ -->
498
  <div id="screen-map" class="screen active">
499
  <div id="map"></div>
500
  <div id="map-empty">Nenhum avistamento ainda 🐾</div>
 
 
501
  <div id="sighting-card" class="hidden">
502
  <div class="card-photo" id="card-photo">🐾</div>
503
  <div class="card-info">
 
512
  </div>
513
  </div>
514
 
515
+ <!-- ══════════════════════════════════
516
+ SCREEN: REGISTER
517
+ ══════════════════════════════════ -->
518
  <div id="screen-register" class="screen">
519
+ <input type="file" id="photo-input" accept="image/*" capture="environment"/>
520
+
521
+ <!-- Viewfinder -->
522
+ <div id="viewfinder">
523
+ <!-- GPS pill -->
524
+ <div id="gps-pill">
525
+ <svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
526
+ <span id="gps-text">Toque para detectar localizaΓ§Γ£o</span>
527
+ </div>
528
+
529
+ <!-- Photo or placeholder -->
530
+ <img id="photo-preview" src="" alt="foto"/>
531
+ <div id="camera-placeholder">
532
+ <svg viewBox="0 0 24 24"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
533
+ <p>Toque no botΓ£o para fotografar</p>
534
+ </div>
535
+
536
+ <!-- Shutter button -->
537
+ <button id="shutter-btn" aria-label="Tirar foto">
538
+ <svg viewBox="0 0 24 24"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
539
+ </button>
540
+ </div>
541
+
542
+ <!-- Bottom sheet -->
543
+ <div id="reg-sheet">
544
+ <div class="drag-handle"></div>
545
+ <div id="notes-row">
546
+ <svg viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
547
+ <textarea id="notes-input" rows="1" placeholder="Adicionar observaΓ§Γ£o (opcional)"></textarea>
548
+ </div>
549
+ <button class="btn-secondary" id="gps-btn">
550
+ <svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
551
+ Registrar LocalizaΓ§Γ£o
552
+ </button>
553
+ <button class="btn-primary" id="submit-reg-btn" disabled>
554
+ <svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
555
+ Registrar Avistamento
556
+ </button>
557
+ </div>
558
+ </div>
559
+
560
+ <!-- ══════════════════════════════════
561
+ SCREEN: ANALYSIS (flow screen)
562
+ ══════════════════════════════════ -->
563
+ <div id="screen-analysis" class="screen">
564
+ <!-- Custom header -->
565
+ <div class="flow-header">
566
+ <button class="back-btn" id="analysis-back">
567
+ <svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
568
+ </button>
569
+ <h2>Analisando Avistamento</h2>
570
+ </div>
571
+
572
+ <!-- Photo -->
573
+ <div id="analysis-photo-wrap">
574
+ <img id="analysis-photo" src="" alt="foto do animal"/>
575
+ <div id="ai-badge">
576
+ <span class="dot"></span>
577
+ <span id="ai-badge-text">IA Analisando...</span>
578
+ </div>
579
+ <div id="animal-result-badge">
580
+ <svg viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg>
581
+ <span id="result-badge-text">CΓ£o Caramelo</span>
582
+ </div>
583
+ </div>
584
+
585
+ <!-- Identified details -->
586
+ <div class="analysis-card">
587
+ <h3>Detalhes Identificados <button id="edit-btn" title="Editar">✏️</button></h3>
588
+ <p class="subtitle">A IA preencheu estes dados. Corrija se necessΓ‘rio.</p>
589
+ <div class="dropdowns-grid">
590
+ <div class="dropdown-field">
591
+ <label>EspΓ©cie</label>
592
+ <select id="sel-species">
593
+ <option value="dog">Cachorro</option>
594
+ <option value="cat">Gato</option>
595
+ </select>
596
+ </div>
597
+ <div class="dropdown-field">
598
+ <label>RaΓ§a (Aproximada)</label>
599
+ <select id="sel-breed">
600
+ <option value="SRD">SRD / Vira-lata</option>
601
+ <option value="Labrador">Labrador</option>
602
+ <option value="Pitbull">Pitbull</option>
603
+ <option value="Poodle">Poodle</option>
604
+ <option value="Outra">Outra</option>
605
+ </select>
606
+ </div>
607
+ <div class="dropdown-field">
608
+ <label>Cor Principal</label>
609
+ <select id="sel-color">
610
+ <option value="Caramelo">Caramelo</option>
611
+ <option value="Preto">Preto</option>
612
+ <option value="Branco">Branco</option>
613
+ <option value="Cinza">Cinza</option>
614
+ <option value="Marrom">Marrom</option>
615
+ <option value="Mesclado">Mesclado</option>
616
+ </select>
617
+ </div>
618
+ <div class="dropdown-field">
619
+ <label>Porte</label>
620
+ <select id="sel-size">
621
+ <option value="Pequeno">Pequeno</option>
622
+ <option value="MΓ©dio">MΓ©dio</option>
623
+ <option value="Grande">Grande</option>
624
+ </select>
625
+ </div>
626
+ </div>
627
+ <div class="condition-label">CondiΓ§Γ£o (Opcional)</div>
628
+ <div class="condition-chips">
629
+ <button class="cond-chip" data-val="Ferido"><span class="check">βœ“</span> Ferido</button>
630
+ <button class="cond-chip" data-val="Aparenta SaudΓ‘vel"><span class="check">βœ“</span> Aparenta SaudΓ‘vel</button>
631
+ <button class="cond-chip" data-val="Com Coleira"><span class="check">βœ“</span> Com Coleira</button>
632
+ </div>
633
+ </div>
634
+
635
+ <!-- Similar animals -->
636
+ <div class="analysis-card" id="similar-section">
637
+ <h3>Animais semelhantes encontrados</h3>
638
+ <p class="subtitle">A IA encontrou possΓ­veis correspondΓͺncias na sua Γ‘rea.</p>
639
+ <div class="similar-scroll" id="similar-scroll">
640
+ <!-- filled by JS -->
641
+ </div>
642
+ </div>
643
+
644
+ <!-- Bottom actions -->
645
+ <div id="analysis-actions">
646
+ <button class="btn-primary" id="confirm-btn">
647
+ Confirmar e Registrar β†’
648
+ </button>
649
+ <button class="btn-outline" id="discard-btn">Descartar Foto</button>
650
  </div>
651
  </div>
652
 
653
+ <!-- ══════════════════════════════════
654
+ SCREEN: CONFIRM (success)
655
+ ══════════════════════════════════ -->
656
+ <div id="screen-confirm" class="screen">
657
+ <div id="confirm-inner">
658
+ <div id="confirm-photo-wrap">
659
+ <div id="confirm-photo">🐾</div>
660
+ <div id="confirm-check">
661
+ <svg viewBox="0 0 24 24"><polyline points="20 6 9 20 4 14"/></svg>
662
+ </div>
663
+ </div>
664
+ <div id="confirm-title">Avistamento<br>Registrado!</div>
665
+ <div id="confirm-card">
666
+ <div class="confirm-row">
667
+ <span class="confirm-row-label">
668
+ <svg viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
669
+ Animal:
670
+ </span>
671
+ <span class="confirm-row-value" id="confirm-animal">β€”</span>
672
+ </div>
673
+ <div class="confirm-row">
674
+ <span class="confirm-row-label">
675
+ <svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
676
+ Local:
677
+ </span>
678
+ <span class="confirm-row-value" id="confirm-local">β€”</span>
679
+ </div>
680
+ <div class="confirm-row">
681
+ <span class="confirm-row-label">
682
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
683
+ Hora:
684
+ </span>
685
+ <span class="confirm-row-value" id="confirm-hora">β€”</span>
686
+ </div>
687
+ </div>
688
+ <div class="confirm-btns">
689
+ <button class="btn-primary" id="register-another-btn">
690
+ <svg viewBox="0 0 24 24"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
691
+ Registrar Outro
692
+ </button>
693
+ <button class="btn-secondary" id="go-map-btn">
694
+ <svg viewBox="0 0 24 24"><polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"/></svg>
695
+ Ver no Mapa
696
+ </button>
697
+ </div>
698
+ </div>
699
+ </div>
700
+
701
+ <!-- ══════════════════════════════════
702
+ SCREEN: SIGHTINGS (placeholder)
703
+ ══════════════════════════════════ -->
704
  <div id="screen-sightings" class="screen">
705
  <div class="screen-placeholder">
706
  <div class="ph-icon">πŸ‘οΈ</div>
 
713
  <!-- ══ BOTTOM NAV ══ -->
714
  <nav id="bottom-nav">
715
  <button class="nav-btn active" data-screen="map">
716
+ <span class="nav-icon">πŸ—ΊοΈ</span><span>Mapa</span>
 
717
  </button>
718
  <button class="nav-btn" data-screen="register">
719
+ <span class="nav-icon">πŸ“·</span><span>Registrar</span>
 
720
  </button>
721
  <button class="nav-btn" data-screen="sightings">
722
+ <span class="nav-icon">πŸ‘οΈ</span><span>Avistados</span>
 
723
  </button>
724
  </nav>
725
 
 
734
  let currentSpecies = 'all';
735
  let currentTimeframe = 'all';
736
  let map, markersLayer;
 
737
  let activeAnimal = null;
738
 
739
+ // Register flow state
740
+ let selectedFile = null;
741
+ let gpsCoords = null; // { lat, lng }
742
+ let sessionId = null; // from analyze_image response
743
+ let analyzeResult = null; // full API response
744
+
745
+ // DOM shortcuts
746
+ const header = document.getElementById('header');
747
+ const filterRow = document.getElementById('filter-row');
748
+ const bottomNav = document.getElementById('bottom-nav');
749
+ const photoInput = document.getElementById('photo-input');
750
+
751
  // ── Navigation ─────────────────────────────────────────────────────────────
752
+ const FLOW_SCREENS = ['analysis', 'confirm'];
753
 
754
  function showScreen(name) {
755
  document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
756
+ const el = document.getElementById('screen-' + name);
757
+ if (el) el.classList.add('active');
 
758
 
759
+ const isFlow = FLOW_SCREENS.includes(name);
760
+ header.classList.toggle('hidden', isFlow);
761
+ bottomNav.classList.toggle('hidden', isFlow);
762
+ filterRow.style.display = (name === 'map') ? 'flex' : 'none';
763
+
764
+ document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
765
+ const navBtn = document.querySelector(`.nav-btn[data-screen="${name}"]`);
766
+ if (navBtn) navBtn.classList.add('active');
767
 
 
768
  if (name === 'map' && map) setTimeout(() => map.invalidateSize(), 60);
769
  }
770
 
 
772
  btn.addEventListener('click', () => showScreen(btn.dataset.screen));
773
  });
774
 
775
+ // ── MAP ────────────────────────────────────────────────────────────────────
776
  function initMap() {
777
+ map = L.map('map', { zoomControl: false }).setView([-23.0316, -46.9785], 13);
 
 
 
 
778
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
779
+ attribution: 'Β© <a href="https://openstreetmap.org">OSM</a>', maxZoom: 19
 
780
  }).addTo(map);
 
781
  markersLayer = L.layerGroup().addTo(map);
 
782
  L.control.zoom({ position: 'bottomright' }).addTo(map);
783
+ map.on('click', hideCard);
 
 
784
  }
785
 
786
+ function makeIcon(a) {
787
+ const isDog = a.species === 'dog';
788
+ const urgent = a.days_since > 30;
 
789
  const color = urgent ? '#E53935' : isDog ? '#388C59' : '#FB8C00';
790
+ const badge = a.count > 1
791
+ ? `<span style="position:absolute;top:-5px;right:-5px;background:#fff;color:${color};border:1.5px solid ${color};border-radius:10px;min-width:16px;height:16px;font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 3px;">${a.count}</span>`
792
  : '';
 
793
  return L.divIcon({
794
+ 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 ? 'πŸ•' : '🐈'}${badge}</div>`,
795
+ className: '', iconSize: [42, 42], iconAnchor: [21, 21], popupAnchor: [0, -26]
 
 
 
796
  });
797
  }
798
 
 
799
  function renderMarkers(data) {
800
  markersLayer.clearLayers();
 
 
801
  const withCoords = data.filter(a => a.lat && a.lng);
802
+ document.getElementById('map-empty').style.display = withCoords.length ? 'none' : 'block';
803
+ if (!withCoords.length) { hideCard(); return; }
 
 
 
 
 
 
804
  withCoords.forEach(animal => {
805
  const marker = L.marker([animal.lat, animal.lng], { icon: makeIcon(animal) });
806
+ marker.on('click', e => { e.originalEvent.stopPropagation(); showCard(animal); });
 
 
 
807
  markersLayer.addLayer(marker);
808
  });
 
 
809
  showCard(withCoords[0]);
810
  }
811
 
812
+ function showCard(a) {
813
+ activeAnimal = a;
814
+ const isDog = a.species === 'dog';
815
+ const urgent = a.days_since > 30;
 
 
 
 
816
  const photoEl = document.getElementById('card-photo');
817
+ photoEl.innerHTML = a.photo_url
818
+ ? `<img src="${a.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>'">`
819
+ : `<span style="font-size:26px">${isDog ? 'πŸ•' : '🐈'}</span>`;
820
+ const badge = document.getElementById('card-badge');
821
+ badge.textContent = isDog ? 'CΓ£o' : 'Gato';
822
+ badge.className = 'badge' + (urgent ? ' urgent' : (!isDog ? ' orange' : ''));
823
+ document.getElementById('card-time').textContent = a.days_since === 0 ? 'Hoje' : a.days_since === 1 ? 'Ontem' : `${a.days_since}d atrΓ‘s`;
824
+ document.getElementById('card-location').textContent = a.desc || (isDog ? 'CΓ£o avistado' : 'Gato avistado');
825
+ document.getElementById('card-sub').textContent = `${a.count} avistamento${a.count !== 1 ? 's' : ''} Β· ΓΊltimo: ${a.last_seen}`;
826
+ document.getElementById('sighting-card').classList.remove('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
827
  }
828
 
829
  function hideCard() {
 
832
  }
833
 
834
  document.getElementById('card-btn').addEventListener('click', () => {
835
+ if (activeAnimal) alert(`Animal #${activeAnimal.id} β€” perfil em breve!`);
 
 
 
836
  });
837
 
 
838
  async function loadMapData() {
839
  try {
840
+ const data = await fetch(`/api/map-data?species=${currentSpecies}&timeframe=${currentTimeframe}`).then(r => r.json());
 
 
841
  renderMarkers(data);
842
+ } catch (e) { console.error(e); }
 
 
843
  }
844
 
 
845
  document.querySelectorAll('.chip[data-species]').forEach(btn => {
846
  btn.addEventListener('click', () => {
847
  document.querySelectorAll('.chip[data-species]').forEach(b => b.classList.remove('active'));
 
853
 
854
  document.querySelectorAll('.chip[data-timeframe]').forEach(btn => {
855
  btn.addEventListener('click', () => {
856
+ const was = btn.classList.contains('active');
857
  document.querySelectorAll('.chip[data-timeframe]').forEach(b => b.classList.remove('active'));
858
+ currentTimeframe = was ? 'all' : btn.dataset.timeframe;
859
+ if (!was) btn.classList.add('active');
 
 
 
 
860
  loadMapData();
861
  });
862
  });
863
 
864
+ // ── REGISTER β€” photo ───────────────────────────────────────────────────────
865
+ document.getElementById('shutter-btn').addEventListener('click', () => photoInput.click());
866
+ photoInput.addEventListener('change', e => {
867
+ const file = e.target.files[0];
868
+ if (!file) return;
869
+ selectedFile = file;
870
+ const url = URL.createObjectURL(file);
871
+ const preview = document.getElementById('photo-preview');
872
+ preview.src = url;
873
+ preview.style.display = 'block';
874
+ document.getElementById('camera-placeholder').style.display = 'none';
875
+ updateSubmitBtn();
876
+ });
877
+
878
+ // ── REGISTER β€” GPS ─────────────────────────────────────────────────────────
879
+ document.getElementById('gps-pill').addEventListener('click', requestGPS);
880
+ document.getElementById('gps-btn').addEventListener('click', requestGPS);
881
+
882
+ function requestGPS() {
883
+ if (!navigator.geolocation) {
884
+ document.getElementById('gps-text').textContent = 'GPS nΓ£o disponΓ­vel neste dispositivo';
885
+ return;
886
+ }
887
+ const txt = document.getElementById('gps-text');
888
+ const btn = document.getElementById('gps-btn');
889
+ txt.textContent = 'Detectando...';
890
+ txt.classList.remove('located');
891
+ btn.disabled = true;
892
+
893
+ navigator.geolocation.getCurrentPosition(
894
+ pos => {
895
+ gpsCoords = { lat: pos.coords.latitude, lng: pos.coords.longitude };
896
+ const label = `${gpsCoords.lat.toFixed(4)}, ${gpsCoords.lng.toFixed(4)}`;
897
+ txt.textContent = label;
898
+ txt.classList.add('located');
899
+ btn.textContent = '';
900
+ btn.innerHTML = `
901
+ <svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
902
+ LocalizaΓ§Γ£o Registrada βœ“`;
903
+ btn.classList.add('located');
904
+ btn.disabled = false;
905
+ updateSubmitBtn();
906
+ },
907
+ err => {
908
+ const msgs = { 1: 'PermissΓ£o negada', 2: 'LocalizaΓ§Γ£o indisponΓ­vel', 3: 'Timeout GPS' };
909
+ txt.textContent = msgs[err.code] || 'Erro ao obter localizaΓ§Γ£o';
910
+ btn.disabled = false;
911
+ },
912
+ { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
913
+ );
914
+ }
915
+
916
+ function updateSubmitBtn() {
917
+ document.getElementById('submit-reg-btn').disabled = !selectedFile;
918
+ }
919
+
920
+ // ── REGISTER β€” submit β†’ analysis ───────────────────────────────────────────
921
+ document.getElementById('submit-reg-btn').addEventListener('click', async () => {
922
+ if (!selectedFile) return;
923
+ showScreen('analysis');
924
+ await runAnalysis();
925
+ });
926
+
927
+ // ── GRADIO CLIENT ──────────────────────────────────────────────────────────
928
+ let _gradioClient = null;
929
+ let _handleFile = null;
930
+
931
+ async function getClient() {
932
+ if (!_gradioClient) {
933
+ const mod = await import('https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js');
934
+ _handleFile = mod.handle_file;
935
+ _gradioClient = await mod.Client.connect(window.location.origin);
936
+ }
937
+ return { client: _gradioClient, handleFile: _handleFile };
938
+ }
939
+
940
+ // ── ANALYSIS ───────────────────────────────────────────────────────────────
941
+ async function runAnalysis() {
942
+ // Show submitted photo
943
+ const photoUrl = URL.createObjectURL(selectedFile);
944
+ document.getElementById('analysis-photo').src = photoUrl;
945
+
946
+ // Reset badge state
947
+ const aiBadge = document.getElementById('ai-badge');
948
+ const aiBadgeText = document.getElementById('ai-badge-text');
949
+ aiBadge.className = '';
950
+ aiBadgeText.textContent = 'IA Analisando...';
951
+ document.getElementById('animal-result-badge').classList.remove('visible');
952
+
953
+ // Disable confirm button while loading
954
+ const confirmBtn = document.getElementById('confirm-btn');
955
+ confirmBtn.disabled = true;
956
+ confirmBtn.innerHTML = '<div class="spinner"></div> Analisando...';
957
+
958
+ try {
959
+ const { client, handleFile } = await getClient();
960
+ const res = await client.predict('/analyze_image', {
961
+ image_path: handleFile(selectedFile),
962
+ });
963
+
964
+ analyzeResult = res.data[0];
965
+ sessionId = analyzeResult.session_id;
966
+ const desc = analyzeResult.description || {};
967
+
968
+ // Update dropdowns with AI results
969
+ setSelectVal('sel-species', desc.species === 'cat' ? 'cat' : 'dog');
970
+ setSelectVal('sel-breed', desc.breed_estimate || 'SRD');
971
+ setSelectVal('sel-color', desc.primary_color || 'Caramelo');
972
+ setSelectVal('sel-size', mapSize(desc.size) || 'MΓ©dio');
973
+
974
+ // Update AI badge to "done"
975
+ aiBadge.className = 'done';
976
+ aiBadgeText.textContent = 'IA ConcluΓ­da';
977
+
978
+ // Show result badge
979
+ const species = desc.species === 'cat' ? 'Gato' : 'CΓ£o';
980
+ const color = (desc.primary_color || '').charAt(0).toUpperCase() + (desc.primary_color || '').slice(1);
981
+ document.getElementById('result-badge-text').textContent = `${species} ${color}`.trim();
982
+ document.getElementById('animal-result-badge').classList.add('visible');
983
+
984
+ // Render similar animals
985
+ renderSimilar(analyzeResult.similar || []);
986
+
987
+ confirmBtn.disabled = false;
988
+ confirmBtn.innerHTML = 'Confirmar e Registrar β†’';
989
+
990
+ } catch (err) {
991
+ console.error('analyze_image error:', err);
992
+ aiBadgeText.textContent = 'Erro na anΓ‘lise';
993
+ confirmBtn.disabled = false;
994
+ confirmBtn.innerHTML = 'Confirmar sem IA β†’';
995
+ }
996
+ }
997
+
998
+ function setSelectVal(id, val) {
999
+ const sel = document.getElementById(id);
1000
+ if (!sel) return;
1001
+ for (const opt of sel.options) {
1002
+ if (opt.value.toLowerCase() === (val || '').toLowerCase()) {
1003
+ sel.value = opt.value;
1004
+ return;
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ function mapSize(s) {
1010
+ if (!s) return 'MΓ©dio';
1011
+ const lower = s.toLowerCase();
1012
+ if (lower.includes('small') || lower.includes('pequen')) return 'Pequeno';
1013
+ if (lower.includes('large') || lower.includes('grand')) return 'Grande';
1014
+ return 'MΓ©dio';
1015
+ }
1016
+
1017
+ function renderSimilar(similar) {
1018
+ const scroll = document.getElementById('similar-scroll');
1019
+ const section = document.getElementById('similar-section');
1020
+ if (!similar.length) { section.style.display = 'none'; return; }
1021
+ section.style.display = '';
1022
+ scroll.innerHTML = similar.map(m => `
1023
+ <div class="similar-card" data-id="${m.id}">
1024
+ <div class="similar-card-img" style="position:relative;display:flex;align-items:center;justify-content:center;font-size:36px;">
1025
+ ${m.photo_url
1026
+ ? `<img src="${m.photo_url}" alt="match" style="width:100%;height:120px;object-fit:cover;" onerror="this.outerHTML='<span style=font-size:36px>🐾</span>'">`
1027
+ : '🐾'}
1028
+ <span class="match-pct">${m.score_pct}%</span>
1029
+ </div>
1030
+ <div class="similar-card-info">
1031
+ <div class="days">${m.days_ago ? `Visto hΓ‘ ${m.days_ago}` : 'Animal cadastrado'}</div>
1032
+ <div class="dist">ID #${m.id}</div>
1033
+ </div>
1034
+ </div>
1035
+ `).join('');
1036
+ }
1037
+
1038
+ // Condition chips toggle
1039
+ document.querySelectorAll('.cond-chip').forEach(btn => {
1040
+ btn.addEventListener('click', () => btn.classList.toggle('active'));
1041
+ });
1042
+
1043
+ // Back from analysis
1044
+ document.getElementById('analysis-back').addEventListener('click', () => showScreen('register'));
1045
+ document.getElementById('discard-btn').addEventListener('click', () => {
1046
+ resetRegister();
1047
+ showScreen('register');
1048
+ });
1049
+
1050
+ // ── CONFIRM ────────────────────────────────────────────────────────────────
1051
+ document.getElementById('confirm-btn').addEventListener('click', async () => {
1052
+ const confirmBtn = document.getElementById('confirm-btn');
1053
+ confirmBtn.disabled = true;
1054
+ confirmBtn.innerHTML = '<div class="spinner"></div> Salvando...';
1055
+
1056
+ try {
1057
+ const { client } = await getClient();
1058
+
1059
+ const gpsJson = gpsCoords ? JSON.stringify(gpsCoords) : '';
1060
+ const notes = document.getElementById('notes-input').value.trim();
1061
+ const conditions = [...document.querySelectorAll('.cond-chip.active')].map(c => c.dataset.val).join(', ');
1062
+
1063
+ const res = await client.predict('/confirm_sighting', {
1064
+ session_id: sessionId || '',
1065
+ gps_json: gpsJson,
1066
+ notes: notes,
1067
+ condition: conditions,
1068
+ });
1069
+
1070
+ const data = res.data[0];
1071
+ if (data.error) { alert(data.error); confirmBtn.disabled = false; confirmBtn.innerHTML = 'Confirmar e Registrar β†’'; return; }
1072
+
1073
+ // Fill confirm screen
1074
+ document.getElementById('confirm-animal').textContent = data.name || (data.species === 'dog' ? 'CΓ£o' : 'Gato');
1075
+ document.getElementById('confirm-local').textContent = data.location || 'β€”';
1076
+ document.getElementById('confirm-hora').textContent = data.time || 'β€”';
1077
+
1078
+ const photoWrap = document.getElementById('confirm-photo');
1079
+ if (data.photo_url) {
1080
+ photoWrap.innerHTML = `<img src="${data.photo_url}" alt="animal" style="width:160px;height:160px;object-fit:cover;border-radius:50%;border:4px solid #fff;" onerror="this.outerHTML='<span style=font-size:64px>🐾</span>'">`;
1081
+ } else {
1082
+ photoWrap.innerHTML = `<span style="font-size:64px">${data.species === 'dog' ? 'πŸ•' : '🐈'}</span>`;
1083
+ }
1084
+
1085
+ showScreen('confirm');
1086
+ loadMapData(); // refresh map in background
1087
+ } catch (err) {
1088
+ console.error(err);
1089
+ alert('Erro ao salvar avistamento. Tente novamente.');
1090
+ confirmBtn.disabled = false;
1091
+ confirmBtn.innerHTML = 'Confirmar e Registrar β†’';
1092
+ }
1093
+ });
1094
+
1095
+ document.getElementById('register-another-btn').addEventListener('click', () => {
1096
+ resetRegister();
1097
+ showScreen('register');
1098
+ });
1099
+ document.getElementById('go-map-btn').addEventListener('click', () => showScreen('map'));
1100
+
1101
+ function resetRegister() {
1102
+ selectedFile = null;
1103
+ gpsCoords = null;
1104
+ sessionId = null;
1105
+ analyzeResult = null;
1106
+ document.getElementById('photo-preview').style.display = 'none';
1107
+ document.getElementById('photo-preview').src = '';
1108
+ document.getElementById('camera-placeholder').style.display = 'flex';
1109
+ document.getElementById('photo-input').value = '';
1110
+ document.getElementById('notes-input').value = '';
1111
+ document.getElementById('gps-text').textContent = 'Toque para detectar localizaΓ§Γ£o';
1112
+ document.getElementById('gps-text').classList.remove('located');
1113
+ const gpsBtn = document.getElementById('gps-btn');
1114
+ gpsBtn.className = 'btn-secondary';
1115
+ gpsBtn.innerHTML = `
1116
+ <svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
1117
+ Registrar LocalizaΓ§Γ£o`;
1118
+ gpsBtn.disabled = false;
1119
+ document.querySelectorAll('.cond-chip').forEach(c => c.classList.remove('active'));
1120
+ updateSubmitBtn();
1121
+ }
1122
+
1123
+ // ── BOOT ───────────────────────────────────────────────────────────────────
1124
  initMap();
1125
  loadMapData();
1126
  })();