klydekushy commited on
Commit
2cffa8f
·
verified ·
1 Parent(s): 6a4992e

Update src/modules/map_dashboard.py

Browse files
Files changed (1) hide show
  1. src/modules/map_dashboard.py +385 -140
src/modules/map_dashboard.py CHANGED
@@ -1,34 +1,210 @@
1
  import streamlit as st
2
  import pandas as pd
3
- import pydeck as pdk # <-- Cette ligne doit être présente
4
  from geopy.geocoders import Nominatim
5
  from geopy.exc import GeocoderTimedOut
6
  import time
7
  import requests
 
8
 
 
 
 
 
9
 
10
- # Ajoutez ceci juste après vos imports, avant les constantes
 
 
 
11
 
12
- # --- CONSTANTES GOTHAM & CONFIG ---
13
- HQ_LOCATION = [48.913418, 2.396667] # 1 Rue Marcelin Berthelot, Aubervilliers
14
- COLOR_BLUE_GOTHAM = "#25C1F7" # Bleu clair
15
- COLOR_BLUE_FILL = "#83D6F7" # Remplissage bleu
16
- COLOR_GREEN_ZONE = "#54BD4B" # Vert Cluster
17
- COLOR_YELLOW_LINE = "#FFD700" # Lignes de distance
18
 
 
 
 
 
 
19
 
 
 
 
 
 
 
 
 
 
20
 
21
- # --- 1. FONCTIONS DE GÉOCODAGE (AVEC CACHE) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  @st.cache_data(show_spinner=False, ttl=3600)
23
  def geocode_addresses(df_clients):
24
- """Géocodage via LocationIQ - Gratuit et précis"""
25
 
26
  if 'Adresse' not in df_clients.columns or 'ID_Client' not in df_clients.columns:
27
  return pd.DataFrame()
28
 
29
- # METTEZ VOTRE TOKEN LOCATIONIQ ICI
30
  LOCATIONIQ_TOKEN = "pk.e1561a89e1ed2bc2ddeb3ee53fd88fb8"
31
-
32
  geocoded_data = []
33
 
34
  for index, row in df_clients.iterrows():
@@ -36,16 +212,12 @@ def geocode_addresses(df_clients):
36
  client_id = row['ID_Client']
37
 
38
  try:
39
- import urllib.parse
40
- import requests
41
-
42
- # URL LocationIQ Geocoding
43
  url = "https://us1.locationiq.com/v1/search.php"
44
  params = {
45
  'key': LOCATIONIQ_TOKEN,
46
  'q': address,
47
  'format': 'json',
48
- 'countrycodes': 'sn', # Limiter au Sénégal
49
  'limit': 1,
50
  'accept-language': 'fr'
51
  }
@@ -59,7 +231,6 @@ def geocode_addresses(df_clients):
59
  result = data[0]
60
  lat = float(result['lat'])
61
  lon = float(result['lon'])
62
- display_name = result['display_name']
63
 
64
  geocoded_data.append({
65
  "ID": client_id,
@@ -69,89 +240,122 @@ def geocode_addresses(df_clients):
69
  "lon": lon,
70
  "Revenus": row.get('Revenus_Mensuels', 0)
71
  })
72
-
73
- st.success(f"✅ {client_id}: {display_name}")
74
- else:
75
- st.error(f"❌ {client_id}: Adresse introuvable - '{address}'")
76
-
77
- elif response.status_code == 401:
78
- st.error(f"❌ Token LocationIQ invalide")
79
- break
80
- elif response.status_code == 429:
81
- st.error(f"⚠️ Limite de requêtes atteinte (5000/jour)")
82
- break
83
- else:
84
- st.error(f"❌ {client_id}: Erreur {response.status_code}")
85
 
86
- time.sleep(1.2) # Rate limit: max 2 req/sec (on joue safe avec 1.2s)
87
 
88
  except Exception as e:
89
- st.error(f"❌ {client_id}: Erreur - {str(e)}")
90
 
91
  return pd.DataFrame(geocoded_data)
92
-
93
-
94
 
95
- # --- 2. FONCTION PRINCIPALE D'AFFICHAGE ---
96
  def show_map_dashboard(client, sheet_name):
97
- st.markdown("## VUE TACTIQUE : GÉOLOCALISATION")
98
 
99
- # 1. Récupération des données depuis Google Sheets
100
  try:
101
  sh = client.open(sheet_name)
102
- ws = sh.worksheet("Clients_KYC")
103
- data = ws.get_all_records()
104
- df_raw = pd.DataFrame(data)
 
 
105
  except Exception as e:
106
- st.error(f"Erreur de lecture des données : {e}")
107
  return
108
 
109
  if df_raw.empty:
110
- st.info("Aucune donnée client à afficher sur la carte.")
111
  return
112
 
113
- # 2. Géocodage (mis en cache)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  with st.spinner("Triangulation des positions satellites..."):
115
  df_map = geocode_addresses(df_raw)
116
 
117
  if df_map.empty:
118
- st.warning("Impossible de géolocaliser les adresses fournies.")
119
  return
120
 
121
- # --- LAYOUT : NAVIGATEUR (GAUCHE) / CARTE (DROITE) ---
122
- col_nav, col_map = st.columns([1, 3])
123
 
124
- with col_nav:
125
- st.markdown("### CIBLES")
 
 
126
 
127
- # Filtres Tactiques
128
- st.markdown("#### FILTRES VISUELS")
129
- show_dist = st.checkbox("GeoDistance (Lignes HQ)", value=True)
130
- show_labels = st.checkbox("LabelZoom (Noms)", value=False)
131
- show_cluster = st.checkbox("ClusterZones (Densité)", value=False)
132
- show_pop = st.checkbox("PopDensity (ANSD Data)", value=False)
133
-
134
- st.divider()
135
 
136
- # Liste des clients (Cartes cliquables simulées par des expanders)
137
- st.markdown("#### LISTE CLIENTS")
138
  for idx, row in df_map.iterrows():
139
- with st.expander(f"📍 {row['ID']} - {row['Nom']}"):
140
- st.caption(f"Ad: {row['Adresse']}")
141
- st.caption(f"Rev: {row['Revenus']} XOF")
 
 
 
 
 
 
 
 
 
142
 
143
- with col_map:
144
- # Préparer les données des clients
145
- clients_markers = []
 
 
 
146
  for _, row in df_map.iterrows():
147
- clients_markers.append({
148
- 'lon': row['lon'],
149
  'lat': row['lat'],
 
150
  'id': row['ID'],
 
151
  'adresse': row['Adresse']
152
  })
153
 
154
- # Créer le HTML avec Mapbox GL
 
 
 
 
155
  mapbox_html = f"""
156
  <!DOCTYPE html>
157
  <html>
@@ -167,20 +371,20 @@ def show_map_dashboard(client, sheet_name):
167
  <body>
168
  <div id="map"></div>
169
  <script>
170
- mapboxgl.accessToken = 'pk.eyJ1Ijoia2x5ZGUwNyIsImEiOiJjbWpwd3B4cWMwYTVqM2ZxeHcwM3FoMHF5In0.mgiTZbug_4eD3cSNgF2t5Q'; // Token public Mapbox
171
 
172
  const map = new mapboxgl.Map({{
173
  container: 'map',
174
- style: 'mapbox://styles/mapbox/satellite-streets-v12', // Vue satellite
175
- center: [2.396667, 48.913418],
176
- zoom: 15,
177
- pitch: 60, // Vue 3D inclinée
178
  bearing: 0,
179
  antialias: true
180
  }});
181
 
182
  map.on('load', () => {{
183
- // Activer les bâtiments 3D
184
  map.addLayer({{
185
  'id': '3d-buildings',
186
  'source': 'composite',
@@ -189,48 +393,48 @@ def show_map_dashboard(client, sheet_name):
189
  'type': 'fill-extrusion',
190
  'minzoom': 14,
191
  'paint': {{
192
- 'fill-extrusion-color': '#aaa',
193
  'fill-extrusion-height': ['get', 'height'],
194
  'fill-extrusion-base': ['get', 'min_height'],
195
- 'fill-extrusion-opacity': 0.8
196
  }}
197
  }});
198
 
199
- // Marqueur HQ (Pyramide Rouge)
200
- const hqEl = document.createElement('div');
201
- hqEl.style.width = '30px';
202
- hqEl.style.height = '30px';
203
- hqEl.style.backgroundImage = 'linear-gradient(135deg, #ff3232 0%, #aa0000 100%)';
204
- hqEl.style.transform = 'rotate(45deg)';
205
- hqEl.style.border = '2px solid #fff';
206
- hqEl.style.boxShadow = '0 0 20px rgba(255, 50, 50, 0.8)';
207
 
208
- new mapboxgl.Marker({{element: hqEl}})
209
- .setLngLat([2.396667, 48.913418])
210
- .setPopup(new mapboxgl.Popup().setHTML('<strong>HQ - VORTEX</strong>'))
211
- .addTo(map);
 
 
 
 
 
 
212
 
213
- // Marqueurs Clients (Losanges Bleus)
214
- const clients = {clients_markers};
215
- clients.forEach(client => {{
216
  const el = document.createElement('div');
217
- el.style.width = '20px';
218
- el.style.height = '20px';
219
- el.style.background = 'linear-gradient(135deg, #25C1F7 0%, #0080ff 100%)';
220
- el.style.transform = 'rotate(45deg)';
221
- el.style.border = '1px solid #fff';
222
- el.style.boxShadow = '0 0 15px rgba(37, 193, 247, 0.6)';
223
  el.style.cursor = 'pointer';
224
 
225
  new mapboxgl.Marker({{element: el}})
226
- .setLngLat([client.lon, client.lat])
227
- .setPopup(new mapboxgl.Popup().setHTML(`<strong>${{client.id}}</strong><br>${{client.adresse}}`))
 
 
 
 
 
 
228
  .addTo(map);
229
-
230
- // Lignes de connexion (si activé)
231
- {'if (true) {' if show_dist else 'if (false) {'}
 
 
232
  map.addLayer({{
233
- 'id': 'line-' + client.id,
234
  'type': 'line',
235
  'source': {{
236
  'type': 'geojson',
@@ -239,56 +443,97 @@ def show_map_dashboard(client, sheet_name):
239
  'geometry': {{
240
  'type': 'LineString',
241
  'coordinates': [
242
- [2.396667, 48.913418],
243
- [client.lon, client.lat]
244
  ]
245
  }}
246
  }}
247
  }},
248
  'paint': {{
249
- 'line-color': '#FFD700',
250
- 'line-width': 2,
251
- 'line-opacity': 0.6
252
  }}
253
  }});
254
  }}
255
- }});
256
-
257
- // Zone de cluster (si activé)
258
- {'if (true) {' if show_cluster else 'if (false) {'}
259
- map.addLayer({{
260
- 'id': 'cluster-zone',
261
- 'type': 'circle',
262
- 'source': {{
263
- 'type': 'geojson',
264
- 'data': {{
265
- 'type': 'Feature',
266
- 'geometry': {{
267
- 'type': 'Point',
268
- 'coordinates': [{df_map['lon'].mean()}, {df_map['lat'].mean()}]
269
- }}
270
- }}
271
- }},
272
- 'paint': {{
273
- 'circle-radius': {{
274
- 'stops': [[0, 0], [20, 300]]
275
- }},
276
- 'circle-color': '#54BD4B',
277
- 'circle-opacity': 0.3,
278
- 'circle-stroke-width': 2,
279
- 'circle-stroke-color': '#54BD4B'
280
- }}
281
- }});
282
  }}
283
  }});
284
 
285
- // Contrôles de navigation
286
- map.addControl(new mapboxgl.NavigationControl());
287
  </script>
288
  </body>
289
  </html>
290
  """
291
 
292
- # Afficher la carte
293
- st.components.v1.html(mapbox_html, height=600)
294
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
  import pandas as pd
3
+ import pydeck as pdk
4
  from geopy.geocoders import Nominatim
5
  from geopy.exc import GeocoderTimedOut
6
  import time
7
  import requests
8
+ import datetime
9
 
10
+ # --- CONSTANTES ---
11
+ COLOR_BLUE_GOTHAM = "#25C1F7"
12
+ COLOR_GREEN_ZONE = "#54BD4B"
13
+ COLOR_YELLOW_LINE = "#FFD700"
14
 
15
+ # === CSS PALANTIR DARK THEME ===
16
+ st.markdown("""
17
+ <style>
18
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
19
 
20
+ /* Fond global Palantir dark */
21
+ .stApp {
22
+ background: linear-gradient(135deg, #0d1117 0%, #161b22 50%, #1c2128 100%);
23
+ }
 
 
24
 
25
+ /* Typography */
26
+ .stApp h1, .stApp h2, .stApp h3, .stApp h4, .stApp h5, .stApp h6,
27
+ .stMarkdown, .stText {
28
+ font-family: 'Space Grotesk', sans-serif !important;
29
+ }
30
 
31
+ .stApp h1 {
32
+ color: #58a6ff !important;
33
+ font-size: 1.8rem !important;
34
+ font-weight: 500 !important;
35
+ letter-spacing: 0.5px;
36
+ border-bottom: 1px solid rgba(88, 166, 255, 0.2);
37
+ padding-bottom: 12px;
38
+ margin-bottom: 24px;
39
+ }
40
 
41
+ .stApp h2, .stApp h3 {
42
+ color: #8b949e !important;
43
+ font-size: 1.1rem !important;
44
+ font-weight: 500 !important;
45
+ text-transform: uppercase;
46
+ letter-spacing: 1px;
47
+ margin-bottom: 16px;
48
+ }
49
+
50
+ /* Colonnes style ops center */
51
+ [data-testid="column"] {
52
+ background: rgba(22, 27, 34, 0.4);
53
+ border: 1px solid rgba(48, 54, 61, 0.6);
54
+ border-radius: 6px;
55
+ padding: 16px;
56
+ backdrop-filter: blur(8px);
57
+ }
58
+
59
+ /* Metrics ANSD style */
60
+ [data-testid="stMetric"] {
61
+ background: rgba(22, 27, 34, 0.8);
62
+ border: 1px solid rgba(48, 54, 61, 0.8);
63
+ border-radius: 4px;
64
+ padding: 12px;
65
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
66
+ }
67
+
68
+ [data-testid="stMetric"] label {
69
+ color: #8b949e !important;
70
+ font-size: 0.7rem !important;
71
+ font-weight: 500 !important;
72
+ text-transform: uppercase;
73
+ letter-spacing: 0.8px;
74
+ }
75
+
76
+ [data-testid="stMetric"] [data-testid="stMetricValue"] {
77
+ color: #58a6ff !important;
78
+ font-size: 1.3rem !important;
79
+ font-weight: 600 !important;
80
+ }
81
+
82
+ [data-testid="stMetric"] [data-testid="stMetricDelta"] {
83
+ font-size: 0.75rem !important;
84
+ }
85
+
86
+ /* Expanders style surveillance compact */
87
+ .streamlit-expanderHeader {
88
+ background: rgba(22, 27, 34, 0.6) !important;
89
+ border-left: 3px solid rgba(88, 166, 255, 0.6) !important;
90
+ color: #c9d1d9 !important;
91
+ font-weight: 500 !important;
92
+ font-size: 0.8rem !important;
93
+ letter-spacing: 0.3px !important;
94
+ padding: 8px 12px !important;
95
+ border-radius: 3px !important;
96
+ margin-bottom: 4px !important;
97
+ }
98
+
99
+ .streamlit-expanderHeader:hover {
100
+ background: rgba(33, 38, 45, 0.8) !important;
101
+ border-left-color: rgba(88, 166, 255, 1) !important;
102
+ }
103
+
104
+ /* Checkbox style ops */
105
+ .stCheckbox {
106
+ margin: 4px 0 !important;
107
+ }
108
+
109
+ .stCheckbox label {
110
+ color: #8b949e !important;
111
+ font-weight: 400 !important;
112
+ font-size: 0.8rem !important;
113
+ }
114
+
115
+ /* Alert boxes style feed */
116
+ .alert-box {
117
+ background: rgba(22, 27, 34, 0.8);
118
+ border-left: 3px solid;
119
+ border-radius: 4px;
120
+ padding: 10px 12px;
121
+ margin-bottom: 8px;
122
+ font-size: 0.8rem;
123
+ transition: all 0.2s ease;
124
+ cursor: pointer;
125
+ }
126
+
127
+ .alert-box:hover {
128
+ background: rgba(33, 38, 45, 0.9);
129
+ transform: translateX(2px);
130
+ }
131
+
132
+ .alert-critical {
133
+ border-left-color: #f85149;
134
+ background: rgba(248, 81, 73, 0.1);
135
+ }
136
+
137
+ .alert-warning {
138
+ border-left-color: #d29922;
139
+ background: rgba(210, 153, 34, 0.1);
140
+ }
141
+
142
+ .alert-success {
143
+ border-left-color: #3fb950;
144
+ background: rgba(63, 185, 80, 0.1);
145
+ }
146
+
147
+ .alert-info {
148
+ border-left-color: #58a6ff;
149
+ background: rgba(88, 166, 255, 0.1);
150
+ }
151
+
152
+ /* Divider */
153
+ hr {
154
+ border: none;
155
+ height: 1px;
156
+ background: rgba(48, 54, 61, 0.6);
157
+ margin: 1rem 0;
158
+ }
159
+
160
+ /* Scrollbar */
161
+ ::-webkit-scrollbar {
162
+ width: 6px;
163
+ height: 6px;
164
+ }
165
+
166
+ ::-webkit-scrollbar-track {
167
+ background: rgba(13, 17, 23, 0.4);
168
+ }
169
+
170
+ ::-webkit-scrollbar-thumb {
171
+ background: rgba(48, 54, 61, 0.8);
172
+ border-radius: 3px;
173
+ }
174
+
175
+ ::-webkit-scrollbar-thumb:hover {
176
+ background: rgba(88, 166, 255, 0.3);
177
+ }
178
+
179
+ /* Button style */
180
+ .stButton > button {
181
+ background: rgba(22, 27, 34, 0.8);
182
+ border: 1px solid rgba(48, 54, 61, 1);
183
+ color: #c9d1d9 !important;
184
+ font-weight: 500;
185
+ font-size: 0.75rem;
186
+ letter-spacing: 0.5px;
187
+ border-radius: 4px;
188
+ padding: 6px 12px;
189
+ transition: all 0.2s ease;
190
+ }
191
+
192
+ .stButton > button:hover {
193
+ background: rgba(33, 38, 45, 1);
194
+ border-color: rgba(88, 166, 255, 0.4);
195
+ }
196
+ </style>
197
+ """, unsafe_allow_html=True)
198
+
199
+ # --- FONCTION GÉOCODAGE ---
200
  @st.cache_data(show_spinner=False, ttl=3600)
201
  def geocode_addresses(df_clients):
202
+ """Géocodage via LocationIQ"""
203
 
204
  if 'Adresse' not in df_clients.columns or 'ID_Client' not in df_clients.columns:
205
  return pd.DataFrame()
206
 
 
207
  LOCATIONIQ_TOKEN = "pk.e1561a89e1ed2bc2ddeb3ee53fd88fb8"
 
208
  geocoded_data = []
209
 
210
  for index, row in df_clients.iterrows():
 
212
  client_id = row['ID_Client']
213
 
214
  try:
 
 
 
 
215
  url = "https://us1.locationiq.com/v1/search.php"
216
  params = {
217
  'key': LOCATIONIQ_TOKEN,
218
  'q': address,
219
  'format': 'json',
220
+ 'countrycodes': 'sn',
221
  'limit': 1,
222
  'accept-language': 'fr'
223
  }
 
231
  result = data[0]
232
  lat = float(result['lat'])
233
  lon = float(result['lon'])
 
234
 
235
  geocoded_data.append({
236
  "ID": client_id,
 
240
  "lon": lon,
241
  "Revenus": row.get('Revenus_Mensuels', 0)
242
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
+ time.sleep(1.2)
245
 
246
  except Exception as e:
247
+ pass
248
 
249
  return pd.DataFrame(geocoded_data)
 
 
250
 
251
+ # --- FONCTION PRINCIPALE ---
252
  def show_map_dashboard(client, sheet_name):
253
+ st.markdown("<h1>🌐 GLOBAL CONTROL TOWER</h1>", unsafe_allow_html=True)
254
 
255
+ # === CHARGEMENT DES DONNÉES ===
256
  try:
257
  sh = client.open(sheet_name)
258
+ ws_clients = sh.worksheet("Clients_KYC")
259
+ ws_prets = sh.worksheet("Prets_Master")
260
+
261
+ df_raw = pd.DataFrame(ws_clients.get_all_records())
262
+ df_prets = pd.DataFrame(ws_prets.get_all_records())
263
  except Exception as e:
264
+ st.error(f"Erreur de lecture : {e}")
265
  return
266
 
267
  if df_raw.empty:
268
+ st.info("Aucune donnée client.")
269
  return
270
 
271
+ # === 5 METRICS TEMPS RÉEL (EN HAUT) ===
272
+ col_m1, col_m2, col_m3, col_m4, col_m5 = st.columns(5)
273
+
274
+ # Calculs depuis Prets_Master
275
+ if not df_prets.empty:
276
+ total_prets = len(df_prets)
277
+ montant_total = df_prets['Montant_Capital'].sum() if 'Montant_Capital' in df_prets.columns else 0
278
+ prets_actifs = len(df_prets[df_prets['Statut'] == 'Actif']) if 'Statut' in df_prets.columns else 0
279
+ taux_moyen = df_prets['Taux_Hebdo'].mean() if 'Taux_Hebdo' in df_prets.columns else 0
280
+
281
+ # Prêts débloqués cette semaine (simulation)
282
+ prets_semaine = len(df_prets.tail(5)) if len(df_prets) >= 5 else len(df_prets)
283
+ else:
284
+ total_prets = montant_total = prets_actifs = taux_moyen = prets_semaine = 0
285
+
286
+ with col_m1:
287
+ st.metric("PRÊTS ACTIFS", f"{prets_actifs}", f"+{prets_semaine} (7j)")
288
+
289
+ with col_m2:
290
+ st.metric("CAPITAL DÉPLOYÉ", f"{montant_total/1e6:.1f}M XOF", "+12%")
291
+
292
+ with col_m3:
293
+ st.metric("TAUX MOYEN", f"{taux_moyen:.1f}%", "-0.2%")
294
+
295
+ with col_m4:
296
+ st.metric("CLIENTS ACTIFS", f"{len(df_raw)}", "+3")
297
+
298
+ with col_m5:
299
+ st.metric("RECOUVREMENT", "94.2%", "+1.8%")
300
+
301
+ st.markdown("---")
302
+
303
+ # === GÉOCODAGE ===
304
  with st.spinner("Triangulation des positions satellites..."):
305
  df_map = geocode_addresses(df_raw)
306
 
307
  if df_map.empty:
308
+ st.warning("Impossible de géolocaliser les adresses.")
309
  return
310
 
311
+ # === LAYOUT PRINCIPAL : 3 COLONNES ===
312
+ col_left, col_center, col_right = st.columns([1, 3, 1])
313
 
314
+ # === COLONNE GAUCHE : CLIENTS ===
315
+ with col_left:
316
+ st.markdown("### 📡 TARGETS")
317
+ st.caption(f"{len(df_map)} clients géolocalisés")
318
 
319
+ # Session state pour focus
320
+ if 'focus_lat' not in st.session_state:
321
+ st.session_state['focus_lat'] = df_map['lat'].mean()
322
+ st.session_state['focus_lon'] = df_map['lon'].mean()
323
+ st.session_state['focus_id'] = None
 
 
 
324
 
 
 
325
  for idx, row in df_map.iterrows():
326
+ client_key = f"client_{row['ID']}"
327
+
328
+ with st.expander(f"🔹 {row['ID']}", expanded=False):
329
+ st.caption(f"**{row['Nom']}**")
330
+ st.caption(f"📍 {row['Adresse'][:35]}...")
331
+ st.caption(f"💰 {row['Revenus']:,.0f} XOF")
332
+
333
+ if st.button("📌 Localiser", key=f"loc_{client_key}", use_container_width=True):
334
+ st.session_state['focus_lat'] = row['lat']
335
+ st.session_state['focus_lon'] = row['lon']
336
+ st.session_state['focus_id'] = row['ID']
337
+ st.rerun()
338
 
339
+ # === COLONNE CENTRALE : CARTE ===
340
+ with col_center:
341
+ st.markdown("### 🌍 TACTICAL MAP")
342
+
343
+ # Préparer données clients
344
+ clients_js = []
345
  for _, row in df_map.iterrows():
346
+ clients_js.append({
 
347
  'lat': row['lat'],
348
+ 'lng': row['lon'],
349
  'id': row['ID'],
350
+ 'nom': row['Nom'],
351
  'adresse': row['Adresse']
352
  })
353
 
354
+ # Focus
355
+ focus_lat = st.session_state.get('focus_lat', df_map['lat'].mean())
356
+ focus_lng = st.session_state.get('focus_lon', df_map['lon'].mean())
357
+
358
+ # HTML Mapbox
359
  mapbox_html = f"""
360
  <!DOCTYPE html>
361
  <html>
 
371
  <body>
372
  <div id="map"></div>
373
  <script>
374
+ mapboxgl.accessToken = 'pk.eyJ1Ijoia2x5ZGUwNyIsImEiOiJjbWpwd3B4cWMwYTVqM2ZxeHcwM3FoMHF5In0.mgiTZbug_4eD3cSNgF2t5Q';
375
 
376
  const map = new mapboxgl.Map({{
377
  container: 'map',
378
+ style: 'mapbox://styles/mapbox/dark-v11',
379
+ center: [{focus_lng}, {focus_lat}],
380
+ zoom: 12,
381
+ pitch: 45,
382
  bearing: 0,
383
  antialias: true
384
  }});
385
 
386
  map.on('load', () => {{
387
+ // Bâtiments 3D
388
  map.addLayer({{
389
  'id': '3d-buildings',
390
  'source': 'composite',
 
393
  'type': 'fill-extrusion',
394
  'minzoom': 14,
395
  'paint': {{
396
+ 'fill-extrusion-color': '#1c2128',
397
  'fill-extrusion-height': ['get', 'height'],
398
  'fill-extrusion-base': ['get', 'min_height'],
399
+ 'fill-extrusion-opacity': 0.6
400
  }}
401
  }});
402
 
403
+ const clients = {clients_js};
 
 
 
 
 
 
 
404
 
405
+ // Marqueurs clients (Losanges bleus)
406
+ const clientSvg = `
407
+ <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
408
+ <rect x="6" y="6" width="12" height="12"
409
+ fill="#58a6ff"
410
+ stroke="#c9d1d9"
411
+ stroke-width="1.5"
412
+ transform="rotate(45 12 12)"
413
+ filter="drop-shadow(0 0 6px rgba(88, 166, 255, 0.8))"/>
414
+ </svg>`;
415
 
416
+ clients.forEach((client, index) => {{
 
 
417
  const el = document.createElement('div');
418
+ el.innerHTML = clientSvg;
 
 
 
 
 
419
  el.style.cursor = 'pointer';
420
 
421
  new mapboxgl.Marker({{element: el}})
422
+ .setLngLat([client.lng, client.lat])
423
+ .setPopup(new mapboxgl.Popup({{ offset: 25 }}).setHTML(
424
+ `<div style="font-family: Space Grotesk; color: #c9d1d9; background: #161b22; padding: 8px; border-radius: 4px;">
425
+ <strong style="color: #58a6ff;">${{client.id}}</strong><br>
426
+ <span style="font-size: 0.85rem;">${{client.nom}}</span><br>
427
+ <span style="font-size: 0.75rem; color: #8b949e;">${{client.adresse.substring(0, 40)}}...</span>
428
+ </div>`
429
+ ))
430
  .addTo(map);
431
+ }});
432
+
433
+ // Lignes de connexion entre clients (réseau)
434
+ if (clients.length > 1) {{
435
+ for (let i = 0; i < clients.length - 1; i++) {{
436
  map.addLayer({{
437
+ 'id': 'line-' + i,
438
  'type': 'line',
439
  'source': {{
440
  'type': 'geojson',
 
443
  'geometry': {{
444
  'type': 'LineString',
445
  'coordinates': [
446
+ [clients[i].lng, clients[i].lat],
447
+ [clients[i+1].lng, clients[i+1].lat]
448
  ]
449
  }}
450
  }}
451
  }},
452
  'paint': {{
453
+ 'line-color': '#3fb950',
454
+ 'line-width': 1.5,
455
+ 'line-opacity': 0.4
456
  }}
457
  }});
458
  }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  }}
460
  }});
461
 
462
+ map.addControl(new mapboxgl.NavigationControl(), 'top-right');
 
463
  </script>
464
  </body>
465
  </html>
466
  """
467
 
468
+ st.components.v1.html(mapbox_html, height=500)
469
+
470
+ # === COLONNE DROITE : ALERTES ===
471
+ with col_right:
472
+ st.markdown("### 🔔 LIVE FEED")
473
+
474
+ now = datetime.datetime.now()
475
+
476
+ alerts = [
477
+ {
478
+ 'type': 'critical',
479
+ 'title': 'SEUIL ATTEINT',
480
+ 'message': 'NPL > 15% détecté',
481
+ 'time': (now - datetime.timedelta(minutes=2)).strftime('%H:%M')
482
+ },
483
+ {
484
+ 'type': 'warning',
485
+ 'title': 'ANOMALIE DÉTECTÉE',
486
+ 'message': 'CA mensuel -12%',
487
+ 'time': (now - datetime.timedelta(minutes=15)).strftime('%H:%M')
488
+ },
489
+ {
490
+ 'type': 'success',
491
+ 'title': 'VALIDATION',
492
+ 'message': f'{df_map.iloc[0]["ID"]} approuvé',
493
+ 'time': (now - datetime.timedelta(minutes=23)).strftime('%H:%M')
494
+ },
495
+ {
496
+ 'type': 'info',
497
+ 'title': 'RECOMMANDATION',
498
+ 'message': 'Augmenter taux +0.5%',
499
+ 'time': (now - datetime.timedelta(hours=1)).strftime('%H:%M')
500
+ },
501
+ {
502
+ 'type': 'warning',
503
+ 'title': 'RETARD PAIEMENT',
504
+ 'message': '3 clients en J+7',
505
+ 'time': (now - datetime.timedelta(hours=2)).strftime('%H:%M')
506
+ },
507
+ ]
508
+
509
+ for alert in alerts:
510
+ st.markdown(f"""
511
+ <div class="alert-box alert-{alert['type']}">
512
+ <div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
513
+ <strong style="color: #c9d1d9; font-size: 0.75rem;">{alert['title']}</strong>
514
+ <span style="color: #8b949e; font-size: 0.7rem;">{alert['time']}</span>
515
+ </div>
516
+ <div style="color: #8b949e; font-size: 0.75rem;">{alert['message']}</div>
517
+ </div>
518
+ """, unsafe_allow_html=True)
519
+
520
+ # === STATS ANSD (BAS) ===
521
+ st.markdown("---")
522
+ st.markdown("### 📈 INDICATEURS MACROÉCONOMIQUES ANSD")
523
+
524
+ col1, col2, col3 = st.columns(3)
525
+
526
+ with col1:
527
+ st.metric("Croissance PIB", "5.2%", "+0.3%")
528
+ st.metric("Taux BCEAO", "3.0%", "-0.5%")
529
+ st.metric("NPL Bancaires", "14.8%", "+1.2%")
530
+
531
+ with col2:
532
+ st.metric("Emploi Informel", "68%", "-2%")
533
+ st.metric("Dette Publique", "72% PIB", "+3%")
534
+ st.metric("Transferts Diaspora", "2.1B USD", "+5%")
535
+
536
+ with col3:
537
+ st.metric("Inflation Alimentaire", "8.3%", "+1.5%")
538
+ st.metric("Taux €/USD", "1.08", "-0.02")
539
+ st.metric("Production Agricole", "92 idx", "-8 idx")