Guilherme Silberfarb Costa
Improve elaboracao interactions and pesquisa map behavior
b9bb1d5
from __future__ import annotations
import math
import re
import traceback
from datetime import datetime
from html import escape
import branca.colormap as cm
import folium
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from folium import plugins
from scipy import stats
from statsmodels.stats.outliers_influence import OLSInfluence
from app.core.map_layers import (
add_bairros_layer,
add_indice_marker,
add_popup_pagination_handlers,
add_trabalhos_tecnicos_markers,
add_zoom_responsive_circle_markers,
)
COR_PRINCIPAL = "#FF8C00"
COR_LINHA = "#dc3545"
def _criar_grafico_obs_calc(y_obs, y_calc, indices=None):
try:
fig = go.Figure()
scatter_args = dict(
x=y_calc,
y=y_obs,
mode="markers",
marker=dict(color=COR_PRINCIPAL, size=10, line=dict(color="black", width=1)),
name="Dados",
)
if indices is not None:
scatter_args["customdata"] = indices
scatter_args["hovertemplate"] = (
"<b>Índice:</b> %{customdata}<br><b>Calculado:</b> %{x:.2f}<br>"
"<b>Observado:</b> %{y:.2f}<extra></extra>"
)
else:
scatter_args["hovertemplate"] = (
"<b>Calculado:</b> %{x:.2f}<br><b>Observado:</b> %{y:.2f}<extra></extra>"
)
fig.add_trace(go.Scatter(**scatter_args))
min_val = min(min(y_obs), min(y_calc))
max_val = max(max(y_obs), max(y_calc))
margin = (max_val - min_val) * 0.05
fig.add_trace(
go.Scatter(
x=[min_val - margin, max_val + margin],
y=[min_val - margin, max_val + margin],
mode="lines",
line=dict(color=COR_LINHA, dash="dash", width=2),
name="Linha de identidade",
)
)
fig.update_layout(
title=dict(text="Valores Observados vs Calculados", x=0.5),
xaxis_title="Valores Calculados",
yaxis_title="Valores Observados",
showlegend=True,
plot_bgcolor="white",
margin=dict(l=60, r=40, t=60, b=60),
)
fig.update_xaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
fig.update_yaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
return fig
except Exception as exc:
print(f"Erro ao criar gráfico obs vs calc: {exc}")
return None
def _criar_grafico_residuos(y_calc, residuos, indices=None):
try:
fig = go.Figure()
scatter_args = dict(
x=y_calc,
y=residuos,
mode="markers",
marker=dict(color=COR_PRINCIPAL, size=10, line=dict(color="black", width=1)),
name="Resíduos",
)
if indices is not None:
scatter_args["customdata"] = indices
scatter_args["hovertemplate"] = (
"<b>Índice:</b> %{customdata}<br><b>Ajustado:</b> %{x:.2f}<br>"
"<b>Resíduo:</b> %{y:.4f}<extra></extra>"
)
else:
scatter_args["hovertemplate"] = (
"<b>Ajustado:</b> %{x:.2f}<br><b>Resíduo:</b> %{y:.4f}<extra></extra>"
)
fig.add_trace(go.Scatter(**scatter_args))
fig.add_hline(y=0, line_dash="dash", line_color=COR_LINHA, line_width=2)
fig.update_layout(
title=dict(text="Resíduos vs Valores Ajustados", x=0.5),
xaxis_title="Valores Ajustados",
yaxis_title="Resíduos",
showlegend=False,
plot_bgcolor="white",
margin=dict(l=60, r=40, t=60, b=60),
)
fig.update_xaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
fig.update_yaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
return fig
except Exception as exc:
print(f"Erro ao criar gráfico de resíduos: {exc}")
return None
def _criar_histograma_residuos(residuos):
try:
fig = go.Figure()
fig.add_trace(
go.Histogram(
x=residuos,
histnorm="probability density",
marker=dict(color=COR_PRINCIPAL, line=dict(color="black", width=1)),
opacity=0.7,
name="Resíduos",
)
)
mu, sigma = np.mean(residuos), np.std(residuos)
x_norm = np.linspace(min(residuos) - sigma, max(residuos) + sigma, 100)
y_norm = stats.norm.pdf(x_norm, mu, sigma)
fig.add_trace(
go.Scatter(
x=x_norm,
y=y_norm,
mode="lines",
line=dict(color=COR_LINHA, width=3),
name="Curva Normal",
)
)
fig.update_layout(
title=dict(text="Distribuição dos Resíduos", x=0.5),
xaxis_title="Resíduos",
yaxis_title="Densidade",
showlegend=True,
plot_bgcolor="white",
barmode="overlay",
margin=dict(l=60, r=40, t=60, b=60),
)
fig.update_xaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
fig.update_yaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
return fig
except Exception as exc:
print(f"Erro ao criar histograma: {exc}")
return None
def _criar_grafico_cook(modelos_sm):
try:
if modelos_sm is None:
return None
influence = OLSInfluence(modelos_sm)
cooks_d = influence.cooks_distance[0]
n = len(cooks_d)
indices = np.arange(1, n + 1)
limite = 4 / n
fig = go.Figure()
for idx, valor in zip(indices, cooks_d):
cor = COR_LINHA if valor > limite else COR_PRINCIPAL
fig.add_trace(
go.Scatter(
x=[idx, idx],
y=[0, valor],
mode="lines",
line=dict(color=cor, width=1.5),
showlegend=False,
hoverinfo="skip",
)
)
cores_pontos = [COR_LINHA if v > limite else COR_PRINCIPAL for v in cooks_d]
fig.add_trace(
go.Scatter(
x=indices,
y=cooks_d,
mode="markers",
marker=dict(color=cores_pontos, size=8, line=dict(color="black", width=1)),
name="Distância de Cook",
hovertemplate="Obs: %{x}<br>Cook: %{y:.4f}<extra></extra>",
)
)
fig.add_hline(
y=limite,
line_dash="dash",
line_color="gray",
annotation_text=f"4/n = {limite:.4f}",
annotation_position="top right",
)
fig.update_layout(
title=dict(text="Distância de Cook", x=0.5),
xaxis_title="Observação",
yaxis_title="Distância de Cook",
plot_bgcolor="white",
margin=dict(l=60, r=40, t=60, b=60),
)
fig.update_xaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
fig.update_yaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
return fig
except Exception as exc:
print(f"Erro ao criar gráfico de Cook: {exc}")
return None
def _criar_grafico_correlacao(modelos_sm, nome_y: str | None = None):
try:
if modelos_sm is None or not hasattr(modelos_sm, "model"):
return None
model = modelos_sm.model
X = model.exog
X_names = model.exog_names
y = model.endog
y_name_base = (
str(nome_y or "").strip()
or str(getattr(model, "endog_names", "Variável Dependente") or "").strip()
or "Variável Dependente"
)
y_name = y_name_base if re.search(r"\(Y\)$", y_name_base, flags=re.IGNORECASE) else f"{y_name_base} (Y)"
df_X = pd.DataFrame(X, columns=X_names)
df_X = df_X.drop(
columns=[c for c in df_X.columns if str(c).lower() in ("const", "intercept")],
errors="ignore",
)
df_y = pd.DataFrame({y_name: y})
df = pd.concat([df_y, df_X], axis=1).apply(pd.to_numeric, errors="coerce")
variancias = df.var(ddof=0)
df = df.loc[:, variancias.fillna(0) > 0]
if df.shape[1] < 2:
return None
corr = df.corr()
if corr.isnull().values.all():
return None
mask = np.eye(len(corr), dtype=bool)
corr = corr.where(~mask)
text = np.where(np.isnan(corr.values), "", np.round(corr.values, 2).astype(str))
fig = go.Figure(
go.Heatmap(
z=corr.values,
x=corr.columns,
y=corr.index,
text=text,
texttemplate="%{text}",
textfont=dict(size=10),
zmin=-1,
zmax=1,
zmid=0,
colorscale=[
[0.00, "rgb(103,0,31)"],
[0.08, "rgb(178,24,43)"],
[0.16, "rgb(214,96,77)"],
[0.24, "rgb(244,165,130)"],
[0.32, "rgb(253,219,199)"],
[0.45, "rgb(255,255,255)"],
[0.55, "rgb(255,255,255)"],
[0.68, "rgb(209,229,240)"],
[0.76, "rgb(146,197,222)"],
[0.84, "rgb(67,147,195)"],
[0.92, "rgb(33,102,172)"],
[1.00, "rgb(5,48,97)"],
],
colorbar=dict(title="Correlação"),
hovertemplate="%{x} × %{y}<br>ρ = %{z:.3f}<extra></extra>",
)
)
fig.add_shape(
type="line",
xref="paper",
yref="paper",
x0=0,
y0=1,
x1=1,
y1=0,
line=dict(color="rgba(0,0,0,0.35)", width=1),
layer="above",
)
fig.update_layout(
title=dict(text="Matriz de Correlação", x=0.5),
height=600,
template="plotly_white",
xaxis=dict(tickangle=45, showgrid=False),
yaxis=dict(autorange="reversed", showgrid=False),
)
return fig
except Exception as exc:
print(f"Erro na geração do gráfico: {exc}")
traceback.print_exc()
return None
def gerar_todos_graficos(pacote):
graficos = {"obs_calc": None, "residuos": None, "hist": None, "cook": None, "corr": None}
obs_calc = pacote["modelo"]["obs_calc"]
modelos_sm = pacote["modelo"]["sm"]
info_transf = pacote.get("transformacoes", {}).get("info") or []
nome_y = None
if isinstance(info_transf, list) and info_transf:
primeira_linha = str(info_transf[0] or "").strip()
if primeira_linha:
nome_y = primeira_linha.split(": ", 1)[0].strip() or None
y_obs = None
y_calc = None
residuos = None
indices = None
if obs_calc is not None and not obs_calc.empty:
cols_lower = {str(c).lower(): c for c in obs_calc.columns}
indices = obs_calc.index.values if obs_calc.index is not None else None
for nome in ["observado", "obs", "y_obs", "y", "valor_observado"]:
if nome in cols_lower:
y_obs = obs_calc[cols_lower[nome]].values
break
for nome in ["calculado", "calc", "y_calc", "y_hat", "previsto"]:
if nome in cols_lower:
y_calc = obs_calc[cols_lower[nome]].values
break
for nome in ["residuo", "residuos", "resid"]:
if nome in cols_lower:
residuos = obs_calc[cols_lower[nome]].values
break
if modelos_sm is not None:
try:
if y_obs is None and hasattr(modelos_sm.model, "endog"):
y_obs = modelos_sm.model.endog
if y_calc is None and hasattr(modelos_sm, "fittedvalues"):
y_calc = modelos_sm.fittedvalues
if residuos is None and hasattr(modelos_sm, "resid"):
residuos = modelos_sm.resid
except Exception:
pass
if residuos is None and y_obs is not None and y_calc is not None:
residuos = np.array(y_obs) - np.array(y_calc)
y_obs = np.array(y_obs) if y_obs is not None else None
y_calc = np.array(y_calc) if y_calc is not None else None
residuos = np.array(residuos) if residuos is not None else None
if y_obs is not None and y_calc is not None:
graficos["obs_calc"] = _criar_grafico_obs_calc(y_obs, y_calc, indices)
if residuos is not None and y_calc is not None:
graficos["residuos"] = _criar_grafico_residuos(y_calc, residuos, indices)
if residuos is not None:
graficos["hist"] = _criar_histograma_residuos(residuos)
if modelos_sm is not None:
graficos["cook"] = _criar_grafico_cook(modelos_sm)
graficos["corr"] = _criar_grafico_correlacao(modelos_sm, nome_y=nome_y)
return graficos
def reorganizar_modelos_resumos(diagnosticos):
gerais = diagnosticos.get("gerais", {})
return {
"estatisticas_gerais": {
"n": {"nome": "Número de observações", "valor": gerais.get("n")},
"k": {"nome": "Número de variáveis independentes", "valor": gerais.get("k")},
"desvio_padrao_residuos": {"nome": "Desvio padrão dos resíduos", "valor": gerais.get("desvio_padrao_residuos")},
"mse": {"nome": "MSE", "valor": gerais.get("mse")},
"r2": {"nome": "R²", "valor": gerais.get("r2")},
"r2_ajustado": {"nome": "R² ajustado", "valor": gerais.get("r2_ajustado")},
"r_pearson": {"nome": "Correlação Pearson", "valor": gerais.get("r_pearson")},
},
"teste_f": {
"nome": "Teste F",
"estatistica": diagnosticos.get("teste_f", {}).get("estatistica"),
"pvalor": diagnosticos.get("teste_f", {}).get("p_valor"),
"interpretacao": diagnosticos.get("teste_f", {}).get("interpretacao"),
},
"teste_ks": {
"nome": "Teste de Normalidade (Kolmogorov-Smirnov)",
"estatistica": diagnosticos.get("teste_ks", {}).get("estatistica"),
"pvalor": diagnosticos.get("teste_ks", {}).get("p_valor"),
"interpretacao": diagnosticos.get("teste_ks", {}).get("interpretacao"),
},
"perc_resid": {
"nome": "Teste de Normalidade (Comparação com a Curva Normal)",
"valor": diagnosticos.get("teste_normalidade", {}).get("percentuais"),
"interpretacao": [
"Ideal 68% → aceitável entre 64% e 75%",
"Ideal 90% → aceitável entre 88% e 95%",
"Ideal 95% → aceitável entre 95% e 100%",
],
},
"teste_dw": {
"nome": "Teste de Autocorrelação (Durbin-Watson)",
"estatistica": diagnosticos.get("teste_dw", {}).get("estatistica"),
"interpretacao": diagnosticos.get("teste_dw", {}).get("interpretacao"),
},
"teste_bp": {
"nome": "Teste de Homocedasticidade (Breusch-Pagan)",
"estatistica": diagnosticos.get("teste_bp", {}).get("estatistica"),
"pvalor": diagnosticos.get("teste_bp", {}).get("p_valor"),
"interpretacao": diagnosticos.get("teste_bp", {}).get("interpretacao"),
},
"equacao": diagnosticos.get("equacao"),
}
def formatar_monetario(valor):
if pd.isna(valor):
return "N/A"
return f"R$ {valor:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
def formatar_resumo_html(resumo_reorganizado):
def criar_titulo_secao(titulo):
return f'<div class="section-title-orange-solid">{titulo}</div>'
def criar_linha_campo(campo, valor):
return f'<div class="field-row"><span class="field-row-label">{campo}</span><span class="field-row-value">{valor}</span></div>'
def criar_linha_interpretacao(interpretacao):
return f'<div class="field-row"><span class="field-row-label">Interpretação</span><span class="field-row-value-italic">{interpretacao}</span></div>'
def formatar_numero(valor, casas_decimais=4):
if valor is None:
return "N/A"
if isinstance(valor, (int, float, np.floating)):
return f"{valor:.{casas_decimais}f}"
return str(valor)
linhas_html = []
estat_gerais = resumo_reorganizado.get("estatisticas_gerais", {})
if estat_gerais:
linhas_html.append(criar_titulo_secao("Estatísticas Gerais"))
for chave, dados in estat_gerais.items():
linhas_html.append(criar_linha_campo(dados.get("nome", chave), formatar_numero(dados.get("valor"))))
for chave_teste, label in [("teste_f", "Estatística F"), ("teste_ks", "Estatística KS")]:
teste = resumo_reorganizado.get(chave_teste, {})
if teste.get("estatistica") is not None:
linhas_html.append(criar_titulo_secao(teste.get("nome")))
linhas_html.append(criar_linha_campo(label, formatar_numero(teste["estatistica"])))
if teste.get("pvalor") is not None:
linhas_html.append(criar_linha_campo("P-valor", formatar_numero(teste["pvalor"])))
if teste.get("interpretacao"):
linhas_html.append(criar_linha_interpretacao(teste["interpretacao"]))
perc_resid = resumo_reorganizado.get("perc_resid", {})
if perc_resid.get("valor") is not None:
linhas_html.append(criar_titulo_secao(perc_resid.get("nome")))
linhas_html.append(criar_linha_campo("Percentuais Atingidos", perc_resid["valor"]))
if perc_resid.get("interpretacao"):
linhas_html.append('<div class="interpretation-label">Interpretação</div>')
for ideal in perc_resid["interpretacao"]:
linhas_html.append(f'<div class="interpretation-item">• {ideal}</div>')
for chave_teste, label in [("teste_dw", "Estatística DW"), ("teste_bp", "Estatística LM")]:
teste = resumo_reorganizado.get(chave_teste, {})
if teste.get("estatistica") is not None:
linhas_html.append(criar_titulo_secao(teste.get("nome")))
linhas_html.append(criar_linha_campo(label, formatar_numero(teste["estatistica"])))
if teste.get("pvalor") is not None:
linhas_html.append(criar_linha_campo("P-valor", formatar_numero(teste["pvalor"])))
if teste.get("interpretacao"):
linhas_html.append(criar_linha_interpretacao(teste["interpretacao"]))
return f'<div class="dai-card scrollable-container">{"".join(linhas_html)}</div>'
def criar_titulo_secao_html(titulo):
return f'<div class="section-title-orange">{titulo}</div>'
def formatar_escalas_html(escalas_raw):
if isinstance(escalas_raw, pd.DataFrame):
itens = escalas_raw.iloc[:, 0].tolist()
elif isinstance(escalas_raw, list):
itens = escalas_raw
else:
itens = [str(escalas_raw)]
itens = [str(item) for item in itens if item and str(item).strip()]
if not itens:
return "<p style='color: #6c757d; font-style: italic;'>Nenhuma transformação disponível.</p>"
max_chars = max(len(item) for item in itens)
largura_min = max(150, max_chars * 7 + 28)
cards_html = ""
for item in itens:
if ":" in item:
partes = item.split(":", 1)
conteudo = (
f'<span style="font-weight: 600; color: #495057;">{partes[0].strip()}:</span>'
f'<span style="font-weight: 400; color: #6c757d; margin-left: 4px;">{partes[1].strip()}</span>'
)
else:
conteudo = f'<span style="font-weight: 600; color: #495057;">{item}</span>'
cards_html += f'<div class="dai-card-light" style="min-width: {largura_min}px;">{conteudo}</div>'
return (
f'<div class="dai-card">{criar_titulo_secao_html("Escalas / Transformações")}'
f'<div class="dai-cards-grid" style="grid-template-columns: repeat(auto-fill, minmax({largura_min}px, 1fr));">'
f"{cards_html}</div></div>"
)
def _aplicar_jitter_sobrepostos(df_mapa, lat_col, lon_col, lat_plot_col, lon_plot_col):
df_plot = df_mapa.copy()
df_plot[lat_plot_col] = pd.to_numeric(df_plot[lat_col], errors="coerce")
df_plot[lon_plot_col] = pd.to_numeric(df_plot[lon_col], errors="coerce")
if len(df_plot) <= 1:
return df_plot
chave_lat = df_plot[lat_col].round(7)
chave_lon = df_plot[lon_col].round(7)
grupos = df_plot.groupby([chave_lat, chave_lon], sort=False)
passo_metros = 4.0
max_raio_metros = 22.0
metros_por_grau_lat = 111_320.0
lat_plot_pos = int(df_plot.columns.get_loc(lat_plot_col))
lon_plot_pos = int(df_plot.columns.get_loc(lon_plot_col))
for _, idx_labels in grupos.indices.items():
posicoes = np.asarray(idx_labels, dtype=int)
if posicoes.size <= 1:
continue
base_lat = float(df_plot.iat[int(posicoes[0]), lat_plot_pos])
base_lon = float(df_plot.iat[int(posicoes[0]), lon_plot_pos])
if not np.isfinite(base_lat) or not np.isfinite(base_lon):
continue
seed_val = int((abs(base_lat) * 1_000_000.0) + (abs(base_lon) * 1_000_000.0) * 3.0) % 360
angulo_base = math.radians(seed_val)
cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
metros_por_grau_lon = metros_por_grau_lat * cos_lat
for pos, pos_idx in enumerate(posicoes):
if pos == 0:
continue
pos_ring = pos - 1
ring = 1
while pos_ring >= (6 * ring):
pos_ring -= 6 * ring
ring += 1
slots_ring = max(6 * ring, 1)
angulo = angulo_base + (2.0 * math.pi * (pos_ring / slots_ring))
raio_m = min(ring * passo_metros, max_raio_metros)
delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
df_plot.iat[int(pos_idx), lat_plot_pos] = base_lat + delta_lat
df_plot.iat[int(pos_idx), lon_plot_pos] = base_lon + delta_lon
return df_plot
def _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8):
def _limitar_texto(valor: str, limite: int) -> str:
txt = str(valor)
if limite <= 0 or len(txt) <= limite:
return txt
if limite <= 3:
return txt[:limite]
return txt[: limite - 3] + "..."
if not itens:
html = (
"<div style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden;\">"
"<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
"<div style=\"padding:12px 15px; background:#f8f9fa; color:#6c757d; font-size:12px;\">Sem variáveis para exibir.</div>"
"</div>"
)
return html, 360
max_colunas_por_pagina = 2
max_chars_chave = 20
max_chars_valor = 20
char_px = 7.2
gap_cols_px = 12
popup_padding_horizontal_px = 30
coluna_largura_px = int(round((max_chars_chave + max_chars_valor) * char_px + 28))
itens_por_pagina = max_itens_pagina * max_colunas_por_pagina
paginas = [itens[i:i + itens_por_pagina] for i in range(0, len(itens), itens_por_pagina)]
itens_primeira_pagina = len(paginas[0]) if paginas else 0
colunas_visiveis = max(
1,
min(
max_colunas_por_pagina,
int(math.ceil(itens_primeira_pagina / max_itens_pagina)) if itens_primeira_pagina else 1,
),
)
popup_largura_px = (
popup_padding_horizontal_px
+ (coluna_largura_px * colunas_visiveis)
+ (gap_cols_px * (colunas_visiveis - 1))
)
pages_html = []
for page_idx, page_items in enumerate(paginas):
colunas = [page_items[i:i + max_itens_pagina] for i in range(0, len(page_items), max_itens_pagina)]
colunas_html = []
for col_itens in colunas:
trs_parts = []
for c, v in col_itens:
c_full = str(c)
v_full = str(v)
c_txt = escape(_limitar_texto(c_full, max_chars_chave))
v_txt = escape(_limitar_texto(v_full, max_chars_valor))
c_title = escape(c_full)
v_title = escape(v_full)
trs_parts.append(
"<tr style='border-bottom:1px solid #e9ecef;'>"
"<td style='padding:4px 8px 4px 0; color:#6c757d; font-weight:500; width:50%; max-width:50%; overflow:hidden; white-space:nowrap;'>"
f"<span title='{c_title}'>{c_txt}</span>"
"</td>"
"<td style='padding:4px 0; text-align:right; color:#495057; width:50%; max-width:50%; overflow:hidden; white-space:nowrap;'>"
f"<span title='{v_title}'>{v_txt}</span>"
"</td>"
"</tr>"
)
trs = "".join(trs_parts)
colunas_html.append(
f"<div style='flex:0 0 {coluna_largura_px}px; width:{coluna_largura_px}px; min-width:{coluna_largura_px}px; overflow:hidden;'>"
f"<table style='border-collapse:collapse; table-layout:fixed; font-size:12px; width:{coluna_largura_px}px;'>{trs}</table>"
"</div>"
)
display = "block" if page_idx == 0 else "none"
pages_html.append(
f"<div class='mesa-popup-page' data-page='{page_idx + 1}' style='display:{display};'>"
f"<div style='display:flex; gap:12px; align-items:flex-start; flex-wrap:nowrap;'>{''.join(colunas_html)}</div>"
"</div>"
)
controls_html = ""
if len(paginas) > 1:
controls_html = (
"<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%;'>"
"<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>"
"<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>"
"<div data-page-number-wrap='1' style='display:flex; gap:5px; align-items:center; justify-content:center; flex-wrap:nowrap;'></div>"
"<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>"
"<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>"
"</div>"
)
html = (
f"<div id='{popup_uid}' data-pager='1' data-current-page='1' data-page-start='1' data-page-window='1' "
f"style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden; width:{popup_largura_px}px; max-width:{popup_largura_px}px;\">"
"<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
"<div style=\"padding:12px 15px; background:#f8f9fa;\">"
f"<div class='mesa-popup-pages' style='width:100%;'>{''.join(pages_html)}</div>"
f"{controls_html}"
"</div></div>"
)
return html, popup_largura_px
def _formatar_valor_popup_registro(coluna, valor):
if valor is None:
return "—"
try:
if pd.isna(valor):
return "—"
except Exception:
pass
col_norm = str(coluna).lower()
if isinstance(valor, (int, float, np.integer, np.floating)):
numero = float(valor)
if not np.isfinite(numero):
return "—"
if any(k in col_norm for k in ["valor", "preco", "vu", "vunit"]):
return formatar_monetario(numero)
return f"{numero:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
return str(valor)
def montar_popup_registro_html(row, popup_uid, max_itens_pagina=8):
itens = []
for col, val in row.items():
col_txt = str(col)
if col_txt.startswith("__mesa_"):
continue
if col_txt.lower() not in ["lat", "latitude", "lon", "longitude"]:
itens.append((col, _formatar_valor_popup_registro(col, val)))
return _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=max_itens_pagina)
def _montar_popup_registro_placeholder(session_id, row_id, popup_uid, popup_endpoint, auth_token=None):
popup_id = escape(str(popup_uid))
session_attr = escape(str(session_id))
row_attr = escape(str(int(row_id)))
endpoint_attr = escape(str(popup_endpoint or "/api/visualizacao/map/popup"))
token_attr = escape(str(auth_token or ""))
html = (
f"<div id='{popup_id}' data-mesa-lazy-popup='1' data-session-id='{session_attr}' "
f"data-row-id='{row_attr}' data-popup-endpoint='{endpoint_attr}' data-auth-token='{token_attr}' "
"style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden; width:340px; max-width:340px;\">"
"<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
"<div style=\"padding:12px 15px; background:#f8f9fa; color:#6c757d; font-size:12px;\">Carregando detalhes...</div>"
"</div>"
)
return html, 380
def criar_mapa(
df,
lat_col="lat",
lon_col="lon",
cor_col=None,
tamanho_col=None,
col_y=None,
session_id=None,
popup_endpoint=None,
popup_auth_token=None,
avaliandos_tecnicos=None,
):
lat_real = None
lon_real = None
for col in df.columns:
col_norm = str(col).lower()
if lat_real is None and col_norm in ["lat", "latitude", "siat_latitude"]:
lat_real = col
if lon_real is None and col_norm in ["lon", "longitude", "long", "siat_longitude"]:
lon_real = col
if lat_real is not None and lon_real is not None:
break
if lat_real is None or lon_real is None:
return "<p>Coordenadas (lat/lon) não encontradas nos dados.</p>"
def _primeira_serie_por_nome(dataframe, nome_coluna):
matches = [i for i, c in enumerate(dataframe.columns) if str(c) == str(nome_coluna)]
if not matches:
return None
return dataframe.iloc[:, matches[0]]
df_mapa = df.copy()
lat_key = "__mesa_lat__"
lon_key = "__mesa_lon__"
lat_serie = _primeira_serie_por_nome(df_mapa, lat_real)
lon_serie = _primeira_serie_por_nome(df_mapa, lon_real)
if lat_serie is None or lon_serie is None:
return "<p>Coordenadas (lat/lon) não encontradas nos dados.</p>"
df_mapa[lat_key] = pd.to_numeric(lat_serie, errors="coerce")
df_mapa[lon_key] = pd.to_numeric(lon_serie, errors="coerce")
df_mapa = df_mapa.dropna(subset=[lat_key, lon_key])
df_mapa = df_mapa[
(df_mapa[lat_key] >= -90.0)
& (df_mapa[lat_key] <= 90.0)
& (df_mapa[lon_key] >= -180.0)
& (df_mapa[lon_key] <= 180.0)
].copy()
if df_mapa.empty:
return "<p>Sem coordenadas válidas para exibir.</p>"
centro_lat = float(df_mapa[lat_key].median())
centro_lon = float(df_mapa[lon_key].median())
mapa = folium.Map(
location=[centro_lat, centro_lon],
zoom_start=12,
tiles=None,
prefer_canvas=True,
control_scale=True,
)
folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
add_bairros_layer(mapa, show=True)
if tamanho_col and tamanho_col != "Visualização Padrão" and not cor_col:
cor_col = tamanho_col
colormap = None
cor_key = None
if cor_col and cor_col in df_mapa.columns:
serie_cor = _primeira_serie_por_nome(df_mapa, cor_col)
if serie_cor is not None:
cor_key = "__mesa_cor__"
df_mapa[cor_key] = pd.to_numeric(serie_cor, errors="coerce")
vmin = df_mapa[cor_key].min()
vmax = df_mapa[cor_key].max()
if pd.notna(vmin) and pd.notna(vmax):
colormap = cm.LinearColormap(
colors=["#2ecc71", "#a8e06c", "#f1c40f", "#e67e22", "#e74c3c"],
vmin=vmin,
vmax=vmax,
caption=cor_col,
)
colormap.add_to(mapa)
raio_min, raio_max = 3, 18
tamanho_func = None
tamanho_key = None
if tamanho_col and tamanho_col != "Visualização Padrão" and tamanho_col in df_mapa.columns:
serie_tamanho = _primeira_serie_por_nome(df_mapa, tamanho_col)
if serie_tamanho is not None:
tamanho_key = "__mesa_tamanho__"
df_mapa[tamanho_key] = pd.to_numeric(serie_tamanho, errors="coerce")
t_min = df_mapa[tamanho_key].min()
t_max = df_mapa[tamanho_key].max()
if pd.notna(t_min) and pd.notna(t_max):
if t_max > t_min:
tamanho_func = lambda v, _min=t_min, _max=t_max: raio_min + (v - _min) / (_max - _min) * (raio_max - raio_min)
else:
tamanho_func = lambda v: (raio_min + raio_max) / 2
mostrar_indices = len(df_mapa) <= 800
camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
lat_plot_key = "__mesa_lat_plot__"
lon_plot_key = "__mesa_lon_plot__"
df_plot_pontos = _aplicar_jitter_sobrepostos(
df_mapa,
lat_col=lat_key,
lon_col=lon_key,
lat_plot_col=lat_plot_key,
lon_plot_col=lon_plot_key,
)
tooltip_col = None
tooltip_key = None
if tamanho_key:
tooltip_col = tamanho_col
tooltip_key = tamanho_key
elif col_y and col_y in df_mapa.columns:
serie_tooltip = _primeira_serie_por_nome(df_mapa, col_y)
if serie_tooltip is not None:
tooltip_col = col_y
tooltip_key = "__mesa_tooltip__"
df_mapa[tooltip_key] = serie_tooltip
total_pontos_plot = len(df_plot_pontos)
raio_padrao = 4 if total_pontos_plot <= 2500 else 3
contorno_padrao = 0.8 if total_pontos_plot <= 2500 else 0.55
opacidade_preenchimento = 0.68 if total_pontos_plot <= 2500 else 0.6
for marker_ordem, (idx, row) in enumerate(df_plot_pontos.iterrows()):
cor = colormap(row[cor_key]) if colormap and cor_key and pd.notna(row[cor_key]) else COR_PRINCIPAL
if tamanho_func and tamanho_key and pd.notna(row[tamanho_key]):
raio = tamanho_func(row[tamanho_key])
peso_contorno = 1
else:
raio = raio_padrao
peso_contorno = contorno_padrao
idx_display = int(row["index"]) if "index" in row.index else idx
popup_uid = f"mesa-pop-{marker_ordem}"
popup_html = None
popup_width = None
row_id_raw = row["__mesa_row_id__"] if "__mesa_row_id__" in row.index else None
if session_id is not None and row_id_raw is not None:
try:
popup_html, popup_width = _montar_popup_registro_placeholder(
session_id=session_id,
row_id=int(row_id_raw),
popup_uid=popup_uid,
popup_endpoint=popup_endpoint,
auth_token=popup_auth_token,
)
except Exception:
popup_html = None
popup_width = None
if popup_html is None or popup_width is None:
popup_html, popup_width = montar_popup_registro_html(row, popup_uid, max_itens_pagina=8)
tooltip_html = (
"<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px; line-height:1.7; padding:2px 4px;'>"
f"<b>Índice {idx_display}</b>"
)
if tooltip_col and tooltip_key and tooltip_key in row.index:
val_t = row[tooltip_key]
col_norm = str(tooltip_col).lower()
if isinstance(val_t, (int, float, np.floating)):
if any(k in col_norm for k in ["valor", "preco", "vu", "vunit"]):
val_str = formatar_monetario(val_t)
else:
val_str = f"{val_t:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
else:
val_str = str(val_t)
tooltip_html += f"<br><span style='color:#555;'>{tooltip_col}:</span> <b>{val_str}</b>"
tooltip_html += "</div>"
marcador = folium.CircleMarker(
location=[row[lat_plot_key], row[lon_plot_key]],
radius=raio,
popup=folium.Popup(popup_html, max_width=popup_width),
tooltip=folium.Tooltip(tooltip_html, sticky=True),
color="black",
weight=peso_contorno,
fill=True,
fillColor=cor,
fillOpacity=opacidade_preenchimento,
).add_to(mapa)
marcador.options["mesaBaseRadius"] = float(max(1.0, raio))
if mostrar_indices and camada_indices is not None:
add_indice_marker(
camada_indices,
lat=float(row[lat_plot_key]),
lon=float(row[lon_plot_key]),
indice=idx_display,
)
if mostrar_indices and camada_indices is not None:
camada_indices.add_to(mapa)
if avaliandos_tecnicos:
camada_trabalhos_tecnicos = folium.FeatureGroup(name="Trabalhos tecnicos", show=True)
add_trabalhos_tecnicos_markers(camada_trabalhos_tecnicos, avaliandos_tecnicos)
camada_trabalhos_tecnicos.add_to(mapa)
folium.LayerControl().add_to(mapa)
plugins.Fullscreen().add_to(mapa)
plugins.MeasureControl(
primary_length_unit="meters",
secondary_length_unit="kilometers",
primary_area_unit="sqmeters",
secondary_area_unit="hectares",
).add_to(mapa)
add_zoom_responsive_circle_markers(mapa)
add_popup_pagination_handlers(mapa)
df_bounds = df_mapa
if len(df_mapa) >= 8:
lat_vals = df_mapa[lat_key]
lon_vals = df_mapa[lon_key]
lat_med = float(lat_vals.median())
lon_med = float(lon_vals.median())
lat_mad = float((lat_vals - lat_med).abs().median())
lon_mad = float((lon_vals - lon_med).abs().median())
lat_span = float(lat_vals.max() - lat_vals.min())
lon_span = float(lon_vals.max() - lon_vals.min())
lat_scale = max(lat_mad, lat_span / 30.0, 1e-6)
lon_scale = max(lon_mad, lon_span / 30.0, 1e-6)
score = ((lat_vals - lat_med) / lat_scale) ** 2 + ((lon_vals - lon_med) / lon_scale) ** 2
lim = float(score.quantile(0.75))
df_core = df_mapa[score <= lim]
if len(df_core) >= max(5, int(len(df_mapa) * 0.45)):
df_bounds = df_core
if len(df_bounds) >= 50:
lat_min, lat_max = df_bounds[lat_key].quantile([0.01, 0.99]).tolist()
lon_min, lon_max = df_bounds[lon_key].quantile([0.01, 0.99]).tolist()
else:
lat_min, lat_max = float(df_bounds[lat_key].min()), float(df_bounds[lat_key].max())
lon_min, lon_max = float(df_bounds[lon_key].min()), float(df_bounds[lon_key].max())
if not np.isfinite(lat_min) or not np.isfinite(lat_max):
lat_min, lat_max = float(df_mapa[lat_key].min()), float(df_mapa[lat_key].max())
if not np.isfinite(lon_min) or not np.isfinite(lon_max):
lon_min, lon_max = float(df_mapa[lon_key].min()), float(df_mapa[lon_key].max())
if np.isclose(lat_min, lat_max):
lat_delta = 0.0008
lat_min = float(lat_min) - lat_delta
lat_max = float(lat_max) + lat_delta
if np.isclose(lon_min, lon_max):
lon_delta = 0.0008
lon_min = float(lon_min) - lon_delta
lon_max = float(lon_max) + lon_delta
mapa.fit_bounds([[float(lat_min), float(lon_min)], [float(lat_max), float(lon_max)]], padding=(48, 48), max_zoom=18)
return mapa.get_root().render()
def _formatar_badge_completo(pacote, nome_modelo=""):
if not pacote:
return ""
def _esc(value):
return str(value or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def _data_br(value):
texto = str(value or "").strip()
if not texto:
return ""
match_iso = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[T\s].*)?$", texto)
if match_iso:
try:
return datetime(int(match_iso.group(1)), int(match_iso.group(2)), int(match_iso.group(3))).strftime("%d/%m/%Y")
except Exception:
return texto
match_iso_slash = re.match(r"^(\d{4})/(\d{2})/(\d{2})(?:[T\s].*)?$", texto)
if match_iso_slash:
try:
return datetime(int(match_iso_slash.group(1)), int(match_iso_slash.group(2)), int(match_iso_slash.group(3))).strftime("%d/%m/%Y")
except Exception:
return texto
match_br = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:[T\s].*)?$", texto)
if match_br:
try:
return datetime(int(match_br.group(3)), int(match_br.group(2)), int(match_br.group(1))).strftime("%d/%m/%Y")
except Exception:
return texto
return texto
model_name = str(nome_modelo or "").strip() or "-"
observacao_modelo = str(pacote.get("observacao_modelo") or "").strip()
periodo = pacote.get("periodo_dados_mercado") or {}
data_inicial = _data_br(periodo.get("data_inicial"))
data_final = _data_br(periodo.get("data_final"))
periodo_txt = f"{data_inicial} a {data_final}" if data_inicial and data_final else "-"
info_variaveis = pacote.get("transformacoes", {}).get("info") or []
y_nome = ""
y_transf = ""
x_itens: list[tuple[str, str]] = []
if info_variaveis:
y_parts = str(info_variaveis[0]).split(": ", 1)
y_nome = y_parts[0].strip()
y_transf = y_parts[1].strip() if len(y_parts) > 1 else ""
for item in info_variaveis[1:]:
parts = str(item).split(": ", 1)
nome = parts[0].strip()
transf = parts[1].strip() if len(parts) > 1 else ""
x_itens.append((nome, transf))
y_transf_display = "" if y_transf in ("(y)", "(x)", "y", "x", "") else y_transf
y_html = (
"<div class='section1-empty-hint'>Variável dependente não encontrada no modelo carregado.</div>"
if not y_nome
else (
"<div class='variavel-badge-line'>"
"<span class='variavel-badge-label'>Dependente:</span>"
"<span class='variavel-chip variavel-chip-y variavel-chip-inline'>"
+ _esc(y_nome)
+ (
f"<span class='variavel-chip-transform'> {_esc(y_transf_display)}</span>"
if y_transf_display
else ""
)
+ "</span></div>"
)
)
if x_itens:
x_badges = []
for nome, transf in x_itens:
transf_display = "" if transf in ("(x)", "(y)", "x", "y", "") else transf
x_badges.append(
"<span class='variavel-chip'>"
+ _esc(nome)
+ (
f"<span class='variavel-chip-transform'> {_esc(transf_display)}</span>"
if transf_display
else ""
)
+ "</span>"
)
x_html = (
"<div class='variavel-badge-line'>"
"<span class='variavel-badge-label'>Independentes:</span>"
"<div class='variavel-chip-wrap'>"
+ "".join(x_badges)
+ "</div></div>"
)
else:
x_html = "<div class='section1-empty-hint'>Sem variáveis independentes no modelo carregado.</div>"
periodo_html = (
"<div class='variavel-badge-line'>"
"<span class='variavel-badge-label'>Período dados:</span>"
f"<span class='variavel-badge-value'>{_esc(periodo_txt)}</span>"
"</div>"
)
elaborador = pacote.get("elaborador") or {}
nome_elaborador = str(elaborador.get("nome_completo") or "").strip()
cargo = str(elaborador.get("cargo") or "").strip()
conselho = str(elaborador.get("conselho") or "").strip()
numero = str(elaborador.get("numero_conselho") or "").strip()
estado = str(elaborador.get("estado_conselho") or "").strip()
matricula = str(elaborador.get("matricula_sem_digito") or "").strip()
lotacao = str(elaborador.get("lotacao") or "").strip()
conselho_reg = ""
if conselho and estado and numero:
conselho_reg = f"{conselho}/{estado} {numero}"
elif conselho or numero or estado:
conselho_reg = " ".join(part for part in [conselho, numero, estado] if part)
meta = [cargo, conselho_reg, f"Matrícula {matricula}" if matricula else "", lotacao]
meta_txt = " | ".join(part for part in meta if part)
elaborador_html = (
"<div class='section1-empty-hint'>Elaborador não informado no arquivo.</div>"
if not nome_elaborador
else (
f"<div class='elaborador-badge-name'>{_esc(nome_elaborador)}</div>"
+ (f"<div class='elaborador-badge-meta'>{_esc(meta_txt)}</div>" if meta_txt else "")
)
)
nome_modelo_html = (
"<div class='modelo-info-stack-block'>"
"<div class='elaborador-badge-title'>NOME DO MODELO:</div>"
f"<div class='elaborador-badge-name'>{_esc(model_name)}</div>"
+ (f"<div class='elaborador-badge-meta'>{_esc(observacao_modelo)}</div>" if observacao_modelo else "")
+ "</div>"
)
return (
"<div class='subpanel section1-group'>"
"<h4>Informações do modelo</h4>"
"<div class='modelo-info-card'>"
"<div class='modelo-info-split'>"
"<div class='modelo-info-col'>"
+ nome_modelo_html
+ "<div class='modelo-info-stack-block'>"
"<div class='elaborador-badge-title'>ELABORADO POR:</div>"
+ elaborador_html
+ "</div>"
"</div>"
"<div class='modelo-info-col modelo-info-col-vars'>"
"<div class='elaborador-badge-title'>Variáveis selecionadas:</div>"
+ y_html
+ x_html
+ periodo_html
+ "</div>"
"</div>"
"</div>"
"</div>"
)