dashboard / pages /exp.py
devtools681420's picture
feat: Add new 'Expedição' dashboard page and update app navigation to redirect to it.
2392daf
Raw
History Blame Contribute Delete
35.9 kB
import streamlit as st
import pandas as pd
import json, time
from datetime import datetime
from streamlit_cookies_controller import CookieController
import streamlit.components.v1 as components
from gs_client import read_ws
st.set_page_config(layout="wide", initial_sidebar_state="collapsed", page_title="PMJA - Dashboard Expedição")
st.markdown("""<style>
#MainMenu,footer,header,[data-testid="stToolbar"],[data-testid="stDecoration"],
[data-testid="stStatusWidget"]{display:none!important}
.block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important}
iframe{position:fixed!important;top:0!important;left:0!important;
width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important}
[data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important;
height:0!important;overflow:visible!important}
.main,[data-testid="stAppViewContainer"]{background:#f5f7fa!important}
@media(max-width:900px){
iframe{position:static!important;width:100%!important;height:7500px!important;z-index:1!important}
.main,[data-testid="stAppViewContainer"]{overflow-y:auto!important;height:auto!important}
.block-container{height:auto!important}
}
</style>""", unsafe_allow_html=True)
# ── SESSION ───────────────────────────────────────────────────────────
_cc = CookieController()
COOKIE_NAME = "pmja_session"
def load_session():
if st.session_state.get("logged_in") and st.session_state.get("session_exp"):
if datetime.now() < st.session_state.session_exp:
return True
st.session_state.logged_in = False
return False
try:
c = _cc.get(COOKIE_NAME)
if not c: return False
if datetime.now() >= datetime.fromisoformat(c["expiry"]):
_cc.remove(COOKIE_NAME); return False
st.session_state.update(logged_in=True, session_uid=c["user_id"],
session_usr=c["username"], session_exp=datetime.fromisoformat(c["expiry"]))
return True
except Exception:
return False
if not load_session():
st.switch_page("app.py")
if 'inicio_exibicao' not in st.session_state:
st.session_state.inicio_exibicao = time.time()
if time.time() - st.session_state.inicio_exibicao >= 120:
st.session_state.inicio_exibicao = time.time()
st.switch_page("pages/full.py")
# ── LOADING ───────────────────────────────────────────────────────────
loading_placeholder = st.empty()
loading_placeholder.markdown("""
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700;800&family=Sora:wght@800&display=swap" rel="stylesheet">
<style>
#ld{position:fixed;inset:0;z-index:999999;background:linear-gradient(135deg,#003a70,#0056A3);
display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:'DM Sans',sans-serif}
#ld .lg{display:flex;align-items:center;gap:24px;margin-bottom:36px}
#ld img{height:34px;filter:brightness(0) invert(1);opacity:.9}
#ld .dv{width:1px;height:32px;background:rgba(255,255,255,.25)}
#ld h2{font-family:'Sora',sans-serif;font-size:clamp(15px,4vw,21px);color:#fff;font-weight:800;margin-bottom:4px;text-align:center}
#ld p{font-size:clamp(11px,3vw,13px);color:rgba(255,255,255,.7);font-weight:600;margin-bottom:40px;text-align:center}
#ld .sp{width:32px;height:32px;border:3px solid rgba(255,255,255,.2);border-top-color:#fff;
border-radius:50%;animation:sp .8s linear infinite;margin-bottom:24px}
@keyframes sp{to{transform:rotate(360deg)}}
#ld .bw{width:min(260px,70vw);height:4px;background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden;margin-bottom:14px}
#ld .bf{height:100%;width:0%;background:#fff;border-radius:99px;transition:width .4s ease}
#ld .st{font-size:11px;color:rgba(255,255,255,.6);font-weight:600;text-align:center}
#ld .dt span{animation:dt 1.2s infinite;opacity:0}
#ld .dt span:nth-child(2){animation-delay:.2s}
#ld .dt span:nth-child(3){animation-delay:.4s}
@keyframes dt{0%,100%{opacity:0}50%{opacity:1}}
</style>
<div id="ld">
<div class="lg">
<img src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490">
<div class="dv"></div>
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg">
</div>
<div class="sp"></div>
<h2>PMJA — Dashboard Expedição</h2>
<p>Gestão de Materiais</p>
<div class="bw"><div class="bf" id="ldbf"></div></div>
<div class="st" id="ldst">Conectando ao Google Sheets<span class="dt"><span>.</span><span>.</span><span>.</span></span></div>
</div>
<script>
(function(){
var s=[{p:18,m:'Conectando ao Google Sheets'},{p:40,m:'Carregando dados de expedição'},
{p:62,m:'Processando métricas'},{p:80,m:'Montando gráficos'},{p:95,m:'Quase pronto'}];
var i=0;
function n(){
if(i>=s.length)return;
var x=s[i++];
var b=document.getElementById('ldbf'),t=document.getElementById('ldst');
if(b)b.style.width=x.p+'%';
if(t)t.innerHTML=x.m+'<span class="dt"><span>.</span><span>.</span><span>.</span></span>';
setTimeout(n,950+Math.random()*550);
}
setTimeout(n,300);
})();
</script>
""", unsafe_allow_html=True)
# ── LOAD DATA ─────────────────────────────────────────────────────────
df_exp_raw = read_ws("exp_dados")
loading_placeholder.empty()
# ── HELPERS ───────────────────────────────────────────────────────────
def fmt(n):
if pd.isna(n): return "0"
return "{:,}".format(int(n)).replace(',', '.')
def extrair_categoria(col):
for s in [' Requisições',' Requisiçoes',' Unid. Itens',' Unid Itens',
' Itens por unidade',' Items por unidade']:
col = col.replace(s, '')
return col.strip()
def proc_exp(df_raw):
if df_raw is None or df_raw.empty: return None
df = df_raw.copy()
df.rename(columns={df.columns[0]: 'mes'}, inplace=True)
df = df[df['mes'].notna()].copy()
df['data'] = pd.to_datetime(df['mes'], format='%m/%Y', errors='coerce')
df = df.dropna(subset=['data']).copy()
df['ano'] = df['data'].dt.year
df['mes_num'] = df['data'].dt.month
req, unid, itens = [], [], []
for col in df.columns:
c = col.lower().strip().replace('.','').replace(' ',' ')
if 'requisi' in c and 'unid' not in c and 'por' not in c: req.append(col)
if 'unid' in c and 'iten' in c and 'por' not in c: unid.append(col)
if 'iten' in c and 'por' in c and 'unid' in c: itens.append(col)
for col in req+unid+itens:
if col in df.columns: df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0)
df['qtd_requisicoes'] = df[req].sum(axis=1)
df['qtd_unidades_emitidas'] = df[unid].sum(axis=1)
df['qtd_itens_total'] = df[itens].sum(axis=1) if itens else 0
df_final = df[['mes_num','ano','qtd_requisicoes','qtd_unidades_emitidas','qtd_itens_total']]\
.sort_values(['ano','mes_num']).reset_index(drop=True)
df_det = df[['mes_num','ano']+req+unid+itens]\
.sort_values(['ano','mes_num']).reset_index(drop=True)
return df_final, df_det, req, unid, itens
# ── BUILD FRAMES ──────────────────────────────────────────────────────
def build_frames(df_plot, df_det, req_cols, unid_cols, itens_cols, anos_unicos, mes_max, CORES_EXP):
MESES_PT = {1:'Jan',2:'Fev',3:'Mar',4:'Abr',5:'Mai',6:'Jun',
7:'Jul',8:'Ago',9:'Set',10:'Out',11:'Nov',12:'Dez'}
metricas_linha = [
{'col':'qtd_requisicoes', 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef']},
{'col':'qtd_unidades_emitidas','cores':['#001f3f','#0056A3','#00a8e8','#7ecfef']},
{'col':'qtd_itens_total', 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef']},
]
tipos_l1 = ['barra_h','barra_v','linha']
frames = []
for mes_atual in range(1, mes_max+1):
frame = {"mes": mes_atual, "cards": {}, "linha1": [], "linha2": []}
df_ate = df_plot[df_plot['mes_num'] <= mes_atual]
df_det_ate = df_det[df_det['mes_num'] <= mes_atual]
for col, tit, cor, suf in [
('qtd_requisicoes', 'Requisições', '#003a70',''),
('qtd_unidades_emitidas','Unidades Emitidas','#005a9c',''),
('qtd_itens_total', 'Total de Itens', '#0075be',''),
]:
frame["cards"][col] = {"valor": fmt(df_ate[col].sum()), "cor": cor, "titulo": tit, "sufixo": suf}
meses_ex = sorted(df_plot[df_plot['mes_num'] <= mes_atual]['mes_num'].unique())
for idx_m, cfg in enumerate(metricas_linha):
col = cfg['col']
traces = []
for idx_ano, ano in enumerate(anos_unicos):
d = df_plot[(df_plot['ano']==ano)&(df_plot['mes_num']<=mes_atual)].sort_values('mes_num')
if d.empty: continue
cor = cfg['cores'][idx_ano % len(cfg['cores'])]
vals, texts = [], []
for m in meses_ex:
row = d[d['mes_num']==m]
v = float(row[col].iloc[0]) if not row.empty else 0.0
vals.append(v); texts.append(fmt(v) if v > 0 else "")
traces.append({
"ano": str(ano), "cor": cor,
"meses": meses_ex, "y": vals, "text": texts,
"fill_rgba": "rgba({},{},{},0.1)".format(int(cor[1:3],16),int(cor[3:5],16),int(cor[5:7],16))
})
frame["linha1"].append({"tipo": tipos_l1[idx_m], "traces": traces, "meses_ex": meses_ex})
for grp in [
{'cols': req_cols, 'tipo': 'donut'},
{'cols': unid_cols, 'tipo': 'linha_sis'},
{'cols': itens_cols, 'tipo': 'barv_sis'},
]:
totais = {}
for c in grp['cols']:
cat = extrair_categoria(c)
if cat not in totais:
totais[cat] = {'v': 0.0, 'cor': CORES_EXP[len(totais) % len(CORES_EXP)]}
totais[cat]['v'] += float(df_det_ate[c].sum()) if c in df_det_ate.columns else 0.0
dados = sorted([(k,v) for k,v in totais.items() if v['v']>0],
key=lambda x: x[1]['v'], reverse=True)
frame["linha2"].append({
"tipo": grp['tipo'],
"labels": [d[0] for d in dados],
"valores":[d[1]['v'] for d in dados],
"cores": [d[1]['cor'] for d in dados],
})
frames.append(frame)
return frames
# ── MAIN ──────────────────────────────────────────────────────────────
result = proc_exp(df_exp_raw) if df_exp_raw is not None and not df_exp_raw.empty else None
if result is not None:
df_expedicao, df_det, req_cols, unid_cols, itens_cols = result
for col in ['qtd_requisicoes','qtd_unidades_emitidas','qtd_itens_total']:
df_expedicao[col] = pd.to_numeric(df_expedicao[col], errors='coerce').fillna(0)
mes_max = int(df_expedicao['mes_num'].max())
anos_unicos = sorted(df_expedicao['ano'].unique())
CORES_EXP = ['#001a33','#002d4d','#003d66','#004d80','#005d99','#006db3',
'#007dcc','#1a8dd4','#3399dd','#4da6e6','#66b3ee','#80c0f5','#99ccff','#b3d9ff']
MESES_PT = {1:'Jan',2:'Fev',3:'Mar',4:'Abr',5:'Mai',6:'Jun',
7:'Jul',8:'Ago',9:'Set',10:'Out',11:'Nov',12:'Dez'}
metricas_cfg = [
{'col':'qtd_requisicoes', 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'suf':''},
{'col':'qtd_unidades_emitidas','cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'suf':''},
{'col':'qtd_itens_total', 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'suf':''},
]
def sub_anos(cfg):
parts = []
for i, ano in enumerate(anos_unicos):
cor = cfg['cores'][i % len(cfg['cores'])]
total = fmt(df_expedicao[df_expedicao['ano']==ano][cfg['col']].sum())
parts.append("<span style='color:{};font-weight:700'>{}: {}{}</span>".format(cor, ano, total, cfg['suf']))
return " <span style='color:#666'>|</span> ".join(parts)
def sub_sistemas(cols):
totais = {}
for c in cols:
cat = extrair_categoria(c)
if cat not in totais: totais[cat] = {'v':0.0,'cor':CORES_EXP[len(totais)%len(CORES_EXP)]}
totais[cat]['v'] += float(df_det[c].sum()) if c in df_det.columns else 0.0
dados = sorted([(k,v) for k,v in totais.items() if v['v']>0],
key=lambda x: x[1]['v'], reverse=True)
return " <span style='color:#666'>|</span> ".join(
["<span style='color:{};font-weight:700'>{}: {}</span>".format(d[1]['cor'], d[0], fmt(d[1]['v']))
for d in dados])
frames = build_frames(df_expedicao, df_det, req_cols, unid_cols, itens_cols, anos_unicos, mes_max, CORES_EXP)
import numpy as np
class NpEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, (np.integer,)): return int(obj)
if isinstance(obj, (np.floating,)): return float(obj)
if isinstance(obj, np.ndarray): return obj.tolist()
return super().default(obj)
frames_json = json.dumps(frames, cls=NpEncoder, ensure_ascii=False)
meses_json = json.dumps(MESES_PT)
TITULOS_L1 = ['Requisições','Unidades Emitidas','Total de Itens']
TITULOS_L2 = ['Requisições por Sistema','Unidades Emitidas por Sistema','Total de Itens por Sistema']
subs_l1 = [sub_anos(c) for c in metricas_cfg]
subs_l2 = [sub_sistemas(req_cols), sub_sistemas(unid_cols), sub_sistemas(itens_cols)]
html = """<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700;800&family=Sora:wght@700;800;900&display=swap" rel="stylesheet">
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js" charset="utf-8"></script>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { width:100%; height:100%; overflow:hidden;
background:linear-gradient(135deg,#f5f7fa 0%,#e8eef5 100%);
font-family:'DM Sans',sans-serif; }
.root { display:grid; width:100%; height:100dvh; gap:3px; padding:4px; overflow:hidden;
grid-template-rows:auto auto 1fr 1fr; }
.hdr { background:linear-gradient(135deg,#003a70 0%,#0056A3 100%);
border-radius:8px; padding:4px 14px; display:flex; align-items:center;
justify-content:space-between; box-shadow:0 4px 12px rgba(0,58,112,.12); }
.logo { height:clamp(22px,3.2dvh,36px); filter:brightness(0) invert(1); opacity:.92; }
.ht { font-family:'Sora',sans-serif; font-size:clamp(12px,1.8dvh,20px);
color:#fff; font-weight:800; letter-spacing:-.5px; }
.hs { font-family:'DM Sans',sans-serif; font-size:clamp(9px,1.2dvh,14px);
color:rgba(255,255,255,.9); font-weight:700; }
.cards { display:grid; grid-template-columns:repeat(3,1fr); gap:3px; }
.mc { background:linear-gradient(135deg,#ffffff 0%,#f8fafd 100%);
border-radius:8px; padding:clamp(4px,.6dvh,10px) clamp(6px,.7vw,14px);
border-left:4px solid; box-shadow:0 2px 12px rgba(0,58,112,.08); overflow:hidden; }
.ml { font-family:'DM Sans',sans-serif; font-size:clamp(7px,.85dvh,11px);
color:#666; font-weight:700; text-transform:uppercase;
letter-spacing:.3px; margin-bottom:2px; white-space:nowrap; }
.mv { font-family:'Sora',sans-serif; font-size:clamp(13px,2dvh,26px);
font-weight:800; line-height:1; white-space:nowrap; }
.row3 { display:grid; grid-template-columns:repeat(3,1fr); gap:3px; min-height:0; }
.cb { background:white; border-radius:10px;
box-shadow:0 2px 12px rgba(0,58,112,.07),0 1px 3px rgba(0,58,112,.04);
overflow:visible; min-height:0; display:flex; flex-direction:column;
border:1px solid rgba(0,58,112,.04); }
.ct { background:linear-gradient(180deg,#fafbfd 0%,#ffffff 100%);
padding:4px 8px 3px; border-radius:10px 10px 0 0;
border-bottom:1px solid #eef1f6; flex-shrink:0; }
.ct-title { font-family:'Sora',sans-serif; font-size:clamp(9px,1.15dvh,15px);
color:#1a1a1a; font-weight:800; text-align:center; }
.ct-sub { text-align:center; font-size:clamp(7px,.8dvh,10px);
font-weight:700; margin-top:1px; line-height:1.3; font-family:'DM Sans',sans-serif; }
.cp { flex:1; min-height:0; overflow:visible; }
@media(max-width:900px){
html,body { overflow:hidden!important; }
.root { height:100dvh!important; overflow:hidden!important; }
.cards { grid-template-columns:repeat(3,1fr)!important; }
.row3 { grid-template-columns:repeat(2,1fr)!important; }
.cb { min-height:0!important; }
.cp { min-height:0!important; }
}
@media(max-width:600px){
html,body { overflow-y:auto!important; height:auto!important; }
.root { height:auto!important; overflow:visible!important; grid-template-rows:auto!important; }
.cards { grid-template-columns:1fr 1fr!important; }
.row3 { grid-template-columns:1fr!important; }
.cb { height:260px!important; }
.cp { height:210px!important; flex:none!important; }
.hs { display:none; }
.hdr { padding:4px 8px!important; }
}
</style>
</head>
<body>
<div class="root">
<div class="hdr">
<img class="logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490" alt="AZ">
<div style="text-align:center;flex:1">
<div class="ht">PMJA - Dashboard Gestão de Materiais</div>
<div class="hs">Evolução Temporal - Expedição</div>
</div>
<img class="logo" src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg" alt="Engie">
</div>
<div class="cards" id="cards-row"></div>
<div class="row3" id="row1"></div>
<div class="row3" id="row2"></div>
</div>
<script>
var FRAMES = __FRAMES__;
var MESES = __MESES__;
var TITULOS_L1 = __TITULOS_L1__;
var TITULOS_L2 = __TITULOS_L2__;
var SUBS_L1 = __SUBS_L1__;
var SUBS_L2 = __SUBS_L2__;
var CARDS_CFG = [
{col:'qtd_requisicoes', titulo:'Requisições', cor:'#003a70', sufixo:''},
{col:'qtd_unidades_emitidas', titulo:'Unidades Emitidas', cor:'#005a9c', sufixo:''},
{col:'qtd_itens_total', titulo:'Total de Itens', cor:'#0075be', sufixo:''}
];
var FRAME_MS = 700;
var PAUSE_MS = 60000;
var frameIdx = 0;
var animTimer = null;
var cdownTimer= null;
var inited = {};
function buildDOM() {
var cr = document.getElementById('cards-row');
CARDS_CFG.forEach(function(c) {
cr.innerHTML += '<div class="mc" style="border-left-color:'+c.cor+'">'
+ '<div class="ml">'+c.titulo+'</div>'
+ '<div class="mv" id="mv_'+c.col+'" style="color:'+c.cor+'">0</div></div>';
});
var r1 = document.getElementById('row1');
for (var i=0; i<3; i++) {
r1.innerHTML += '<div class="cb"><div class="ct">'
+ '<div class="ct-title">'+TITULOS_L1[i]+'</div>'
+ '<div class="ct-sub">'+SUBS_L1[i]+'</div></div>'
+ '<div class="cp" id="l1_'+i+'"></div></div>';
}
var r2 = document.getElementById('row2');
for (var i=0; i<3; i++) {
r2.innerHTML += '<div class="cb"><div class="ct">'
+ '<div class="ct-title">'+TITULOS_L2[i]+'</div>'
+ '<div class="ct-sub">'+SUBS_L2[i]+'</div></div>'
+ '<div class="cp" id="l2_'+i+'"></div></div>';
}
}
function gsize(id) {
var el = document.getElementById(id);
if (!el) return [200,150];
return [el.clientWidth||200, el.clientHeight||150];
}
var tickvals = Object.keys(MESES).map(Number);
var ticktext = Object.values(MESES);
function fmtDot(v) {
var n = Math.round(v);
if (n===0) return '0';
var s=n.toString(), r='', c=0;
for (var i=s.length-1; i>=0; i--) {
if (c>0 && c%3===0) r='.'+r;
r=s[i]+r; c++;
}
return r;
}
function renderBarH(item, elId) {
var wh = gsize(elId);
var el = document.getElementById(elId);
var meses = item.meses_ex || [];
var data = [];
var yOrder = meses.map(function(m){ return MESES[m]; }).slice().reverse();
var maxVal = 0;
item.traces.forEach(function(t){ t.y.forEach(function(v){ if(v>maxVal) maxVal=v; }); });
item.traces.forEach(function(t) {
var yV = t.y.slice().reverse();
data.push({
type:'bar', orientation:'h', name:t.ano,
x:yV, y:yOrder,
text:yV.map(function(){ return ''; }),
marker:{color:t.cor, line:{color:'white',width:1}},
hovertemplate:'<b>'+t.ano+' - %{y}</b><br>%{x:,.0f}<extra></extra>'
});
});
var annotations = [];
var accum = yOrder.map(function(){ return 0; });
item.traces.forEach(function(t) {
var yV = t.y.slice().reverse();
yV.forEach(function(v, i) {
if (!v) { accum[i]+=(v||0); return; }
var xStart = accum[i];
var inside = v >= maxVal*0.10;
annotations.push({
x: inside ? xStart+v/2 : xStart+v+maxVal*0.015,
y: yOrder[i],
xref:'x', yref:'y', showarrow:false,
xanchor: inside?'center':'left',
yanchor:'middle',
text:'<b>'+fmtDot(v)+'</b>',
font:{size:11,family:'DM Sans',color:inside?'white':'#1e3a5f'}
});
accum[i] += v;
});
});
var layout = {
width:wh[0], height:wh[1], autosize:true, barmode:'stack',
template:'plotly_white', showlegend:false, font:{family:'DM Sans',size:11},
margin:{l:70,r:56,t:8,b:18},
plot_bgcolor:'rgba(248,250,252,0.8)', paper_bgcolor:'white',
bargap:0.28, annotations:annotations,
xaxis:{tickfont:{size:9,family:'DM Sans',color:'#9ca3af'},
showgrid:true, gridcolor:'rgba(200,200,200,0.25)',
rangemode:'tozero', zeroline:false, range:[0,maxVal*1.25]},
yaxis:{tickfont:{size:11,family:'DM Sans',color:'#374151'},
showgrid:false, categoryorder:'array', categoryarray:yOrder, ticksuffix:' '},
hoverlabel:{bgcolor:'white',font_size:12,font_family:'DM Sans'}
};
if (!inited[elId]) {
Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
inited[elId] = true;
} else { Plotly.react(el, data, layout); }
roundBars(el, 8, 'h');
}
function renderBarV(item, elId) {
var wh = gsize(elId);
var el = document.getElementById(elId);
var meses = item.meses_ex || [];
var data = [];
var maxByMes = {};
meses.forEach(function(m,mi) {
var s=0; item.traces.forEach(function(t){ s+=t.y[mi]||0; }); maxByMes[mi]=s;
});
var globalMax = Math.max.apply(null, meses.map(function(_,mi){ return maxByMes[mi]; }));
var annotations = [];
meses.forEach(function(m,mi) {
var tot = maxByMes[mi];
if (!tot) return;
annotations.push({x:MESES[m],y:tot,xref:'x',yref:'y',showarrow:false,
yanchor:'bottom',xanchor:'center',yshift:5,
text:'<b>'+fmtDot(tot)+'</b>',font:{size:11,family:'DM Sans',color:'#1e3a5f'}});
});
var accumV = meses.map(function(){ return 0; });
item.traces.forEach(function(t) {
var segAnnots = [];
t.y.forEach(function(v,mi) {
if (v>0) {
var yBase = accumV[mi];
if (v >= globalMax*0.08) {
segAnnots.push({x:MESES[meses[mi]],y:yBase+v/2,xref:'x',yref:'y',showarrow:false,
xanchor:'center',yanchor:'middle',
text:'<b>'+fmtDot(v)+'</b>',font:{size:10,family:'DM Sans',color:'white'}});
}
accumV[mi] += v;
} else { accumV[mi]+=(v||0); }
});
segAnnots.forEach(function(a){ annotations.push(a); });
data.push({type:'bar',name:t.ano,
x:meses.map(function(m){ return MESES[m]; }), y:t.y,
text:t.y.map(function(){ return ''; }),
marker:{color:t.cor, line:{color:'white',width:1}},
hovertemplate:'<b>'+t.ano+' - %{x}</b><br>%{y:,.0f}<extra></extra>'});
});
var layout = {
width:wh[0], height:wh[1], autosize:true, barmode:'stack',
template:'plotly_white', showlegend:false, font:{family:'DM Sans',size:11},
margin:{l:34,r:16,t:30,b:32},
plot_bgcolor:'rgba(248,250,252,0.8)', paper_bgcolor:'white',
bargap:0.28, annotations:annotations,
xaxis:{tickfont:{size:11,family:'DM Sans',color:'#374151'},showgrid:false,tickangle:0},
yaxis:{tickfont:{size:9,family:'DM Sans',color:'#9ca3af'},showgrid:true,
gridcolor:'rgba(200,200,200,0.25)',zeroline:false,range:[0,globalMax*1.20]},
hoverlabel:{bgcolor:'white',font_size:12,font_family:'DM Sans'}
};
if (!inited[elId]) {
Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
inited[elId] = true;
} else { Plotly.react(el, data, layout); }
roundBars(el, 8, 'v');
}
function renderLine(item, elId) {
var wh = gsize(elId);
var el = document.getElementById(elId);
var data = [], annotations = [], yshifts = [18,32,46,60];
item.traces.forEach(function(t, i) {
data.push({
type:'scatter', mode:'lines+markers',
x:t.meses, y:t.y,
line:{width:3, color:t.cor, shape:'spline'},
marker:{size:8, color:t.cor, line:{width:2,color:'white'}},
fill: i>0?'tonexty':null, fillcolor:t.fill_rgba,
cliponaxis:false, showlegend:false, name:t.ano,
hovertemplate:'<b>'+t.ano+' - %{customdata}</b><br>%{y:,.0f}<extra></extra>',
customdata:t.meses.map(function(m){ return MESES[m]; })
});
var shift = yshifts[i % yshifts.length];
t.meses.forEach(function(mx, j) {
if (!t.text[j]) return;
annotations.push({x:mx,y:t.y[j],xref:'x',yref:'y',text:t.text[j],
showarrow:false,yshift:shift,
font:{size:11,family:'DM Sans',color:'#1a1a1a'},bgcolor:'rgba(0,0,0,0)'});
});
});
var layout = {
autosize:true,
hovermode:'x unified', template:'plotly_white',
font:{family:'DM Sans',size:9}, showlegend:false,
margin:{l:38,r:18,t:30,b:38},
plot_bgcolor:'rgba(248,249,250,0.5)', annotations:annotations,
xaxis:{tickmode:'array',tickvals:tickvals,ticktext:ticktext,tickangle:0,
tickfont:{size:9,family:'DM Sans'},showgrid:true,
gridcolor:'rgba(200,200,200,0.2)',ticklabelstandoff:8},
yaxis:{tickfont:{size:9,family:'DM Sans'},rangemode:'tozero',
showgrid:true,gridcolor:'rgba(200,200,200,0.2)'},
hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans'}
};
if (!inited[elId]) {
Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
inited[elId] = true;
} else { Plotly.react(el, data, layout); }
}
function renderDonut(item, elId) {
if (!item.valores.length) return;
var wh = gsize(elId);
var el = document.getElementById(elId);
var total = item.valores.reduce(function(s,v){ return s+v; }, 0);
var labels = item.labels.map(function(l,i) {
var pct = total>0 ? (item.valores[i]/total*100).toFixed(1) : '0.0';
return l+': '+fmtDot(item.valores[i])+' ('+pct+'%)';
});
var textF = item.valores.map(function(v){ return total>0?(v/total*100).toFixed(1)+'%':''; });
var data = [{
type:'pie', labels:labels, values:item.valores,
marker:{colors:item.cores, line:{color:'white',width:3}},
hole:0.4, text:textF, textinfo:'text',
textfont:{size:12,family:'Sora'}, insidetextorientation:'radial',
hovertemplate:'<b>%{label}</b><extra></extra>',
pull:item.valores.map(function(v){ return v===Math.max.apply(null,item.valores)?0.03:0; }),
rotation:90, direction:'clockwise', sort:false
}];
var layout = {
autosize:true,
template:'plotly_white', font:{family:'DM Sans',size:9}, showlegend:true,
legend:{orientation:'v',yanchor:'middle',y:0.5,xanchor:'left',x:1.02,font:{size:8,family:'DM Sans',color:'#333'}},
margin:{l:10,r:130,t:10,b:10},
hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans'}
};
if (!inited[elId]) {
Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
inited[elId] = true;
} else { Plotly.react(el, data, layout); }
}
function renderLineSis(item, elId) {
if (!item.valores.length) return;
var wh = gsize(elId);
var el = document.getElementById(elId);
var total = item.valores.reduce(function(s,v){ return s+v; }, 0);
var xVals = item.labels.map(function(_,i){ return i; });
var texts = item.valores.map(function(v) {
var pct = total>0 ? (v/total*100).toFixed(1) : '0.0';
return v>0 ? '<b>'+fmtDot(v)+'</b><br>'+pct+'%' : '';
});
var cor = item.cores[0] || '#0056A3';
var r_c = parseInt(cor.slice(1,3),16);
var g_c = parseInt(cor.slice(3,5),16);
var b_c = parseInt(cor.slice(5,7),16);
var fill = 'rgba('+r_c+','+g_c+','+b_c+',0.2)';
var data = [{
type:'scatter', mode:'lines+markers+text',
x:xVals, y:item.valores,
text:texts, textposition:'top center',
textfont:{size:11,family:'DM Sans',color:'#1a1a1a'},
line:{width:4, color:cor, shape:'spline'},
marker:{size:14, color:item.cores, symbol:'circle'},
fill:'tozeroy', fillcolor:fill,
hovertemplate:'<b>%{customdata}</b><br>%{y:,.0f}<extra></extra>',
customdata:item.labels, showlegend:false, cliponaxis:false
}];
var layout = {
autosize:true,
template:'plotly_white', showlegend:false, font:{family:'DM Sans',size:9},
margin:{l:10,r:10,t:30,b:72},
plot_bgcolor:'rgba(248,249,250,0.5)', paper_bgcolor:'white',
xaxis:{tickmode:'array',tickvals:xVals,ticktext:item.labels,
tickfont:{size:8,family:'DM Sans',color:'#333'},tickangle:-35,
showgrid:false,zeroline:false,showline:false,ticks:'',automargin:true},
yaxis:{showgrid:true,gridcolor:'rgba(200,200,200,0.35)',gridwidth:1,
zeroline:false,showline:false,showticklabels:false,ticks:'',
range:[0,Math.max.apply(null,item.valores)*1.6]},
hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans'}
};
if (!inited[elId]) {
Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
inited[elId] = true;
} else { Plotly.react(el, data, layout); }
}
// ── FIXED: added roundBars call to render rounded tops on bar chart ──
function renderBarVSis(item, elId) {
if (!item.valores.length) return;
var wh = gsize(elId);
var el = document.getElementById(elId);
var total = item.valores.reduce(function(s,v){ return s+v; }, 0);
var maxVal = Math.max.apply(null, item.valores);
var annotations = item.labels.map(function(l,i) {
var v = item.valores[i];
var pct = total>0 ? (v/total*100).toFixed(1) : '0.0';
return {x:l, y:v, xref:'x', yref:'y', showarrow:false,
yanchor:'bottom', xanchor:'center', yshift:4,
text:'<b>'+fmtDot(v)+'</b><br><span style="font-size:10px">'+pct+'%</span>',
font:{size:11,family:'DM Sans',color:'#1e3a5f'}};
});
var data = [{
type:'bar', x:item.labels, y:item.valores,
text:item.valores.map(function(){ return ''; }),
marker:{color:item.cores, line:{color:'white',width:2}},
hovertemplate:'<b>%{x}</b><br>%{y:,.0f}<extra></extra>',
width:0.6, showlegend:false
}];
var layout = {
autosize:true,
template:'plotly_white', showlegend:false, font:{family:'DM Sans',size:11},
margin:{l:30,r:18,t:40,b:72},
plot_bgcolor:'rgba(248,249,250,0.6)', paper_bgcolor:'white',
bargap:0.3, annotations:annotations,
xaxis:{tickfont:{size:9,family:'DM Sans',color:'#374151'},showgrid:false,tickangle:-30,automargin:true},
yaxis:{tickfont:{size:9,family:'DM Sans'},showgrid:true,
gridcolor:'rgba(200,200,200,0.35)',range:[0,maxVal*1.35],showticklabels:false,zeroline:false},
hoverlabel:{bgcolor:'white',font_size:12,font_family:'DM Sans'}
};
if (!inited[elId]) {
Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
inited[elId] = true;
} else { Plotly.react(el, data, layout); }
// ── CORREÇÃO: arredondar o topo das barras ──
roundBars(el, 8, 'v');
}
function roundBars(boxEl, r, orientation) {
setTimeout(function() {
boxEl.querySelectorAll('.bars path').forEach(function(p) {
var d = p.getAttribute('d');
if (!d) return;
var tok = d.match(/[A-Za-z][^A-Za-z]*/g) || [];
var cx=0, cy=0, aX=[], aY=[];
tok.forEach(function(t) {
var cmd = t[0];
var pts = t.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){ return !isNaN(n); });
if (cmd==='M'||cmd==='L') { aX.push(pts[0]); aY.push(pts[1]); cx=pts[0]; cy=pts[1]; }
else if (cmd==='H') { aX.push(pts[0]); aY.push(cy); cx=pts[0]; }
else if (cmd==='V') { aX.push(cx); aY.push(pts[0]); cy=pts[0]; }
});
if (aX.length < 2) return;
var x0=Math.min.apply(null,aX), x1=Math.max.apply(null,aX);
var y0=Math.min.apply(null,aY), y1=Math.max.apply(null,aY);
var w=x1-x0, h=y1-y0;
if (w<=0 || h<=0) return;
var rr = Math.min(r, w/2, h/2);
var nd;
if (orientation === 'h') {
nd = 'M '+x0+','+y0
+ ' H '+(x1-rr)+' Q '+x1+','+y0+' '+x1+','+(y0+rr)
+ ' V '+(y1-rr)+' Q '+x1+','+y1+' '+(x1-rr)+','+y1
+ ' H '+x0+' V '+y0+' Z';
} else {
nd = 'M '+x0+','+y1
+ ' H '+x1
+ ' V '+(y0+rr)
+ ' Q '+x1+','+y0+' '+(x1-rr)+','+y0
+ ' H '+(x0+rr)+' Q '+x0+','+y0+' '+x0+','+(y0+rr)
+ ' V '+y1+' Z';
}
p.setAttribute('d', nd);
});
}, 80);
}
function renderFrame(f) {
CARDS_CFG.forEach(function(c) {
var cd = f.cards[c.col];
if (cd) document.getElementById('mv_'+c.col).textContent = cd.valor+cd.sufixo;
});
f.linha1.forEach(function(item, i) {
if (item.tipo==='barra_h') renderBarH(item, 'l1_'+i);
else if (item.tipo==='barra_v') renderBarV(item, 'l1_'+i);
else if (item.tipo==='linha') renderLine(item, 'l1_'+i);
});
f.linha2.forEach(function(item, i) {
if (item.tipo==='donut') renderDonut(item, 'l2_'+i);
else if (item.tipo==='linha_sis') renderLineSis(item, 'l2_'+i);
else if (item.tipo==='barv_sis') renderBarVSis(item, 'l2_'+i);
});
}
function stepFrame() {
renderFrame(FRAMES[frameIdx]);
frameIdx++;
if (frameIdx >= FRAMES.length) {
clearTimeout(animTimer);
cdownTimer = setTimeout(function(){ frameIdx=0; startAnimation(); }, PAUSE_MS);
} else {
animTimer = setTimeout(stepFrame, FRAME_MS);
}
}
function startAnimation() {
clearTimeout(animTimer);
clearTimeout(cdownTimer);
animTimer = setTimeout(stepFrame, FRAME_MS);
}
var _rt;
window.addEventListener('resize', function() {
clearTimeout(_rt);
_rt = setTimeout(function() {
document.querySelectorAll('.cp').forEach(function(box) {
if (box.data) Plotly.relayout(box, {width:box.clientWidth, height:box.clientHeight});
});
}, 200);
});
window.addEventListener('load', function() {
buildDOM();
setTimeout(function(){ frameIdx=0; startAnimation(); }, 400);
});
</script>
</body>
</html>"""
html = (html
.replace('__FRAMES__', frames_json)
.replace('__MESES__', meses_json)
.replace('__TITULOS_L1__', json.dumps(TITULOS_L1))
.replace('__TITULOS_L2__', json.dumps(TITULOS_L2))
.replace('__SUBS_L1__', json.dumps(subs_l1))
.replace('__SUBS_L2__', json.dumps(subs_l2))
)
components.html(html, height=8000, scrolling=True)
else:
components.html("""<!DOCTYPE html><html><body style="display:flex;align-items:center;
justify-content:center;height:100dvh;font-family:sans-serif;color:#003a70;background:#f5f7fa;">
<div style="text-align:center"><div style="font-size:2em">📊</div>
<p>Aguardando dados... Recarregue a página.</p></div></body></html>""",
height=4000, scrolling=False)
time.sleep(60)
st.rerun()