Vortex-Flux / src /modules /map_dashboard.py
klydekushy's picture
Update src/modules/map_dashboard.py
4d7a465 verified
import streamlit as st
import pandas as pd
import pydeck as pdk
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderTimedOut
import time
import requests
import datetime
# --- CONSTANTES ---
COLOR_BLUE_GOTHAM = "#25C1F7"
COLOR_GREEN_ZONE = "#54BD4B"
COLOR_YELLOW_LINE = "#FFD700"
# === CSS SPÉCIFIQUE MAP DASHBOARD (Styles additionnels seulement) ===
def apply_map_dashboard_styles():
st.markdown("""
<style>
/* === STYLES SPÉCIFIQUES MAP DASHBOARD === */
/* Wrapper pour isolation */
#map-dashboard-module {
font-family: 'Space Grotesk', sans-serif;
}
/* Réduction des marges pour vue compacte */
#map-dashboard-module .block-container {
padding-top: 1rem !important;
padding-bottom: 0.5rem !important;
}
/* Headers compacts spécifiques à la map */
#map-dashboard-module h1 {
font-size: 1.5rem !important;
margin-bottom: 12px !important;
margin-top: 0 !important;
}
#map-dashboard-module h2,
#map-dashboard-module h3 {
font-size: 0.9rem !important;
margin-bottom: 6px !important;
margin-top: 0 !important;
}
/* Colonnes encadrées style ops center */
#map-dashboard-module [data-testid="column"] {
background: rgba(22, 27, 34, 0.6);
border: 1px solid rgba(88, 166, 255, 0.3);
border-radius: 4px;
padding: 12px !important;
margin: 2px;
backdrop-filter: blur(10px);
box-shadow: inset 0 0 20px rgba(88, 166, 255, 0.05);
}
/* Metrics ultra compacts pour la map */
#map-dashboard-module [data-testid="stMetric"] {
background: rgba(13, 17, 23, 0.9);
border: 1px solid rgba(48, 54, 61, 1);
padding: 6px !important;
margin: 0 !important;
}
#map-dashboard-module [data-testid="stMetric"] label {
font-size: 0.65rem !important;
margin-bottom: 2px !important;
}
#map-dashboard-module [data-testid="stMetric"] [data-testid="stMetricValue"] {
font-size: 1.1rem !important;
margin: 0 !important;
}
#map-dashboard-module [data-testid="stMetric"] [data-testid="stMetricDelta"] {
font-size: 0.7rem !important;
margin-top: 2px !important;
}
/* Expanders ultra compacts */
#map-dashboard-module .streamlit-expanderHeader {
background: rgba(13, 17, 23, 0.9) !important;
font-size: 0.75rem !important;
padding: 6px 10px !important;
margin-bottom: 3px !important;
margin-top: 0 !important;
}
#map-dashboard-module .streamlit-expanderContent {
background: rgba(13, 17, 23, 0.6);
padding: 8px !important;
margin-bottom: 3px !important;
}
/* Checkbox compacts */
#map-dashboard-module .stCheckbox {
margin: 2px 0 !important;
padding: 2px 0 !important;
}
#map-dashboard-module .stCheckbox label {
font-size: 0.75rem !important;
}
/* Caption text compact */
#map-dashboard-module .caption,
#map-dashboard-module [data-testid="stCaptionContainer"] {
font-size: 0.7rem !important;
margin: 2px 0 !important;
line-height: 1.2 !important;
}
/* === ALERT BOXES PERSONNALISÉES === */
.map-alert-box {
background: rgba(22, 27, 34, 0.9);
border: 1px solid;
border-radius: 3px;
padding: 6px 8px !important;
margin-bottom: 4px !important;
font-size: 0.7rem;
transition: all 0.2s ease;
cursor: pointer;
}
.map-alert-box:hover {
background: rgba(33, 38, 45, 1);
transform: translateX(2px);
}
.map-alert-critical {
border-color: #f85149;
background: rgba(248, 81, 73, 0.15);
}
.map-alert-warning {
border-color: #d29922;
background: rgba(210, 153, 34, 0.15);
}
.map-alert-success {
border-color: #3fb950;
background: rgba(63, 185, 80, 0.15);
}
.map-alert-info {
border-color: #58a6ff;
background: rgba(88, 166, 255, 0.15);
}
/* Button compact pour la map */
#map-dashboard-module .stButton > button {
font-size: 0.7rem !important;
padding: 4px 10px !important;
margin: 2px 0 !important;
}
/* Réduire l'espacement vertical dans la map */
#map-dashboard-module .element-container {
margin: 0 !important;
padding: 0 !important;
}
/* Divider compact */
#map-dashboard-module hr {
margin: 8px 0 !important;
}
</style>
""", unsafe_allow_html=True)
# --- FONCTION GÉOCODAGE ---
@st.cache_data(show_spinner=False, ttl=3600)
def geocode_addresses(df_clients):
"""Géocodage via LocationIQ"""
if 'Adresse' not in df_clients.columns or 'ID_Client' not in df_clients.columns:
return pd.DataFrame()
LOCATIONIQ_TOKEN = "pk.e1561a89e1ed2bc2ddeb3ee53fd88fb8"
geocoded_data = []
for index, row in df_clients.iterrows():
address = str(row['Adresse']).strip()
client_id = row['ID_Client']
try:
url = "https://us1.locationiq.com/v1/search.php"
params = {
'key': LOCATIONIQ_TOKEN,
'q': address,
'format': 'json',
'countrycodes': 'sn',
'limit': 1,
'accept-language': 'fr'
}
response = requests.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
if data and len(data) > 0:
result = data[0]
lat = float(result['lat'])
lon = float(result['lon'])
geocoded_data.append({
"ID": client_id,
"Nom": row.get('Nom_Complet', 'Client Inconnu'),
"Adresse": address,
"lat": lat,
"lon": lon,
"Revenus": row.get('Revenus_Mensuels', 0)
})
time.sleep(1.2)
except Exception as e:
pass
return pd.DataFrame(geocoded_data)
# --- FONCTION PRINCIPALE ---
def show_map_dashboard(client, sheet_name):
# Appliquer les styles spécifiques
apply_map_dashboard_styles()
# Wrapper pour isolation des styles
st.markdown('<div id="map-dashboard-module">', unsafe_allow_html=True)
st.markdown("<h1> GLOBAL CONTROL TOWER</h1>", unsafe_allow_html=True)
# === CHARGEMENT DES DONNÉES ===
try:
sh = client.open(sheet_name)
ws_clients = sh.worksheet("Clients_KYC")
ws_prets = sh.worksheet("Prets_Master")
df_raw = pd.DataFrame(ws_clients.get_all_records())
df_prets = pd.DataFrame(ws_prets.get_all_records())
except Exception as e:
st.error(f"Erreur de lecture : {e}")
st.markdown('</div>', unsafe_allow_html=True)
return
if df_raw.empty:
st.info("Aucune donnée client.")
st.markdown('</div>', unsafe_allow_html=True)
return
# === 5 METRICS TEMPS RÉEL ===
col_m1, col_m2, col_m3, col_m4, col_m5 = st.columns(5)
# Calculs depuis Prets_Master
if not df_prets.empty:
total_prets = len(df_prets)
# Conversion sécurisée du montant en numérique
if 'Montant_Capital' in df_prets.columns:
montant_total = pd.to_numeric(df_prets['Montant_Capital'], errors='coerce').fillna(0).sum()
else:
montant_total = 0
prets_actifs = len(df_prets[df_prets['Statut'] == 'Actif']) if 'Statut' in df_prets.columns else 0
# Conversion sécurisée du taux en numérique
if 'Taux_Hebdo' in df_prets.columns:
taux_moyen = pd.to_numeric(df_prets['Taux_Hebdo'], errors='coerce').fillna(0).mean()
else:
taux_moyen = 0
prets_semaine = len(df_prets.tail(5)) if len(df_prets) >= 5 else len(df_prets)
else:
total_prets = montant_total = prets_actifs = taux_moyen = prets_semaine = 0
with col_m1:
st.metric("PRÊTS ACTIFS", f"{prets_actifs}", f"+{prets_semaine} (7j)")
with col_m2:
st.metric("CAPITAL DÉPLOYÉ", f"{montant_total/1e6:.1f}M XOF", "+12%")
with col_m3:
st.metric("TAUX MOYEN", f"{taux_moyen:.1f}%", "-0.2%")
with col_m4:
st.metric("CLIENTS ACTIFS", f"{len(df_raw)}", "+3")
with col_m5:
st.metric("RECOUVREMENT", "94.2%", "+1.8%")
st.markdown("---")
# === GÉOCODAGE ===
with st.spinner(" Triangulation satellites..."):
df_map = geocode_addresses(df_raw)
if df_map.empty:
st.warning("⚠️ Impossible de géolocaliser les adresses.")
st.markdown('</div>', unsafe_allow_html=True)
return
# === LAYOUT PRINCIPAL : 3 COLONNES ===
col_left, col_center, col_right = st.columns([1, 3, 1])
# === COLONNE GAUCHE : CLIENTS ===
with col_left:
st.markdown("### TARGETS")
st.caption(f"{len(df_map)} clients géolocalisés")
# Session state pour focus
if 'focus_lat' not in st.session_state:
st.session_state['focus_lat'] = df_map['lat'].mean()
st.session_state['focus_lon'] = df_map['lon'].mean()
st.session_state['focus_id'] = None
for idx, row in df_map.iterrows():
client_key = f"client_{row['ID']}"
with st.expander(f"🔹 {row['ID']}", expanded=False):
st.caption(f"{row['Nom']}")
st.caption(f" {row['Adresse'][:35]}...")
st.caption(f" {row['Revenus']:,.0f} XOF")
if st.button("LOCALISER", key=f"loc_{client_key}", use_container_width=True):
st.session_state['focus_lat'] = row['lat']
st.session_state['focus_lon'] = row['lon']
st.session_state['focus_id'] = row['ID']
st.rerun()
# === COLONNE CENTRALE : CARTE ===
with col_center:
st.markdown("### TACTICAL MAP")
# Préparer données clients
clients_js = []
for _, row in df_map.iterrows():
clients_js.append({
'lat': row['lat'],
'lng': row['lon'],
'id': row['ID'],
'nom': row['Nom'],
'adresse': row['Adresse']
})
# Focus
focus_lat = st.session_state.get('focus_lat', df_map['lat'].mean())
focus_lng = st.session_state.get('focus_lon', df_map['lon'].mean())
# HTML Mapbox
mapbox_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css' rel='stylesheet' />
<style>
body {{ margin: 0; padding: 0; }}
#map {{ position: absolute; top: 0; bottom: 0; width: 100%; }}
</style>
</head>
<body>
<div id="map"></div>
<script>
mapboxgl.accessToken = 'pk.eyJ1Ijoia2x5ZGUwNyIsImEiOiJjbWpwd3B4cWMwYTVqM2ZxeHcwM3FoMHF5In0.mgiTZbug_4eD3cSNgF2t5Q';
const map = new mapboxgl.Map({{
container: 'map',
style: 'mapbox://styles/mapbox/satellite-streets-v12',
center: [{focus_lng}, {focus_lat}],
zoom: 15,
pitch: 60,
bearing: 0,
antialias: true
}});
map.on('load', () => {{
// Bâtiments 3D
map.addLayer({{
'id': '3d-buildings',
'source': 'composite',
'source-layer': 'building',
'filter': ['==', 'extrude', 'true'],
'type': 'fill-extrusion',
'minzoom': 14,
'paint': {{
'fill-extrusion-color': '#aaa',
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': ['get', 'min_height'],
'fill-extrusion-opacity': 0.8
}}
}});
const clients = {clients_js};
// Marqueurs clients (Losanges bleus)
const clientSvg = `
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="12" height="12"
fill="#58a6ff"
stroke="#c9d1d9"
stroke-width="1.5"
transform="rotate(45 12 12)"
filter="drop-shadow(0 0 6px rgba(88, 166, 255, 0.8))"/>
</svg>`;
clients.forEach((client, index) => {{
const el = document.createElement('div');
el.innerHTML = clientSvg;
el.style.cursor = 'pointer';
new mapboxgl.Marker({{element: el}})
.setLngLat([client.lng, client.lat])
.setPopup(new mapboxgl.Popup({{ offset: 25 }}).setHTML(
`<div style="font-family: Space Grotesk; color: #c9d1d9; background: #161b22; padding: 8px; border-radius: 4px;">
<strong style="color: #58a6ff;">${{client.id}}</strong><br>
<span style="font-size: 0.85rem;">${{client.nom}}</span><br>
<span style="font-size: 0.75rem; color: #8b949e;">${{client.adresse.substring(0, 40)}}...</span>
</div>`
))
.addTo(map);
}});
// Lignes de connexion entre clients
if (clients.length > 1) {{
for (let i = 0; i < clients.length - 1; i++) {{
map.addLayer({{
'id': 'line-' + i,
'type': 'line',
'source': {{
'type': 'geojson',
'data': {{
'type': 'Feature',
'geometry': {{
'type': 'LineString',
'coordinates': [
[clients[i].lng, clients[i].lat],
[clients[i+1].lng, clients[i+1].lat]
]
}}
}}
}},
'paint': {{
'line-color': '#3fb950',
'line-width': 1.5,
'line-opacity': 0.4
}}
}});
}}
}}
}});
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
</script>
</body>
</html>
"""
st.components.v1.html(mapbox_html, height=500)
# === COLONNE DROITE : ALERTES ===
with col_right:
st.markdown("### LIVE FEED")
now = datetime.datetime.now()
alerts = [
{
'type': 'critical',
'title': 'SEUIL ATTEINT',
'message': 'NPL > 15% détecté',
'time': (now - datetime.timedelta(minutes=2)).strftime('%H:%M')
},
{
'type': 'warning',
'title': 'ANOMALIE DÉTECTÉE',
'message': 'CA mensuel -12%',
'time': (now - datetime.timedelta(minutes=15)).strftime('%H:%M')
},
{
'type': 'success',
'title': 'VALIDATION',
'message': f'{df_map.iloc[0]["ID"]} approuvé',
'time': (now - datetime.timedelta(minutes=23)).strftime('%H:%M')
},
{
'type': 'info',
'title': 'RECOMMANDATION',
'message': 'Augmenter taux +0.5%',
'time': (now - datetime.timedelta(hours=1)).strftime('%H:%M')
},
{
'type': 'warning',
'title': 'RETARD PAIEMENT',
'message': '3 clients en J+7',
'time': (now - datetime.timedelta(hours=2)).strftime('%H:%M')
},
]
for alert in alerts:
st.markdown(f"""
<div class="map-alert-box map-alert-{alert['type']}">
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<strong style="color: #c9d1d9; font-size: 0.7rem;">{alert['title']}</strong>
<span style="color: #8b949e; font-size: 0.65rem;">{alert['time']}</span>
</div>
<div style="color: #8b949e; font-size: 0.7rem; line-height: 1.2;">{alert['message']}</div>
</div>
""", unsafe_allow_html=True)
# === STATS ANSD ===
st.markdown("---")
st.markdown("### INDICATEURS MACROÉCONOMIQUES ANSD")
col1, col2, col3 = st.columns(3)
with col1:
st.metric("Croissance PIB", "5.2%", "+0.3%")
st.metric("Taux BCEAO", "3.0%", "-0.5%")
st.metric("NPL Bancaires", "14.8%", "+1.2%")
with col2:
st.metric("Emploi Informel", "68%", "-2%")
st.metric("Dette Publique", "72% PIB", "+3%")
st.metric("Transferts Diaspora", "2.1B USD", "+5%")
with col3:
st.metric("Inflation Alimentaire", "8.3%", "+1.5%")
st.metric("Taux EUR/USD", "1.08", "-0.02")
st.metric("Production Agricole", "92 idx", "-8 idx")
# Fermeture du wrapper
st.markdown('</div>', unsafe_allow_html=True)