vutia-streamlit / app.py
perellorets's picture
Upload app.py with huggingface_hub
4d0651e verified
"""
VUTIA - Sistema de Análisis de Viviendas de Uso Turístico
Ayuntamiento de Dénia - Plataforma de Análisis Institucional
"""
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import numpy as np
from datetime import datetime
from io import BytesIO
import os
# Configuración de la página
st.set_page_config(
page_title="VUTIA - Sistema de Análisis VUT",
page_icon="📊",
layout="wide",
initial_sidebar_state="expanded"
)
# CSS Profesional Institucional (copiado del original)
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
/* VARIABLES */
:root {
--bg-primary: #0a0e27;
--bg-secondary: #141832;
--bg-tertiary: #1a1f3a;
--bg-card: rgba(20, 24, 50, 0.6);
--border-color: rgba(99, 102, 241, 0.2);
--border-subtle: rgba(99, 102, 241, 0.1);
--text-primary: rgba(255, 255, 255, 0.95);
--text-secondary: rgba(255, 255, 255, 0.7);
--text-tertiary: rgba(255, 255, 255, 0.5);
--accent-primary: #006AA7;
--accent-hover: #017CB5;
--accent-light: rgba(0, 106, 167, 0.2);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
/* RESET BASE */
.stApp {
background: linear-gradient(135deg, #0a0e27 0%, #141832 100%);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
color: var(--text-primary);
}
/* === HEADER INSTITUCIONAL MODERNO Y SOBRIO === */
.header-institucional {
position: relative;
padding: 3.5rem 3rem;
background: linear-gradient(135deg,
rgba(0, 106, 167, 0.15) 0%,
rgba(1, 124, 181, 0.12) 25%,
rgba(0, 153, 146, 0.10) 50%,
rgba(0, 170, 152, 0.08) 75%,
rgba(67, 193, 121, 0.05) 100%);
border-bottom: 1px solid rgba(67, 193, 121, 0.2);
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
backdrop-filter: blur(10px);
box-shadow:
0 4px 30px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.header-institucional::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(90deg,
transparent 0%,
rgba(67, 193, 121, 0.03) 50%,
transparent 100%),
linear-gradient(0deg,
rgba(0, 106, 167, 0.08) 0%,
transparent 100%);
pointer-events: none;
z-index: 0;
}
/* Línea de escaneo animada - Efecto sobrio */
.header-scan-line {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
rgba(67, 193, 121, 0.4) 50%,
transparent 100%);
animation: scan 6s ease-in-out infinite;
z-index: 2;
opacity: 0.4;
}
@keyframes scan {
0%, 100% { left: -100%; opacity: 0; }
50% { left: 100%; opacity: 0.4; }
}
/* Efectos de luz sutiles */
.header-light-effect-1,
.header-light-effect-2 {
position: absolute;
width: 500px;
height: 160%;
pointer-events: none;
z-index: 1;
animation: glow-pulse-advanced 6s ease-in-out infinite;
filter: blur(30px);
}
.header-light-effect-1 {
top: -40%;
right: -10%;
background: radial-gradient(ellipse at center,
rgba(67, 193, 121, 0.2) 0%,
rgba(0, 170, 152, 0.12) 25%,
rgba(139, 182, 64, 0.06) 50%,
transparent 70%);
}
.header-light-effect-2 {
top: -30%;
left: -10%;
background: radial-gradient(ellipse at center,
rgba(0, 106, 167, 0.18) 0%,
rgba(1, 124, 181, 0.10) 25%,
rgba(0, 153, 146, 0.05) 50%,
transparent 70%);
animation-delay: 1s;
}
@keyframes glow-pulse-advanced {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.08); }
}
/* Contenido del header */
.header-content {
position: relative;
z-index: 3;
flex: 1;
}
.header-title {
font-size: 2.8rem;
font-weight: 900;
color: white;
margin: 0;
letter-spacing: 0.02em;
line-height: 1.2;
text-shadow:
0 0 12px rgba(67, 193, 121, 0.8),
0 0 25px rgba(67, 193, 121, 0.6),
0 0 40px rgba(0, 170, 152, 0.4),
0 4px 20px rgba(0, 0, 0, 0.7);
position: relative;
animation: text-glow-holographic 4s ease-in-out infinite;
filter: drop-shadow(0 0 25px rgba(67, 193, 121, 0.6));
}
@keyframes text-glow-holographic {
0%, 100% {
text-shadow:
0 0 12px rgba(67, 193, 121, 0.8),
0 0 25px rgba(67, 193, 121, 0.6),
0 0 40px rgba(0, 170, 152, 0.4),
0 4px 20px rgba(0, 0, 0, 0.7);
}
50% {
text-shadow:
0 0 15px rgba(139, 182, 64, 0.8),
0 0 30px rgba(181, 164, 53, 0.6),
0 0 50px rgba(67, 193, 121, 0.4),
0 4px 20px rgba(0, 0, 0, 0.7);
}
}
.header-subtitle {
font-size: 1rem;
font-weight: 400;
color: rgba(255, 255, 255, 0.85);
margin: 0.5rem 0 0 0;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.9;
}
/* Badge VUT Moderno y Sobrio */
.header-vut-badge {
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 4;
padding: 1rem 1.8rem;
background: linear-gradient(135deg,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.3) 100%);
border-radius: 16px;
border: 2px solid rgba(67, 193, 121, 0.4);
backdrop-filter: blur(20px);
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
transition: all 0.3s ease;
}
.header-vut-badge:hover {
transform: translateY(-2px);
box-shadow:
0 12px 35px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
border-color: rgba(67, 193, 121, 0.6);
}
.header-vut-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.header-vut-acronym {
font-size: 2.8rem;
font-weight: 900;
color: white;
margin: 0;
letter-spacing: 0.12em;
line-height: 1;
text-shadow:
0 0 15px rgba(67, 193, 121, 0.8),
0 0 30px rgba(139, 182, 64, 0.6),
0 0 45px rgba(181, 164, 53, 0.4),
0 4px 15px rgba(0, 0, 0, 0.7);
animation: vut-glow 5s ease-in-out infinite;
}
@keyframes vut-glow {
0%, 100% {
text-shadow:
0 0 15px rgba(67, 193, 121, 0.8),
0 0 30px rgba(139, 182, 64, 0.6),
0 0 45px rgba(181, 164, 53, 0.4),
0 4px 15px rgba(0, 0, 0, 0.7);
}
50% {
text-shadow:
0 0 20px rgba(139, 182, 64, 0.9),
0 0 40px rgba(67, 193, 121, 0.7),
0 0 60px rgba(0, 170, 152, 0.5),
0 4px 15px rgba(0, 0, 0, 0.7);
}
}
.header-vut-subtext {
font-size: 0.75rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.1em;
text-transform: uppercase;
margin: 0;
}
/* Resto del CSS original... */
#MainMenu {visibility: hidden;}
footer {visibility: hidden;}
header {visibility: hidden;}
</style>
""", unsafe_allow_html=True)
# Colores corporativos
COLORS = {
'VUT_CONFIRMADA': '#006AA7',
'VUT_POSIBLE': '#017CB5',
'VUT_BAJACONFIANZA_NOREGLADA': '#009992',
'HABITUAL': '#00AA98',
'SEGUNDA_RESIDENCIA': '#43C179',
'VACIA': '#8BB640'
}
def get_chart_layout():
return {
'paper_bgcolor': 'rgba(20, 24, 50, 0.5)',
'plot_bgcolor': 'rgba(26, 31, 58, 0.5)',
'font': {'family': 'Inter', 'size': 12, 'color': 'white'},
'margin': {'t': 40, 'r': 20, 'b': 40, 'l': 50},
'xaxis': {'gridcolor': 'rgba(67, 193, 121, 0.1)', 'color': 'white'},
'yaxis': {'gridcolor': 'rgba(67, 193, 121, 0.1)', 'color': 'white'}
}
@st.cache_data
def cargar_datos(uploaded_file=None):
"""Carga datos desde archivo subido o usa datos de demostración"""
if uploaded_file is not None:
try:
df = pd.read_excel(uploaded_file)
return df
except Exception as e:
st.error(f"Error al cargar el archivo: {e}")
return None
else:
# Datos de demostración
st.info("📊 Usando datos de demostración. Sube tu archivo Excel para ver tus datos reales.")
return crear_datos_demo()
def crear_datos_demo():
"""Crea datos de demostración para pruebas"""
np.random.seed(42)
n_samples = 1000
categorias = ['VUT_CONFIRMADA', 'VUT_POSIBLE', 'VUT_BAJACONFIANZA_NOREGLADA',
'HABITUAL', 'SEGUNDA_RESIDENCIA', 'VACIA']
barrios = ['Centro', 'Les Rotes', 'Les Marines', 'La Xara', 'Jesus Pobre',
'Els Poblets', 'Dénia Nord', 'Dénia Sud']
data = {
'Direccion': [f'Calle Demo {i}' for i in range(n_samples)],
'Barrio': np.random.choice(barrios, n_samples),
'Categoria': np.random.choice(categorias, n_samples,
p=[0.15, 0.20, 0.15, 0.25, 0.15, 0.10]),
'Confianza': np.random.uniform(0.3, 1.0, n_samples),
'Consumo_Total_Periodo_m3': np.random.uniform(50, 500, n_samples),
'Ratio_Verano_Invierno': np.random.uniform(0.5, 3.0, n_samples)
}
return pd.DataFrame(data)
# Header institucional
st.markdown("""
<div class="header-institucional">
<div class="header-scan-line"></div>
<div class="header-light-effect-1"></div>
<div class="header-light-effect-2"></div>
<div class="header-content">
<div class="header-title">VUTIA</div>
<div class="header-subtitle">Sistema de Análisis de Viviendas de Uso Turístico</div>
</div>
<div class="header-vut-badge">
<div class="header-vut-text">
<div class="header-vut-acronym">VUT</div>
<div class="header-vut-subtext">Sistema de Análisis</div>
</div>
</div>
</div>
""", unsafe_allow_html=True)
# Subida de archivo
with st.sidebar:
st.markdown("### 📁 CARGAR DATOS")
uploaded_file = st.file_uploader(
"Sube tu archivo Excel",
type=['xlsx', 'xls'],
help="Archivo con datos de clasificación VUT"
)
st.markdown("---")
# Cargar datos
df = cargar_datos(uploaded_file)
if df is None:
st.error("No se pudieron cargar los datos")
st.stop()
# SIDEBAR DE FILTROS
with st.sidebar:
st.markdown("### 🔍 FILTROS")
categorias = df['Categoria'].unique().tolist()
cat_sel = st.multiselect("Categorías", categorias, default=categorias)
barrios = ['Todos'] + sorted(df['Barrio'].unique().tolist())
barrio_sel = st.selectbox("Barrio", barrios)
confianza_min = st.slider("Confianza Mínima (%)", 0, 100, 0, 5)
# Aplicar filtros
df_filtrado = df.copy()
if cat_sel:
df_filtrado = df_filtrado[df_filtrado['Categoria'].isin(cat_sel)]
if barrio_sel != 'Todos':
df_filtrado = df_filtrado[df_filtrado['Barrio'] == barrio_sel]
df_filtrado = df_filtrado[df_filtrado['Confianza'] >= confianza_min / 100]
# Estadísticas
st.markdown("### ESTADÍSTICAS")
col1, col2 = st.columns(2)
with col1:
st.metric("Filtradas", f"{len(df_filtrado):,}")
with col2:
st.metric("Total", f"{len(df):,}")
if len(df_filtrado) < len(df):
pct = (len(df_filtrado) / len(df)) * 100
st.caption(f"Mostrando {pct:.1f}% del total")
st.markdown("---")
st.caption("**VUTIA v3.0**")
st.caption("© 2026 Ayuntamiento de Dénia")
# TABS
tab1, tab2, tab3, tab4 = st.tabs([
"Panel General",
"Análisis VUT",
"Datos",
"Exportación"
])
# TAB 1: PANEL GENERAL
with tab1:
st.markdown("## Panel General")
st.markdown("")
# Métricas principales
col1, col2, col3, col4, col5, col6 = st.columns(6)
with col1:
n = len(df_filtrado[df_filtrado['Categoria'] == 'VUT_CONFIRMADA'])
st.metric("VUT Confirmada", f"{n:,}",
f"{(n/len(df_filtrado)*100):.1f}%" if len(df_filtrado) > 0 else "0%")
with col2:
n = len(df_filtrado[df_filtrado['Categoria'] == 'VUT_POSIBLE'])
st.metric("VUT Posible", f"{n:,}",
f"{(n/len(df_filtrado)*100):.1f}%" if len(df_filtrado) > 0 else "0%")
with col3:
n = len(df_filtrado[df_filtrado['Categoria'] == 'VUT_BAJACONFIANZA_NOREGLADA'])
st.metric("VUT Baja Conf.", f"{n:,}",
f"{(n/len(df_filtrado)*100):.1f}%" if len(df_filtrado) > 0 else "0%")
with col4:
n = len(df_filtrado[df_filtrado['Categoria'] == 'HABITUAL'])
st.metric("Habitual", f"{n:,}",
f"{(n/len(df_filtrado)*100):.1f}%" if len(df_filtrado) > 0 else "0%")
with col5:
n = len(df_filtrado[df_filtrado['Categoria'] == 'SEGUNDA_RESIDENCIA'])
st.metric("Segunda Res.", f"{n:,}",
f"{(n/len(df_filtrado)*100):.1f}%" if len(df_filtrado) > 0 else "0%")
with col6:
n = len(df_filtrado[df_filtrado['Categoria'] == 'VACIA'])
st.metric("Vacía", f"{n:,}",
f"{(n/len(df_filtrado)*100):.1f}%" if len(df_filtrado) > 0 else "0%")
st.markdown("---")
# Gráfico de distribución
st.markdown("### Distribución por Categorías")
dist = df_filtrado['Categoria'].value_counts()
fig = go.Figure(data=[go.Pie(
labels=dist.index,
values=dist.values,
hole=.45,
marker=dict(
colors=[COLORS.get(c, '#017CB5') for c in dist.index],
line=dict(color='rgba(10, 14, 39, 0.8)', width=3)
),
textfont=dict(size=12, family='Inter', color='white'),
hovertemplate='<b>%{label}</b><br>%{value:,} viviendas<br>%{percent}<extra></extra>'
)])
layout = get_chart_layout()
layout['height'] = 400
fig.update_layout(layout)
st.plotly_chart(fig, use_container_width=True)
# TAB 2: ANÁLISIS VUT
with tab2:
st.markdown("## Análisis Detallado de VUT")
vut_categories = ['VUT_CONFIRMADA', 'VUT_POSIBLE', 'VUT_BAJACONFIANZA_NOREGLADA']
df_vut = df_filtrado[df_filtrado['Categoria'].isin(vut_categories)]
col1, col2, col3 = st.columns(3)
with col1:
st.metric("Total VUT", f"{len(df_vut):,}")
with col2:
st.metric("Confirmadas", f"{len(df_vut[df_vut['Categoria']=='VUT_CONFIRMADA']):,}")
with col3:
avg_conf = df_vut['Confianza'].mean() * 100 if len(df_vut) > 0 else 0
st.metric("Confianza Media", f"{avg_conf:.1f}%")
st.markdown("---")
# Top 20 VUT
st.markdown("### Top 20 VUT por Confianza")
top20 = df_vut.nlargest(20, 'Confianza')
for idx, row in top20.iterrows():
with st.container():
col1, col2, col3, col4 = st.columns([3, 2, 2, 2])
with col1:
st.write(f"**{row['Direccion']}**")
with col2:
st.write(f"Barrio: {row['Barrio']}")
with col3:
st.write(f"Categoría: {row['Categoria']}")
with col4:
st.write(f"Confianza: {row['Confianza']*100:.1f}%")
st.markdown("---")
# TAB 3: DATOS
with tab3:
st.markdown("## Datos Detallados")
# Búsqueda
busqueda = st.text_input("🔍 Buscar por dirección o barrio")
df_mostrar = df_filtrado.copy()
if busqueda:
mask = (df_mostrar['Direccion'].str.contains(busqueda, case=False, na=False) |
df_mostrar['Barrio'].str.contains(busqueda, case=False, na=False))
df_mostrar = df_mostrar[mask]
st.dataframe(
df_mostrar[['Direccion', 'Barrio', 'Categoria', 'Confianza', 'Consumo_Total_Periodo_m3']],
use_container_width=True,
height=600
)
st.caption(f"Mostrando {len(df_mostrar):,} de {len(df_filtrado):,} viviendas")
# TAB 4: EXPORTACIÓN
with tab4:
st.markdown("## Exportación de Datos")
st.markdown("### Descargar Datos Filtrados")
# Convertir a Excel
buffer = BytesIO()
with pd.ExcelWriter(buffer, engine='openpyxl') as writer:
df_filtrado.to_excel(writer, index=False, sheet_name='Datos')
buffer.seek(0)
st.download_button(
label="📊 Descargar Excel",
data=buffer,
file_name=f"vutia_datos_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
# Convertir a CSV
csv = df_filtrado.to_csv(index=False).encode('utf-8')
st.download_button(
label="📄 Descargar CSV",
data=csv,
file_name=f"vutia_datos_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
mime="text/csv"
)