Guilherme Silberfarb Costa commited on
Commit
03a7ca2
·
1 Parent(s): 4e2aace

correcao de mapas, linkagem da pesquisa e avaliacao

Browse files
backend/app/core/elaboracao/charts.py CHANGED
@@ -17,7 +17,12 @@ import branca.colormap as cm
17
  from branca.element import Element
18
  from html import escape
19
  from typing import Any
20
- from app.core.map_layers import add_bairros_layer, add_indice_marker, add_zoom_responsive_circle_markers
 
 
 
 
 
21
 
22
  # ============================================================
23
  # CONSTANTES DE ESTILO
@@ -634,12 +639,15 @@ def _aplicar_jitter_sobrepostos(
634
  max_raio_metros = 22.0
635
  metros_por_grau_lat = 111_320.0
636
 
 
 
 
637
  for _, idx_labels in grupos.indices.items():
638
- if len(idx_labels) <= 1:
 
639
  continue
640
- idx_list = list(idx_labels)
641
- base_lat = float(df_plot.at[idx_list[0], lat_plot_col])
642
- base_lon = float(df_plot.at[idx_list[0], lon_plot_col])
643
  if not np.isfinite(base_lat) or not np.isfinite(base_lon):
644
  continue
645
 
@@ -648,7 +656,7 @@ def _aplicar_jitter_sobrepostos(
648
  cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
649
  metros_por_grau_lon = metros_por_grau_lat * cos_lat
650
 
651
- for pos, idx_label in enumerate(idx_list):
652
  if pos == 0:
653
  continue
654
 
@@ -665,8 +673,8 @@ def _aplicar_jitter_sobrepostos(
665
  delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
666
  delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
667
 
668
- df_plot.at[idx_label, lat_plot_col] = base_lat + delta_lat
669
- df_plot.at[idx_label, lon_plot_col] = base_lon + delta_lon
670
 
671
  return df_plot
672
 
@@ -678,6 +686,14 @@ def _montar_popup_registro_em_colunas(
678
  max_itens_coluna: int = 8,
679
  popup_uid: str | None = None,
680
  ) -> tuple[str, int]:
 
 
 
 
 
 
 
 
681
  itens: list[tuple[str, str]] = []
682
  for col in allowed_cols:
683
  if col not in row.index:
@@ -698,67 +714,78 @@ def _montar_popup_registro_em_colunas(
698
  if not itens:
699
  return f"<b>Índice: {escape(str(idx))}</b>", 320
700
 
701
- paginas = [itens[i:i + max_itens_coluna] for i in range(0, len(itens), max_itens_coluna)]
 
 
 
 
 
 
 
 
702
  popup_uid = popup_uid or f"mesa-pop-{abs(hash(str(idx))) % 10_000_000}"
 
 
 
703
 
704
  pages_html = []
705
- botoes_html = []
706
  for pagina_idx, pagina_itens in enumerate(paginas):
707
- trs = "".join([
708
- "<tr style='border-bottom:1px solid #e9ecef;'>"
709
- f"<td style='padding:4px 8px 4px 0; color:#6c757d; font-weight:500;'>{escape(c)}</td>"
710
- f"<td style='padding:4px 0; text-align:right; color:#495057;'>{escape(v)}</td>"
711
- "</tr>"
712
- for c, v in pagina_itens
713
- ])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  display = "block" if pagina_idx == 0 else "none"
715
  pages_html.append(
716
- f"<div class='mesa-popup-page' data-page='{pagina_idx}' style='display:{display};'>"
717
- f"<table style='border-collapse:collapse; font-size:12px; width:100%;'>{trs}</table>"
718
  "</div>"
719
  )
720
- botao_style = (
721
- "border:1px solid #9fb4c8; background:#eaf1f7; border-radius:6px; "
722
- "padding:2px 7px; font-size:11px; cursor:pointer; color:#2f4b66;"
723
- if pagina_idx == 0
724
- else
725
- "border:1px solid #ced8e2; background:#fff; border-radius:6px; "
726
- "padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;"
727
- )
728
- onclick = (
729
- f"var root=document.getElementById('{popup_uid}');"
730
- "if(!root){return false;}"
731
- "var pages=root.querySelectorAll('.mesa-popup-page');"
732
- "for(var i=0;i<pages.length;i++){pages[i].style.display=(i==="
733
- f"{pagina_idx}"
734
- ")?'block':'none';}"
735
- "var btns=root.querySelectorAll('[data-page-btn]');"
736
- "for(var j=0;j<btns.length;j++){btns[j].style.background='#fff';btns[j].style.borderColor='#ced8e2';btns[j].style.color='#4e6479';}"
737
- "this.style.background='#eaf1f7';this.style.borderColor='#9fb4c8';this.style.color='#2f4b66';"
738
- "return false;"
739
- )
740
- botoes_html.append(
741
- f"<button type='button' data-page-btn='1' style=\"{botao_style}\" onclick=\"{onclick}\">"
742
- f"{pagina_idx + 1}</button>"
743
- )
744
 
745
  controls_html = ""
746
  if len(paginas) > 1:
747
  controls_html = (
748
- "<div class='mesa-popup-controls' style='display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; align-items:center;'>"
749
- f"<span style='font-size:11px; color:#5d7388; margin-right:2px;'>Páginas:</span>{''.join(botoes_html)}"
 
 
 
 
750
  "</div>"
751
  )
752
 
753
  popup_html = (
754
- f"<div id='{popup_uid}' style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden;\">"
 
755
  "<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
756
  "<div style=\"padding:12px 15px; background:#f8f9fa;\">"
757
- f"<div class='mesa-popup-pages'>{''.join(pages_html)}</div>"
758
  f"{controls_html}"
759
  "</div></div>"
760
  )
761
- return popup_html, 430
762
 
763
 
764
  def _normalizar_stops_cor(
@@ -1344,6 +1371,7 @@ def criar_mapa(
1344
  secondary_area_unit='hectares'
1345
  ).add_to(m)
1346
  add_zoom_responsive_circle_markers(m)
 
1347
 
1348
  # Ajusta bounds robustos para evitar "salto" para longe por pontos geocodificados distantes.
1349
  df_bounds = df_mapa
 
17
  from branca.element import Element
18
  from html import escape
19
  from typing import Any
20
+ from app.core.map_layers import (
21
+ add_bairros_layer,
22
+ add_indice_marker,
23
+ add_popup_pagination_handlers,
24
+ add_zoom_responsive_circle_markers,
25
+ )
26
 
27
  # ============================================================
28
  # CONSTANTES DE ESTILO
 
639
  max_raio_metros = 22.0
640
  metros_por_grau_lat = 111_320.0
641
 
642
+ lat_plot_pos = int(df_plot.columns.get_loc(lat_plot_col))
643
+ lon_plot_pos = int(df_plot.columns.get_loc(lon_plot_col))
644
+
645
  for _, idx_labels in grupos.indices.items():
646
+ posicoes = np.asarray(idx_labels, dtype=int)
647
+ if posicoes.size <= 1:
648
  continue
649
+ base_lat = float(df_plot.iat[int(posicoes[0]), lat_plot_pos])
650
+ base_lon = float(df_plot.iat[int(posicoes[0]), lon_plot_pos])
 
651
  if not np.isfinite(base_lat) or not np.isfinite(base_lon):
652
  continue
653
 
 
656
  cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
657
  metros_por_grau_lon = metros_por_grau_lat * cos_lat
658
 
659
+ for pos, pos_idx in enumerate(posicoes):
660
  if pos == 0:
661
  continue
662
 
 
673
  delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
674
  delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
675
 
676
+ df_plot.iat[int(pos_idx), lat_plot_pos] = base_lat + delta_lat
677
+ df_plot.iat[int(pos_idx), lon_plot_pos] = base_lon + delta_lon
678
 
679
  return df_plot
680
 
 
686
  max_itens_coluna: int = 8,
687
  popup_uid: str | None = None,
688
  ) -> tuple[str, int]:
689
+ def _limitar_texto(valor: str, limite: int) -> str:
690
+ txt = str(valor)
691
+ if limite <= 0 or len(txt) <= limite:
692
+ return txt
693
+ if limite <= 3:
694
+ return txt[:limite]
695
+ return txt[: limite - 3] + "..."
696
+
697
  itens: list[tuple[str, str]] = []
698
  for col in allowed_cols:
699
  if col not in row.index:
 
714
  if not itens:
715
  return f"<b>Índice: {escape(str(idx))}</b>", 320
716
 
717
+ max_colunas_por_pagina = 2
718
+ max_chars_chave = 20
719
+ max_chars_valor = 20
720
+ char_px = 7.2
721
+ gap_cols_px = 12
722
+ popup_padding_horizontal_px = 30
723
+ coluna_largura_px = int(round((max_chars_chave + max_chars_valor) * char_px + 28))
724
+ itens_por_pagina = max_itens_coluna * max_colunas_por_pagina
725
+ paginas = [itens[i:i + itens_por_pagina] for i in range(0, len(itens), itens_por_pagina)]
726
  popup_uid = popup_uid or f"mesa-pop-{abs(hash(str(idx))) % 10_000_000}"
727
+ itens_primeira_pagina = len(paginas[0]) if paginas else 0
728
+ colunas_visiveis = max(1, min(max_colunas_por_pagina, int(math.ceil(itens_primeira_pagina / max_itens_coluna)) if itens_primeira_pagina else 1))
729
+ popup_largura_px = popup_padding_horizontal_px + (coluna_largura_px * colunas_visiveis) + (gap_cols_px * (colunas_visiveis - 1))
730
 
731
  pages_html = []
 
732
  for pagina_idx, pagina_itens in enumerate(paginas):
733
+ colunas = [pagina_itens[i:i + max_itens_coluna] for i in range(0, len(pagina_itens), max_itens_coluna)]
734
+ colunas_html = []
735
+ for col_itens in colunas:
736
+ trs_parts: list[str] = []
737
+ for c, v in col_itens:
738
+ c_full = str(c)
739
+ v_full = str(v)
740
+ c_txt = escape(_limitar_texto(c_full, max_chars_chave))
741
+ v_txt = escape(_limitar_texto(v_full, max_chars_valor))
742
+ c_title = escape(c_full)
743
+ v_title = escape(v_full)
744
+ trs_parts.append(
745
+ "<tr style='border-bottom:1px solid #e9ecef;'>"
746
+ "<td style='padding:4px 8px 4px 0; color:#6c757d; font-weight:500; width:50%; max-width:50%; overflow:hidden; white-space:nowrap;'>"
747
+ f"<span title='{c_title}'>{c_txt}</span>"
748
+ "</td>"
749
+ "<td style='padding:4px 0; text-align:right; color:#495057; width:50%; max-width:50%; overflow:hidden; white-space:nowrap;'>"
750
+ f"<span title='{v_title}'>{v_txt}</span>"
751
+ "</td>"
752
+ "</tr>"
753
+ )
754
+ trs = "".join(trs_parts)
755
+ colunas_html.append(
756
+ f"<div style='flex:0 0 {coluna_largura_px}px; width:{coluna_largura_px}px; min-width:{coluna_largura_px}px; overflow:hidden;'>"
757
+ f"<table style='border-collapse:collapse; table-layout:fixed; font-size:12px; width:{coluna_largura_px}px;'>{trs}</table>"
758
+ "</div>"
759
+ )
760
  display = "block" if pagina_idx == 0 else "none"
761
  pages_html.append(
762
+ f"<div class='mesa-popup-page' data-page='{pagina_idx + 1}' style='display:{display};'>"
763
+ f"<div style='display:flex; gap:12px; align-items:flex-start; flex-wrap:nowrap;'>{''.join(colunas_html)}</div>"
764
  "</div>"
765
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
 
767
  controls_html = ""
768
  if len(paginas) > 1:
769
  controls_html = (
770
+ "<div class='mesa-popup-controls' style='display:flex; gap:5px; flex-wrap:nowrap; margin-top:8px; align-items:center; justify-content:center; white-space:nowrap; width:100%;'>"
771
+ f"<button type='button' data-page-nav='first' data-a='first' style=\"border:1px solid #ced8e2; background:#fff; border-radius:6px; padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;\">&laquo;</button>"
772
+ f"<button type='button' data-page-nav='prev' data-a='prev' style=\"border:1px solid #ced8e2; background:#fff; border-radius:6px; padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;\">&lsaquo;</button>"
773
+ "<div data-page-number-wrap='1' style='display:flex; gap:5px; align-items:center; justify-content:center; flex-wrap:nowrap;'></div>"
774
+ f"<button type='button' data-page-nav='next' data-a='next' style=\"border:1px solid #ced8e2; background:#fff; border-radius:6px; padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;\">&rsaquo;</button>"
775
+ f"<button type='button' data-page-nav='last' data-a='last' style=\"border:1px solid #ced8e2; background:#fff; border-radius:6px; padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;\">&raquo;</button>"
776
  "</div>"
777
  )
778
 
779
  popup_html = (
780
+ f"<div id='{popup_uid}' data-pager='1' data-current-page='1' data-page-start='1' data-page-window='1' "
781
+ f"style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden; width:{popup_largura_px}px; max-width:{popup_largura_px}px;\">"
782
  "<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
783
  "<div style=\"padding:12px 15px; background:#f8f9fa;\">"
784
+ f"<div class='mesa-popup-pages' style='width:100%;'>{''.join(pages_html)}</div>"
785
  f"{controls_html}"
786
  "</div></div>"
787
  )
788
+ return popup_html, popup_largura_px
789
 
790
 
791
  def _normalizar_stops_cor(
 
1371
  secondary_area_unit='hectares'
1372
  ).add_to(m)
1373
  add_zoom_responsive_circle_markers(m)
1374
+ add_popup_pagination_handlers(m)
1375
 
1376
  # Ajusta bounds robustos para evitar "salto" para longe por pontos geocodificados distantes.
1377
  df_bounds = df_mapa
backend/app/core/map_layers.py CHANGED
@@ -237,3 +237,254 @@ def add_zoom_responsive_circle_markers(
237
  """
238
  mapa.get_root().html.add_child(Element(script))
239
  setattr(mapa, "_mesa_zoom_radius_script", True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  """
238
  mapa.get_root().html.add_child(Element(script))
239
  setattr(mapa, "_mesa_zoom_radius_script", True)
240
+
241
+
242
+ def add_popup_pagination_handlers(mapa: folium.Map) -> None:
243
+ if getattr(mapa, "_mesa_popup_pager_script", False):
244
+ return
245
+
246
+ map_name = mapa.get_name()
247
+ script = f"""
248
+ <script>
249
+ (function() {{
250
+ const MAP_NAME = "{map_name}";
251
+
252
+ function getCssGapPx(el, fallback) {{
253
+ if (!el || !window.getComputedStyle) return fallback;
254
+ const style = window.getComputedStyle(el);
255
+ const raw = style.columnGap || style.gap || '';
256
+ const value = parseFloat(raw);
257
+ return Number.isFinite(value) ? value : fallback;
258
+ }}
259
+
260
+ function buildNumberButton() {{
261
+ const btn = document.createElement('button');
262
+ btn.type = 'button';
263
+ btn.dataset.pageNumber = '1';
264
+ btn.style.border = '1px solid #ced8e2';
265
+ btn.style.background = '#fff';
266
+ btn.style.borderRadius = '6px';
267
+ btn.style.padding = '2px 7px';
268
+ btn.style.fontSize = '11px';
269
+ btn.style.cursor = 'pointer';
270
+ btn.style.color = '#4e6479';
271
+ btn.style.display = 'inline-flex';
272
+ btn.style.alignItems = 'center';
273
+ btn.style.justifyContent = 'center';
274
+ btn.textContent = '1';
275
+ return btn;
276
+ }}
277
+
278
+ function ensureNumberSlots(numberWrap, count) {{
279
+ if (!numberWrap) return;
280
+ let target = Number.isFinite(count) ? count : 1;
281
+ if (target < 1) target = 1;
282
+ while (numberWrap.children.length < target) {{
283
+ numberWrap.appendChild(buildNumberButton());
284
+ }}
285
+ while (numberWrap.children.length > target) {{
286
+ numberWrap.removeChild(numberWrap.lastElementChild);
287
+ }}
288
+ }}
289
+
290
+ function measureNumberButtonWidth(numberWrap) {{
291
+ if (!numberWrap) return 30;
292
+ const probe = buildNumberButton();
293
+ probe.style.visibility = 'hidden';
294
+ probe.style.position = 'absolute';
295
+ probe.style.pointerEvents = 'none';
296
+ numberWrap.appendChild(probe);
297
+ const width = probe.getBoundingClientRect().width || 30;
298
+ numberWrap.removeChild(probe);
299
+ return Math.max(24, width);
300
+ }}
301
+
302
+ function resolveWindowSize(root, total) {{
303
+ if (!(total > 0)) return 1;
304
+ const controls = root.querySelector('.mesa-popup-controls');
305
+ const numberWrap = root.querySelector('[data-page-number-wrap]');
306
+ if (!controls || !numberWrap) return Math.max(1, total);
307
+
308
+ const controlsWidth = controls.getBoundingClientRect().width || root.getBoundingClientRect().width || 0;
309
+ if (!(controlsWidth > 0)) return Math.max(1, total);
310
+
311
+ const controlsGap = getCssGapPx(controls, 5);
312
+ const numberGap = getCssGapPx(numberWrap, 5);
313
+ let navWidth = 0;
314
+ controls.querySelectorAll('[data-page-nav]').forEach(function(btn) {{
315
+ navWidth += btn.getBoundingClientRect().width || 28;
316
+ }});
317
+
318
+ const numberButtonWidth = measureNumberButtonWidth(numberWrap);
319
+ const navCount = controls.querySelectorAll('[data-page-nav]').length;
320
+ const estimatedGaps = Math.max(0, navCount + 2) * controlsGap;
321
+ let available = controlsWidth - navWidth - estimatedGaps;
322
+ if (!(available > 0)) {{
323
+ available = controlsWidth - navWidth;
324
+ }}
325
+
326
+ let fit = Math.floor((available + numberGap) / (numberButtonWidth + numberGap));
327
+ if (!Number.isFinite(fit) || fit < 1) fit = 1;
328
+ if (fit > total) fit = total;
329
+ ensureNumberSlots(numberWrap, fit);
330
+ return fit;
331
+ }}
332
+
333
+ function updatePager(root, targetPage) {{
334
+ if (!root) return;
335
+ const pages = root.querySelectorAll('.mesa-popup-page');
336
+ const total = pages.length;
337
+ if (!total) return;
338
+
339
+ let windowSize = resolveWindowSize(root, total);
340
+ if (!Number.isFinite(windowSize) || windowSize < 1) windowSize = 1;
341
+ if (windowSize > total) windowSize = total;
342
+ root.dataset.pageWindow = String(windowSize);
343
+
344
+ const currentRaw = parseInt(root.dataset.currentPage || '1', 10);
345
+ let current = Number.isFinite(currentRaw) && currentRaw > 0 ? currentRaw : 1;
346
+ let next = Number.isFinite(targetPage) ? targetPage : current;
347
+ if (next < 1) next = 1;
348
+ if (next > total) next = total;
349
+ current = next;
350
+
351
+ let start = parseInt(root.dataset.pageStart || '1', 10);
352
+ if (!Number.isFinite(start) || start < 1) start = 1;
353
+ if (total <= windowSize) {{
354
+ start = 1;
355
+ }} else {{
356
+ if (current < start) start = current;
357
+ if (current > start + windowSize - 1) start = current - windowSize + 1;
358
+ const maxStart = total - windowSize + 1;
359
+ if (start > maxStart) start = maxStart;
360
+ if (start < 1) start = 1;
361
+ }}
362
+
363
+ root.dataset.currentPage = String(current);
364
+ root.dataset.pageStart = String(start);
365
+
366
+ pages.forEach(function(pageEl, idx) {{
367
+ pageEl.style.display = idx + 1 === current ? 'block' : 'none';
368
+ }});
369
+
370
+ const numberWrap = root.querySelector('[data-page-number-wrap]');
371
+ const numberButtons = numberWrap ? numberWrap.querySelectorAll('[data-page-number]') : [];
372
+ numberButtons.forEach(function(buttonEl, idx) {{
373
+ const value = start + idx;
374
+ if (value <= total) {{
375
+ buttonEl.style.display = 'inline-flex';
376
+ buttonEl.dataset.pageValue = String(value);
377
+ buttonEl.textContent = String(value);
378
+ buttonEl.style.background = value === current ? '#eaf1f7' : '#fff';
379
+ buttonEl.style.borderColor = value === current ? '#9fb4c8' : '#ced8e2';
380
+ buttonEl.style.color = value === current ? '#2f4b66' : '#4e6479';
381
+ }} else {{
382
+ buttonEl.style.display = 'none';
383
+ }}
384
+ }});
385
+
386
+ const onFirst = current <= 1;
387
+ const onLast = current >= total;
388
+ const firstBtn = root.querySelector('[data-a="first"]');
389
+ const prevBtn = root.querySelector('[data-a="prev"]');
390
+ const nextBtn = root.querySelector('[data-a="next"]');
391
+ const lastBtn = root.querySelector('[data-a="last"]');
392
+
393
+ if (firstBtn) {{
394
+ firstBtn.disabled = onFirst;
395
+ firstBtn.style.opacity = onFirst ? '0.45' : '1';
396
+ firstBtn.style.cursor = onFirst ? 'not-allowed' : 'pointer';
397
+ }}
398
+ if (prevBtn) {{
399
+ prevBtn.disabled = onFirst;
400
+ prevBtn.style.opacity = onFirst ? '0.45' : '1';
401
+ prevBtn.style.cursor = onFirst ? 'not-allowed' : 'pointer';
402
+ }}
403
+ if (nextBtn) {{
404
+ nextBtn.disabled = onLast;
405
+ nextBtn.style.opacity = onLast ? '0.45' : '1';
406
+ nextBtn.style.cursor = onLast ? 'not-allowed' : 'pointer';
407
+ }}
408
+ if (lastBtn) {{
409
+ lastBtn.disabled = onLast;
410
+ lastBtn.style.opacity = onLast ? '0.45' : '1';
411
+ lastBtn.style.cursor = onLast ? 'not-allowed' : 'pointer';
412
+ }}
413
+ }}
414
+
415
+ function handleClick(event) {{
416
+ const target = event && event.target ? event.target : null;
417
+ if (!target || !target.closest) return;
418
+ const button = target.closest('[data-page-number], [data-page-nav]');
419
+ if (!button) return;
420
+
421
+ const root = button.closest('[data-pager]');
422
+ if (!root) return;
423
+
424
+ event.preventDefault();
425
+ event.stopPropagation();
426
+ if (button.disabled) return;
427
+
428
+ const action = button.dataset.a || button.dataset.pageValue || '';
429
+ const currentRaw = parseInt(root.dataset.currentPage || '1', 10);
430
+ const current = Number.isFinite(currentRaw) && currentRaw > 0 ? currentRaw : 1;
431
+ const total = root.querySelectorAll('.mesa-popup-page').length;
432
+ if (!total) return;
433
+
434
+ let nextPage = current;
435
+ if (action === 'first') nextPage = 1;
436
+ else if (action === 'prev') nextPage = current - 1;
437
+ else if (action === 'next') nextPage = current + 1;
438
+ else if (action === 'last') nextPage = total;
439
+ else {{
440
+ const parsed = parseInt(action, 10);
441
+ if (Number.isFinite(parsed)) nextPage = parsed;
442
+ }}
443
+
444
+ updatePager(root, nextPage);
445
+ }}
446
+
447
+ function bindWhenMapReady() {{
448
+ const map = window[MAP_NAME];
449
+ if (!map) {{
450
+ setTimeout(bindWhenMapReady, 50);
451
+ return;
452
+ }}
453
+ if (map.__mesaPopupPagerBound) return;
454
+ map.__mesaPopupPagerBound = true;
455
+
456
+ const mapContainer = typeof map.getContainer === 'function' ? map.getContainer() : null;
457
+ const doc = mapContainer && mapContainer.ownerDocument ? mapContainer.ownerDocument : document;
458
+ doc.addEventListener('click', handleClick, true);
459
+
460
+ map.on('popupopen', function(evt) {{
461
+ const popup = evt && evt.popup ? evt.popup : null;
462
+ const contentNode = popup && popup._contentNode ? popup._contentNode : null;
463
+ if (!contentNode || !contentNode.querySelector) return;
464
+ const root = contentNode.querySelector('[data-pager]');
465
+ if (!root) return;
466
+ const initialRaw = parseInt(root.dataset.currentPage || '1', 10);
467
+ const initialPage = Number.isFinite(initialRaw) && initialRaw > 0 ? initialRaw : 1;
468
+ updatePager(root, initialPage);
469
+ }});
470
+
471
+ let resizeTimer = null;
472
+ window.addEventListener('resize', function() {{
473
+ if (resizeTimer) window.clearTimeout(resizeTimer);
474
+ resizeTimer = window.setTimeout(function() {{
475
+ const pagers = doc.querySelectorAll('[data-pager]');
476
+ pagers.forEach(function(root) {{
477
+ const currentRaw = parseInt(root.dataset.currentPage || '1', 10);
478
+ const current = Number.isFinite(currentRaw) && currentRaw > 0 ? currentRaw : 1;
479
+ updatePager(root, current);
480
+ }});
481
+ }}, 70);
482
+ }});
483
+ }}
484
+
485
+ bindWhenMapReady();
486
+ }})();
487
+ </script>
488
+ """
489
+ mapa.get_root().html.add_child(Element(script))
490
+ setattr(mapa, "_mesa_popup_pager_script", True)
backend/app/core/visualizacao/app.py CHANGED
@@ -23,7 +23,12 @@ import math
23
 
24
  from app.core.elaboracao.core import avaliar_imovel, _migrar_pacote_v1_para_v2, exportar_avaliacoes_excel
25
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
26
- from app.core.map_layers import add_bairros_layer, add_indice_marker, add_zoom_responsive_circle_markers
 
 
 
 
 
27
 
28
  # ============================================================
29
  # CONSTANTES
@@ -692,12 +697,15 @@ def _aplicar_jitter_sobrepostos(df_mapa, lat_col, lon_col, lat_plot_col, lon_plo
692
  max_raio_metros = 22.0
693
  metros_por_grau_lat = 111_320.0
694
 
 
 
 
695
  for _, idx_labels in grupos.indices.items():
696
- if len(idx_labels) <= 1:
 
697
  continue
698
- idx_list = list(idx_labels)
699
- base_lat = float(df_plot.at[idx_list[0], lat_plot_col])
700
- base_lon = float(df_plot.at[idx_list[0], lon_plot_col])
701
  if not np.isfinite(base_lat) or not np.isfinite(base_lon):
702
  continue
703
 
@@ -706,7 +714,7 @@ def _aplicar_jitter_sobrepostos(df_mapa, lat_col, lon_col, lat_plot_col, lon_plo
706
  cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
707
  metros_por_grau_lon = metros_por_grau_lat * cos_lat
708
 
709
- for pos, idx_label in enumerate(idx_list):
710
  if pos == 0:
711
  continue
712
 
@@ -723,13 +731,21 @@ def _aplicar_jitter_sobrepostos(df_mapa, lat_col, lon_col, lat_plot_col, lon_plo
723
  delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
724
  delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
725
 
726
- df_plot.at[idx_label, lat_plot_col] = base_lat + delta_lat
727
- df_plot.at[idx_label, lon_plot_col] = base_lon + delta_lon
728
 
729
  return df_plot
730
 
731
 
732
  def _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8):
 
 
 
 
 
 
 
 
733
  if not itens:
734
  html = (
735
  "<div style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden;\">"
@@ -739,66 +755,77 @@ def _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8):
739
  )
740
  return html, 360
741
 
742
- paginas = [itens[i:i + max_itens_pagina] for i in range(0, len(itens), max_itens_pagina)]
743
- pages_html = []
744
- botoes_html = []
 
 
 
 
 
 
 
 
 
745
 
 
746
  for page_idx, page_items in enumerate(paginas):
747
- trs = "".join([
748
- "<tr style='border-bottom:1px solid #e9ecef;'>"
749
- f"<td style='padding:4px 8px 4px 0; color:#6c757d; font-weight:500;'>{escape(str(c))}</td>"
750
- f"<td style='padding:4px 0; text-align:right; color:#495057;'>{escape(str(v))}</td>"
751
- "</tr>"
752
- for c, v in page_items
753
- ])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754
  display = "block" if page_idx == 0 else "none"
755
  pages_html.append(
756
- f"<div class='mesa-popup-page' data-page='{page_idx}' style='display:{display};'>"
757
- f"<table style='border-collapse:collapse; font-size:12px; width:100%;'>{trs}</table>"
758
  "</div>"
759
  )
760
- botao_style = (
761
- "border:1px solid #9fb4c8; background:#eaf1f7; border-radius:6px; "
762
- "padding:2px 7px; font-size:11px; cursor:pointer; color:#2f4b66;"
763
- if page_idx == 0
764
- else
765
- "border:1px solid #ced8e2; background:#fff; border-radius:6px; "
766
- "padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;"
767
- )
768
- onclick = (
769
- f"var root=document.getElementById('{popup_uid}');"
770
- "if(!root){return false;}"
771
- "var pages=root.querySelectorAll('.mesa-popup-page');"
772
- "for(var i=0;i<pages.length;i++){pages[i].style.display=(i==="
773
- f"{page_idx}"
774
- ")?'block':'none';}"
775
- "var btns=root.querySelectorAll('[data-page-btn]');"
776
- "for(var j=0;j<btns.length;j++){btns[j].style.background='#fff';btns[j].style.borderColor='#ced8e2';btns[j].style.color='#4e6479';}"
777
- "this.style.background='#eaf1f7';this.style.borderColor='#9fb4c8';this.style.color='#2f4b66';"
778
- "return false;"
779
- )
780
- botoes_html.append(
781
- f"<button type='button' data-page-btn='1' style=\"{botao_style}\" onclick=\"{onclick}\">"
782
- f"{page_idx + 1}</button>"
783
- )
784
 
785
  controls_html = ""
786
  if len(paginas) > 1:
787
  controls_html = (
788
- "<div class='mesa-popup-controls' style='display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; align-items:center;'>"
789
- f"<span style='font-size:11px; color:#5d7388; margin-right:2px;'>Páginas:</span>{''.join(botoes_html)}"
 
 
 
 
790
  "</div>"
791
  )
792
 
793
  html = (
794
- f"<div id='{popup_uid}' style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden;\">"
 
795
  "<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
796
  "<div style=\"padding:12px 15px; background:#f8f9fa;\">"
797
- f"<div class='mesa-popup-pages'>{''.join(pages_html)}</div>"
798
  f"{controls_html}"
799
  "</div></div>"
800
  )
801
- return html, 430
802
 
803
  # ============================================================
804
  # FUNÇÃO: GERAR MAPA FOLIUM (com suporte a dimensionamento por variável)
@@ -1029,6 +1056,7 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
1029
  secondary_area_unit='hectares'
1030
  ).add_to(m)
1031
  add_zoom_responsive_circle_markers(m)
 
1032
 
1033
  # Ajusta bounds robustos para evitar "salto" por pontos geocodificados distantes.
1034
  df_bounds = df_mapa
 
23
 
24
  from app.core.elaboracao.core import avaliar_imovel, _migrar_pacote_v1_para_v2, exportar_avaliacoes_excel
25
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
26
+ from app.core.map_layers import (
27
+ add_bairros_layer,
28
+ add_indice_marker,
29
+ add_popup_pagination_handlers,
30
+ add_zoom_responsive_circle_markers,
31
+ )
32
 
33
  # ============================================================
34
  # CONSTANTES
 
697
  max_raio_metros = 22.0
698
  metros_por_grau_lat = 111_320.0
699
 
700
+ lat_plot_pos = int(df_plot.columns.get_loc(lat_plot_col))
701
+ lon_plot_pos = int(df_plot.columns.get_loc(lon_plot_col))
702
+
703
  for _, idx_labels in grupos.indices.items():
704
+ posicoes = np.asarray(idx_labels, dtype=int)
705
+ if posicoes.size <= 1:
706
  continue
707
+ base_lat = float(df_plot.iat[int(posicoes[0]), lat_plot_pos])
708
+ base_lon = float(df_plot.iat[int(posicoes[0]), lon_plot_pos])
 
709
  if not np.isfinite(base_lat) or not np.isfinite(base_lon):
710
  continue
711
 
 
714
  cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
715
  metros_por_grau_lon = metros_por_grau_lat * cos_lat
716
 
717
+ for pos, pos_idx in enumerate(posicoes):
718
  if pos == 0:
719
  continue
720
 
 
731
  delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
732
  delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
733
 
734
+ df_plot.iat[int(pos_idx), lat_plot_pos] = base_lat + delta_lat
735
+ df_plot.iat[int(pos_idx), lon_plot_pos] = base_lon + delta_lon
736
 
737
  return df_plot
738
 
739
 
740
  def _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8):
741
+ def _limitar_texto(valor: str, limite: int) -> str:
742
+ txt = str(valor)
743
+ if limite <= 0 or len(txt) <= limite:
744
+ return txt
745
+ if limite <= 3:
746
+ return txt[:limite]
747
+ return txt[: limite - 3] + "..."
748
+
749
  if not itens:
750
  html = (
751
  "<div style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden;\">"
 
755
  )
756
  return html, 360
757
 
758
+ max_colunas_por_pagina = 2
759
+ max_chars_chave = 20
760
+ max_chars_valor = 20
761
+ char_px = 7.2
762
+ gap_cols_px = 12
763
+ popup_padding_horizontal_px = 30
764
+ coluna_largura_px = int(round((max_chars_chave + max_chars_valor) * char_px + 28))
765
+ itens_por_pagina = max_itens_pagina * max_colunas_por_pagina
766
+ paginas = [itens[i:i + itens_por_pagina] for i in range(0, len(itens), itens_por_pagina)]
767
+ itens_primeira_pagina = len(paginas[0]) if paginas else 0
768
+ colunas_visiveis = max(1, min(max_colunas_por_pagina, int(math.ceil(itens_primeira_pagina / max_itens_pagina)) if itens_primeira_pagina else 1))
769
+ popup_largura_px = popup_padding_horizontal_px + (coluna_largura_px * colunas_visiveis) + (gap_cols_px * (colunas_visiveis - 1))
770
 
771
+ pages_html = []
772
  for page_idx, page_items in enumerate(paginas):
773
+ colunas = [page_items[i:i + max_itens_pagina] for i in range(0, len(page_items), max_itens_pagina)]
774
+ colunas_html = []
775
+ for col_itens in colunas:
776
+ trs_parts = []
777
+ for c, v in col_itens:
778
+ c_full = str(c)
779
+ v_full = str(v)
780
+ c_txt = escape(_limitar_texto(c_full, max_chars_chave))
781
+ v_txt = escape(_limitar_texto(v_full, max_chars_valor))
782
+ c_title = escape(c_full)
783
+ v_title = escape(v_full)
784
+ trs_parts.append(
785
+ "<tr style='border-bottom:1px solid #e9ecef;'>"
786
+ "<td style='padding:4px 8px 4px 0; color:#6c757d; font-weight:500; width:50%; max-width:50%; overflow:hidden; white-space:nowrap;'>"
787
+ f"<span title='{c_title}'>{c_txt}</span>"
788
+ "</td>"
789
+ "<td style='padding:4px 0; text-align:right; color:#495057; width:50%; max-width:50%; overflow:hidden; white-space:nowrap;'>"
790
+ f"<span title='{v_title}'>{v_txt}</span>"
791
+ "</td>"
792
+ "</tr>"
793
+ )
794
+ trs = "".join(trs_parts)
795
+ colunas_html.append(
796
+ f"<div style='flex:0 0 {coluna_largura_px}px; width:{coluna_largura_px}px; min-width:{coluna_largura_px}px; overflow:hidden;'>"
797
+ f"<table style='border-collapse:collapse; table-layout:fixed; font-size:12px; width:{coluna_largura_px}px;'>{trs}</table>"
798
+ "</div>"
799
+ )
800
  display = "block" if page_idx == 0 else "none"
801
  pages_html.append(
802
+ f"<div class='mesa-popup-page' data-page='{page_idx + 1}' style='display:{display};'>"
803
+ f"<div style='display:flex; gap:12px; align-items:flex-start; flex-wrap:nowrap;'>{''.join(colunas_html)}</div>"
804
  "</div>"
805
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
806
 
807
  controls_html = ""
808
  if len(paginas) > 1:
809
  controls_html = (
810
+ "<div class='mesa-popup-controls' style='display:flex; gap:5px; flex-wrap:nowrap; margin-top:8px; align-items:center; justify-content:center; white-space:nowrap; width:100%;'>"
811
+ f"<button type='button' data-page-nav='first' data-a='first' style=\"border:1px solid #ced8e2; background:#fff; border-radius:6px; padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;\">&laquo;</button>"
812
+ f"<button type='button' data-page-nav='prev' data-a='prev' style=\"border:1px solid #ced8e2; background:#fff; border-radius:6px; padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;\">&lsaquo;</button>"
813
+ "<div data-page-number-wrap='1' style='display:flex; gap:5px; align-items:center; justify-content:center; flex-wrap:nowrap;'></div>"
814
+ f"<button type='button' data-page-nav='next' data-a='next' style=\"border:1px solid #ced8e2; background:#fff; border-radius:6px; padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;\">&rsaquo;</button>"
815
+ f"<button type='button' data-page-nav='last' data-a='last' style=\"border:1px solid #ced8e2; background:#fff; border-radius:6px; padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;\">&raquo;</button>"
816
  "</div>"
817
  )
818
 
819
  html = (
820
+ f"<div id='{popup_uid}' data-pager='1' data-current-page='1' data-page-start='1' data-page-window='1' "
821
+ f"style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden; width:{popup_largura_px}px; max-width:{popup_largura_px}px;\">"
822
  "<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
823
  "<div style=\"padding:12px 15px; background:#f8f9fa;\">"
824
+ f"<div class='mesa-popup-pages' style='width:100%;'>{''.join(pages_html)}</div>"
825
  f"{controls_html}"
826
  "</div></div>"
827
  )
828
+ return html, popup_largura_px
829
 
830
  # ============================================================
831
  # FUNÇÃO: GERAR MAPA FOLIUM (com suporte a dimensionamento por variável)
 
1056
  secondary_area_unit='hectares'
1057
  ).add_to(m)
1058
  add_zoom_responsive_circle_markers(m)
1059
+ add_popup_pagination_handlers(m)
1060
 
1061
  # Ajusta bounds robustos para evitar "salto" por pontos geocodificados distantes.
1062
  df_bounds = df_mapa
frontend/src/App.jsx CHANGED
@@ -22,6 +22,7 @@ export default function App() {
22
  const [showStartupIntro, setShowStartupIntro] = useState(true)
23
  const [sessionId, setSessionId] = useState('')
24
  const [bootError, setBootError] = useState('')
 
25
 
26
  const [authLoading, setAuthLoading] = useState(true)
27
  const [authUser, setAuthUser] = useState(null)
@@ -241,6 +242,18 @@ export default function App() {
241
  setLogsOpen(false)
242
  }
243
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  return (
245
  <div className="app-shell">
246
  <header className="app-header app-header-logo-only">
@@ -453,7 +466,7 @@ export default function App() {
453
  ) : null}
454
 
455
  <div className="tab-pane" hidden={activeTab !== 'Pesquisa'}>
456
- <PesquisaTab sessionId={sessionId} />
457
  </div>
458
 
459
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
@@ -469,7 +482,7 @@ export default function App() {
469
  </div>
470
 
471
  <div className="tab-pane" hidden={activeTab !== 'Avaliação Beta'}>
472
- <AvaliacaoBetaTab sessionId={sessionId} />
473
  </div>
474
  </>
475
  )
 
22
  const [showStartupIntro, setShowStartupIntro] = useState(true)
23
  const [sessionId, setSessionId] = useState('')
24
  const [bootError, setBootError] = useState('')
25
+ const [avaliacaoBetaQuickLoad, setAvaliacaoBetaQuickLoad] = useState(null)
26
 
27
  const [authLoading, setAuthLoading] = useState(true)
28
  const [authUser, setAuthUser] = useState(null)
 
242
  setLogsOpen(false)
243
  }
244
 
245
+ function onUsarModeloEmAvaliacao(modelo) {
246
+ const modeloId = String(modelo?.id || '').trim()
247
+ if (!modeloId) return
248
+ setAvaliacaoBetaQuickLoad({
249
+ requestKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
250
+ modeloId,
251
+ nomeModelo: String(modelo?.nome_modelo || modelo?.arquivo || modeloId),
252
+ })
253
+ setActiveTab('Avaliação Beta')
254
+ setShowStartupIntro(false)
255
+ }
256
+
257
  return (
258
  <div className="app-shell">
259
  <header className="app-header app-header-logo-only">
 
466
  ) : null}
467
 
468
  <div className="tab-pane" hidden={activeTab !== 'Pesquisa'}>
469
+ <PesquisaTab sessionId={sessionId} onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao} />
470
  </div>
471
 
472
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
 
482
  </div>
483
 
484
  <div className="tab-pane" hidden={activeTab !== 'Avaliação Beta'}>
485
+ <AvaliacaoBetaTab sessionId={sessionId} quickLoadRequest={avaliacaoBetaQuickLoad} />
486
  </div>
487
  </>
488
  )
frontend/src/components/AvaliacaoBetaTab.jsx CHANGED
@@ -63,7 +63,7 @@ function formatarFonteRepositorio(fonte) {
63
  return 'Fonte: pasta local'
64
  }
65
 
66
- export default function AvaliacaoBetaTab({ sessionId }) {
67
  const [loading, setLoading] = useState(false)
68
  const [error, setError] = useState('')
69
  const [status, setStatus] = useState('')
@@ -88,6 +88,7 @@ export default function AvaliacaoBetaTab({ sessionId }) {
88
  const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
89
 
90
  const uploadInputRef = useRef(null)
 
91
 
92
  const repoModeloOptions = useMemo(
93
  () => (repoModelos || []).map((item) => ({
@@ -206,14 +207,16 @@ export default function AvaliacaoBetaTab({ sessionId }) {
206
  })
207
  }
208
 
209
- async function onCarregarModeloRepositorio() {
210
- if (!sessionId || !repoModeloSelecionado) return
 
211
  setModeloLoadSource('repo')
 
212
  await withBusy(async () => {
213
- const uploadResp = await api.visualizacaoRepositorioCarregar(sessionId, repoModeloSelecionado)
214
  setStatus(uploadResp?.status || '')
215
  setBadgeHtml(uploadResp?.badge_html || '')
216
- const modeloSelecionado = repoModeloOptions.find((item) => item.value === repoModeloSelecionado)
217
  const nomeModelo = uploadResp?.nome_modelo || modeloSelecionado?.label || ''
218
  const exibirResp = await api.exibirVisualizacao(sessionId)
219
  aplicarRespostaExibicao(exibirResp, nomeModelo)
@@ -221,6 +224,17 @@ export default function AvaliacaoBetaTab({ sessionId }) {
221
  })
222
  }
223
 
 
 
 
 
 
 
 
 
 
 
 
224
  function onUploadInputChange(event) {
225
  const input = event.target
226
  const file = input.files?.[0] ?? null
 
63
  return 'Fonte: pasta local'
64
  }
65
 
66
+ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null }) {
67
  const [loading, setLoading] = useState(false)
68
  const [error, setError] = useState('')
69
  const [status, setStatus] = useState('')
 
88
  const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
89
 
90
  const uploadInputRef = useRef(null)
91
+ const quickLoadHandledRef = useRef('')
92
 
93
  const repoModeloOptions = useMemo(
94
  () => (repoModelos || []).map((item) => ({
 
207
  })
208
  }
209
 
210
+ async function onCarregarModeloRepositorio(modeloIdOverride = '') {
211
+ const alvoModeloId = String(modeloIdOverride || repoModeloSelecionado || '').trim()
212
+ if (!sessionId || !alvoModeloId) return
213
  setModeloLoadSource('repo')
214
+ setRepoModeloSelecionado(alvoModeloId)
215
  await withBusy(async () => {
216
+ const uploadResp = await api.visualizacaoRepositorioCarregar(sessionId, alvoModeloId)
217
  setStatus(uploadResp?.status || '')
218
  setBadgeHtml(uploadResp?.badge_html || '')
219
+ const modeloSelecionado = repoModeloOptions.find((item) => item.value === alvoModeloId)
220
  const nomeModelo = uploadResp?.nome_modelo || modeloSelecionado?.label || ''
221
  const exibirResp = await api.exibirVisualizacao(sessionId)
222
  aplicarRespostaExibicao(exibirResp, nomeModelo)
 
224
  })
225
  }
226
 
227
+ useEffect(() => {
228
+ const requestKey = String(quickLoadRequest?.requestKey || '').trim()
229
+ const modeloId = String(quickLoadRequest?.modeloId || '').trim()
230
+ if (!sessionId || !requestKey || !modeloId) return
231
+ if (quickLoadHandledRef.current === requestKey) return
232
+ quickLoadHandledRef.current = requestKey
233
+ setModeloLoadSource('repo')
234
+ setRepoModeloSelecionado(modeloId)
235
+ void onCarregarModeloRepositorio(modeloId)
236
+ }, [quickLoadRequest, sessionId])
237
+
238
  function onUploadInputChange(event) {
239
  const input = event.target
240
  const file = input.files?.[0] ?? null
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -452,7 +452,7 @@ function ChipAutocompleteInput({
452
  )
453
  }
454
 
455
- export default function PesquisaTab({ sessionId }) {
456
  const [loading, setLoading] = useState(false)
457
  const [error, setError] = useState('')
458
  const [pesquisaInicializada, setPesquisaInicializada] = useState(false)
@@ -490,6 +490,7 @@ export default function PesquisaTab({ sessionId }) {
490
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
491
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
492
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
 
493
 
494
  const sugestoes = result.sugestoes || {}
495
  const opcoesTipoModelo = useMemo(
@@ -502,6 +503,31 @@ export default function PesquisaTab({ sessionId }) {
502
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
503
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
504
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  async function buscarModelos(nextFilters = filters) {
506
  setLoading(true)
507
  setError('')
@@ -598,6 +624,16 @@ export default function PesquisaTab({ sessionId }) {
598
  })
599
  }
600
 
 
 
 
 
 
 
 
 
 
 
601
  function preencherModeloAberto(resp) {
602
  setModeloAbertoDados(resp?.dados || null)
603
  setModeloAbertoEstatisticas(resp?.estatisticas || null)
@@ -633,6 +669,9 @@ export default function PesquisaTab({ sessionId }) {
633
  id: modelo.id,
634
  nome: modelo.nome_modelo || modelo.arquivo || modelo.id,
635
  })
 
 
 
636
  } catch (err) {
637
  setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
638
  } finally {
@@ -644,6 +683,7 @@ export default function PesquisaTab({ sessionId }) {
644
  setModeloAbertoMeta(null)
645
  setModeloAbertoError('')
646
  setModeloAbertoActiveTab('mapa')
 
647
  }
648
 
649
  async function onModeloAbertoMapChange(nextVar) {
@@ -938,11 +978,12 @@ export default function PesquisaTab({ sessionId }) {
938
  {error ? <div className="error-line inline-error">{error}</div> : null}
939
  </SectionBlock>
940
 
941
- <SectionBlock
942
- step="2"
943
- title="Resultados"
944
- subtitle="Modelos aceitos para os parametros do avaliando informado."
945
- >
 
946
  <div className="pesquisa-results-toolbar">
947
  <div className="pesquisa-summary-line">
948
  <strong>{formatCount(result.total_filtrado)}</strong>{' '}
@@ -969,21 +1010,21 @@ export default function PesquisaTab({ sessionId }) {
969
  return (
970
  <article key={modelo.id} className={`pesquisa-card${selecionado ? ' is-selected' : ''}`}>
971
  <div className="pesquisa-card-top">
972
- <div className="pesquisa-card-head">
973
- <h4>{modelo.nome_modelo || modelo.arquivo}</h4>
974
- <div className="pesquisa-card-controls">
975
- <label className="pesquisa-select-toggle pesquisa-select-toggle-compact">
976
- <input
977
- type="checkbox"
978
- checked={selecionado}
979
- onChange={() => onToggleSelecionado(modelo.id)}
980
- />
981
- Selecionar
982
- </label>
983
- <button type="button" className="btn-pesquisa-open" onClick={() => void onAbrirModelo(modelo)}>
984
- Abrir
985
- </button>
986
- </div>
987
  </div>
988
  <div className="pesquisa-card-body">
989
  <div className="pesquisa-card-dados-list">
@@ -1005,7 +1046,8 @@ export default function PesquisaTab({ sessionId }) {
1005
  })}
1006
  </div>
1007
  )}
1008
- </SectionBlock>
 
1009
 
1010
  <SectionBlock step="3" title="Mapa" subtitle="Plote os modelos selecionados com cores distintas e legenda.">
1011
  <div className="pesquisa-summary-line">
 
452
  )
453
  }
454
 
455
+ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null }) {
456
  const [loading, setLoading] = useState(false)
457
  const [error, setError] = useState('')
458
  const [pesquisaInicializada, setPesquisaInicializada] = useState(false)
 
490
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
491
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
492
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
493
+ const sectionResultadosRef = useRef(null)
494
 
495
  const sugestoes = result.sugestoes || {}
496
  const opcoesTipoModelo = useMemo(
 
503
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
504
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
505
 
506
+ function scrollToElementTop(el, behavior = 'smooth', offsetPx = 0) {
507
+ if (!el || typeof window === 'undefined') return
508
+ const rect = el.getBoundingClientRect()
509
+ const targetTop = Math.max(0, window.scrollY + rect.top - offsetPx)
510
+ window.scrollTo({ top: targetTop, behavior })
511
+ }
512
+
513
+ function scrollParaAbasGeraisNoTopo() {
514
+ if (typeof document === 'undefined') return
515
+ const tabsEl = document.querySelector('.tabs')
516
+ if (tabsEl) {
517
+ scrollToElementTop(tabsEl, 'smooth', 0)
518
+ return
519
+ }
520
+ window.scrollTo({ top: 0, behavior: 'smooth' })
521
+ }
522
+
523
+ function scrollParaResultadosNoTopo() {
524
+ window.requestAnimationFrame(() => {
525
+ window.requestAnimationFrame(() => {
526
+ scrollToElementTop(sectionResultadosRef.current, 'smooth', 0)
527
+ })
528
+ })
529
+ }
530
+
531
  async function buscarModelos(nextFilters = filters) {
532
  setLoading(true)
533
  setError('')
 
624
  })
625
  }
626
 
627
+ function onUsarEmAvaliacao(modelo) {
628
+ if (!sessionId) {
629
+ setError('Sessao indisponivel no momento. Aguarde e tente novamente.')
630
+ return
631
+ }
632
+ if (typeof onUsarModeloEmAvaliacao === 'function') {
633
+ onUsarModeloEmAvaliacao(modelo)
634
+ }
635
+ }
636
+
637
  function preencherModeloAberto(resp) {
638
  setModeloAbertoDados(resp?.dados || null)
639
  setModeloAbertoEstatisticas(resp?.estatisticas || null)
 
669
  id: modelo.id,
670
  nome: modelo.nome_modelo || modelo.arquivo || modelo.id,
671
  })
672
+ window.requestAnimationFrame(() => {
673
+ scrollParaAbasGeraisNoTopo()
674
+ })
675
  } catch (err) {
676
  setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
677
  } finally {
 
683
  setModeloAbertoMeta(null)
684
  setModeloAbertoError('')
685
  setModeloAbertoActiveTab('mapa')
686
+ scrollParaResultadosNoTopo()
687
  }
688
 
689
  async function onModeloAbertoMapChange(nextVar) {
 
978
  {error ? <div className="error-line inline-error">{error}</div> : null}
979
  </SectionBlock>
980
 
981
+ <div ref={sectionResultadosRef}>
982
+ <SectionBlock
983
+ step="2"
984
+ title="Resultados"
985
+ subtitle="Modelos aceitos para os parametros do avaliando informado."
986
+ >
987
  <div className="pesquisa-results-toolbar">
988
  <div className="pesquisa-summary-line">
989
  <strong>{formatCount(result.total_filtrado)}</strong>{' '}
 
1010
  return (
1011
  <article key={modelo.id} className={`pesquisa-card${selecionado ? ' is-selected' : ''}`}>
1012
  <div className="pesquisa-card-top">
1013
+ <h4 className="pesquisa-card-title">{modelo.nome_modelo || modelo.arquivo}</h4>
1014
+ <div className="pesquisa-card-actions">
1015
+ <button
1016
+ type="button"
1017
+ className={`btn-pesquisa-map-toggle${selecionado ? ' is-selected' : ''}`}
1018
+ onClick={() => onToggleSelecionado(modelo.id)}
1019
+ >
1020
+ {selecionado ? 'Desmarcar para mapa' : 'Marcar para mapa'}
1021
+ </button>
1022
+ <button type="button" className="btn-pesquisa-open" onClick={() => void onAbrirModelo(modelo)}>
1023
+ Abrir modelo
1024
+ </button>
1025
+ <button type="button" className="btn-pesquisa-eval" onClick={() => onUsarEmAvaliacao(modelo)}>
1026
+ Usar em avaliação
1027
+ </button>
1028
  </div>
1029
  <div className="pesquisa-card-body">
1030
  <div className="pesquisa-card-dados-list">
 
1046
  })}
1047
  </div>
1048
  )}
1049
+ </SectionBlock>
1050
+ </div>
1051
 
1052
  <SectionBlock step="3" title="Mapa" subtitle="Plote os modelos selecionados com cores distintas e legenda.">
1053
  <div className="pesquisa-summary-line">
frontend/src/styles.css CHANGED
@@ -1675,82 +1675,67 @@ button.pesquisa-coluna-remove:hover {
1675
  .pesquisa-card-top {
1676
  display: flex;
1677
  flex-direction: column;
1678
- gap: 8px;
1679
  min-width: 0;
1680
  flex: 1 1 auto;
1681
  }
1682
 
1683
- .pesquisa-card-head {
1684
- display: flex;
1685
- align-items: flex-start;
1686
- justify-content: space-between;
1687
- gap: 14px;
1688
- min-width: 0;
1689
- }
1690
-
1691
- .pesquisa-card-head h4 {
1692
  margin: 0;
1693
  font-family: 'Sora', sans-serif;
1694
  color: #2e4358;
1695
- font-size: 0.93rem;
1696
  line-height: 1.32;
1697
  overflow-wrap: anywhere;
1698
- flex: 1 1 auto;
1699
- }
1700
-
1701
- .pesquisa-card-head p {
1702
- margin: 3px 0 0;
1703
- color: #5f758b;
1704
- font-size: 0.8rem;
1705
- line-height: 1.35;
1706
- overflow-wrap: anywhere;
1707
  }
1708
 
1709
- .pesquisa-card-controls {
1710
- display: inline-flex;
1711
- align-items: center;
1712
- justify-content: flex-end;
1713
  gap: 8px;
1714
- flex: 0 0 auto;
1715
- align-self: center;
1716
  }
1717
 
1718
- .btn-pesquisa-open {
1719
- --btn-bg-start: #2ea94f;
1720
- --btn-bg-end: #238a40;
1721
- --btn-border: #1b7435;
1722
- --btn-shadow-soft: rgba(35, 138, 64, 0.22);
1723
- --btn-shadow-strong: rgba(35, 138, 64, 0.3);
1724
- min-width: 74px;
1725
  min-height: 32px;
1726
  padding: 6px 10px;
1727
  font-size: 0.78rem;
1728
  letter-spacing: 0.02em;
1729
  }
1730
 
1731
- .pesquisa-select-toggle {
1732
- display: inline-flex;
1733
- align-items: center;
1734
- gap: 7px;
1735
- border: 1px solid #d8e4ef;
1736
- border-radius: 9px;
1737
- background: #f5f9fd;
1738
- padding: 5px 8px;
1739
- width: fit-content;
1740
- max-width: 100%;
1741
- font-size: 0.8rem;
1742
- font-weight: 700;
1743
- color: #48627b;
1744
  }
1745
 
1746
- .pesquisa-select-toggle-compact {
1747
- min-height: 32px;
1748
- padding: 4px 8px;
1749
- font-size: 0.78rem;
 
 
 
1750
  }
1751
 
1752
- .pesquisa-select-toggle input {
1753
- margin: 0;
 
 
 
 
 
 
 
 
 
 
 
 
1754
  }
1755
 
1756
  .pesquisa-card-status-row .status-pill {
@@ -4546,14 +4531,8 @@ button.btn-download-subtle {
4546
  scrollbar-width: thin;
4547
  }
4548
 
4549
- .pesquisa-card-head {
4550
- flex-direction: column;
4551
- }
4552
-
4553
- .pesquisa-card-controls {
4554
- width: 100%;
4555
- justify-content: flex-start;
4556
- align-self: flex-start;
4557
  }
4558
 
4559
  .pesquisa-results-toolbar {
 
1675
  .pesquisa-card-top {
1676
  display: flex;
1677
  flex-direction: column;
1678
+ gap: 10px;
1679
  min-width: 0;
1680
  flex: 1 1 auto;
1681
  }
1682
 
1683
+ .pesquisa-card-title {
 
 
 
 
 
 
 
 
1684
  margin: 0;
1685
  font-family: 'Sora', sans-serif;
1686
  color: #2e4358;
1687
+ font-size: 0.94rem;
1688
  line-height: 1.32;
1689
  overflow-wrap: anywhere;
 
 
 
 
 
 
 
 
 
1690
  }
1691
 
1692
+ .pesquisa-card-actions {
1693
+ display: grid;
1694
+ grid-template-columns: repeat(3, minmax(0, 1fr));
 
1695
  gap: 8px;
1696
+ min-width: 0;
 
1697
  }
1698
 
1699
+ .pesquisa-card-actions button {
1700
+ width: 100%;
 
 
 
 
 
1701
  min-height: 32px;
1702
  padding: 6px 10px;
1703
  font-size: 0.78rem;
1704
  letter-spacing: 0.02em;
1705
  }
1706
 
1707
+ .btn-pesquisa-map-toggle {
1708
+ --btn-bg-start: #edf4fb;
1709
+ --btn-bg-end: #dfeaf5;
1710
+ --btn-border: #b9ccdf;
1711
+ --btn-shadow-soft: rgba(88, 116, 144, 0.12);
1712
+ --btn-shadow-strong: rgba(88, 116, 144, 0.18);
1713
+ color: #35506a;
 
 
 
 
 
 
1714
  }
1715
 
1716
+ .btn-pesquisa-map-toggle.is-selected {
1717
+ --btn-bg-start: #f8bd74;
1718
+ --btn-bg-end: #f1a047;
1719
+ --btn-border: #e28a29;
1720
+ --btn-shadow-soft: rgba(226, 138, 41, 0.18);
1721
+ --btn-shadow-strong: rgba(226, 138, 41, 0.25);
1722
+ color: #fff;
1723
  }
1724
 
1725
+ .btn-pesquisa-open {
1726
+ --btn-bg-start: #2ea94f;
1727
+ --btn-bg-end: #238a40;
1728
+ --btn-border: #1b7435;
1729
+ --btn-shadow-soft: rgba(35, 138, 64, 0.22);
1730
+ --btn-shadow-strong: rgba(35, 138, 64, 0.3);
1731
+ }
1732
+
1733
+ .btn-pesquisa-eval {
1734
+ --btn-bg-start: #4e95cf;
1735
+ --btn-bg-end: #3d82be;
1736
+ --btn-border: #2e6d9f;
1737
+ --btn-shadow-soft: rgba(61, 130, 190, 0.2);
1738
+ --btn-shadow-strong: rgba(61, 130, 190, 0.28);
1739
  }
1740
 
1741
  .pesquisa-card-status-row .status-pill {
 
4531
  scrollbar-width: thin;
4532
  }
4533
 
4534
+ .pesquisa-card-actions {
4535
+ grid-template-columns: 1fr;
 
 
 
 
 
 
4536
  }
4537
 
4538
  .pesquisa-results-toolbar {