Spaces:
Sleeping
Sleeping
| """ | |
| 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'} | |
| } | |
| 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" | |
| ) | |