| 
									
										
										
										
											2025-09-26 11:57:54 +02:00
										 |  |  |  | """
 | 
					
						
							|  |  |  |  | Live page: shows last 7 days events from public OEDB API, refreshes every minute, | 
					
						
							|  |  |  |  | displays 10-minute bucket histogram and a table of events. | 
					
						
							|  |  |  |  | """
 | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  | import falcon | 
					
						
							|  |  |  |  | from oedb.utils.logging import logger | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  | class LiveResource: | 
					
						
							|  |  |  |  |     def on_get(self, req, resp): | 
					
						
							|  |  |  |  |         logger.info("Processing GET request to /live") | 
					
						
							|  |  |  |  |         resp.content_type = 'text/html' | 
					
						
							|  |  |  |  |         html = """
 | 
					
						
							|  |  |  |  |         <!DOCTYPE html> | 
					
						
							|  |  |  |  |         <html lang="fr"> | 
					
						
							|  |  |  |  |         <head> | 
					
						
							|  |  |  |  |             <meta charset="UTF-8"> | 
					
						
							|  |  |  |  |             <meta name="viewport" content="width=device-width, initial-scale=1.0"> | 
					
						
							|  |  |  |  |             <title>OEDB Live - derniers événements</title> | 
					
						
							|  |  |  |  |             <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> | 
					
						
							|  |  |  |  |             <script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script> | 
					
						
							|  |  |  |  |             <link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" /> | 
					
						
							|  |  |  |  |             <style> | 
					
						
							|  |  |  |  |                 body { margin: 0; padding: 16px; font-family: Arial, sans-serif; background: #f6f7f9; } | 
					
						
							|  |  |  |  |                 .container { max-width: 1200px; margin: 0 auto; } | 
					
						
							|  |  |  |  |                 h1 { margin: 0 0 12px; } | 
					
						
							|  |  |  |  |                 .controls { display: flex; align-items: center; gap: 8px; margin: 8px 0 16px; } | 
					
						
							|  |  |  |  |                 .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px; margin-bottom: 16px; } | 
					
						
							|  |  |  |  |                 #chart { width: 100%; height: 320px; } | 
					
						
							|  |  |  |  |                 table { width: 100%; border-collapse: collapse; } | 
					
						
							|  |  |  |  |                 th, td { padding: 6px 8px; border-bottom: 1px solid #eee; text-align: left; font-size: 13px; } | 
					
						
							|  |  |  |  |                 th { background: #fafafa; } | 
					
						
							|  |  |  |  |                 .muted { color: #6b7280; } | 
					
						
							|  |  |  |  |             </style> | 
					
						
							|  |  |  |  |         </head> | 
					
						
							|  |  |  |  |         <body> | 
					
						
							|  |  |  |  |             <div class="container"> | 
					
						
							|  |  |  |  |                 <h1> | 
					
						
							|  |  |  |  |                     <a href="/demo">OEDB</a> Live | 
					
						
							|  |  |  |  |                 </h1> | 
					
						
							| 
									
										
										
										
											2025-09-27 01:10:47 +02:00
										 |  |  |  | 
 | 
					
						
							|  |  |  |  | <nav class="demo-nav" style=" | 
					
						
							|  |  |  |  |     background-color: #f8f9fa; | 
					
						
							|  |  |  |  |     padding: 12px 16px; | 
					
						
							|  |  |  |  |     border-radius: 8px; | 
					
						
							|  |  |  |  |     margin-bottom: 20px; | 
					
						
							|  |  |  |  |     border-left: 4px solid #0078ff; | 
					
						
							|  |  |  |  | "> | 
					
						
							|  |  |  |  |     <div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;"> | 
					
						
							|  |  |  |  |         <!-- Logo et titre --> | 
					
						
							|  |  |  |  |         <div style="display: flex; align-items: center; gap: 8px;"> | 
					
						
							|  |  |  |  |             <img src="/static/oedb.png" style="width: 24px; height: 24px;" alt="OEDB" /> | 
					
						
							|  |  |  |  |             <strong style="color: #0078ff;">OpenEventDatabase</strong> | 
					
						
							|  |  |  |  |         </div> | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |         <!-- Liens de navigation --> | 
					
						
							|  |  |  |  |         <div style="display: flex; gap: 15px; flex-wrap: wrap; font-size: 14px;"> | 
					
						
							|  |  |  |  |             <a href="/demo" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;" | 
					
						
							|  |  |  |  |                onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'"> | 
					
						
							|  |  |  |  |                 🗺️ Carte principale | 
					
						
							|  |  |  |  |             </a> | 
					
						
							|  |  |  |  |             <a href="/demo/add" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;" | 
					
						
							|  |  |  |  |                onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'"> | 
					
						
							|  |  |  |  |                 ➕ Ajouter un événement | 
					
						
							|  |  |  |  |             </a> | 
					
						
							|  |  |  |  |             <a href="/demo/traffic" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;" | 
					
						
							|  |  |  |  |                onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'"> | 
					
						
							|  |  |  |  |                 🚗 Signaler un problème | 
					
						
							|  |  |  |  |             </a> | 
					
						
							|  |  |  |  |             <a href="/demo/live" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;" | 
					
						
							|  |  |  |  |                onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'"> | 
					
						
							|  |  |  |  |                 🔴 Live | 
					
						
							|  |  |  |  |             </a> | 
					
						
							|  |  |  |  |             <a href="/demo/view-events" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;" | 
					
						
							|  |  |  |  |                onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'"> | 
					
						
							|  |  |  |  |                 📋 Voir les événements | 
					
						
							|  |  |  |  |             </a> | 
					
						
							|  |  |  |  |             <a href="/demo/property-stats" style="color: #0078ff; text-decoration: none; padding: 4px 8px; border-radius: 4px;" | 
					
						
							|  |  |  |  |                onmouseover="this.style.backgroundColor='#e3f2fd'" onmouseout="this.style.backgroundColor='transparent'"> | 
					
						
							|  |  |  |  |                 📊 Statistiques | 
					
						
							|  |  |  |  |             </a> | 
					
						
							|  |  |  |  |         </div> | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |         <!-- Lien vers l'API --> | 
					
						
							|  |  |  |  |         <div style="margin-left: auto;"> | 
					
						
							|  |  |  |  |             <a href="/" style="color: #6c757d; text-decoration: none; font-size: 13px;" | 
					
						
							|  |  |  |  |                onmouseover="this.style.color='#0078ff'" onmouseout="this.style.color='#6c757d'"> | 
					
						
							|  |  |  |  |                 📡 API | 
					
						
							|  |  |  |  |             </a> | 
					
						
							|  |  |  |  |         </div> | 
					
						
							|  |  |  |  |     </div> | 
					
						
							|  |  |  |  | </nav> | 
					
						
							|  |  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-26 11:57:54 +02:00
										 |  |  |  |                 <div class="controls"> | 
					
						
							|  |  |  |  |                     <span>Période: 7 jours (rafraîchit chaque minute)</span> | 
					
						
							|  |  |  |  |                     <button id="refreshBtn">Rafraîchir</button> | 
					
						
							|  |  |  |  |                     <span id="lastUpdate" class="muted"></span> | 
					
						
							|  |  |  |  |                 </div> | 
					
						
							|  |  |  |  |                 <div class="card" style="display:flex; gap:12px; align-items:flex-start;"> | 
					
						
							|  |  |  |  |                     <div style="flex:1 1 auto; min-width: 0;"> | 
					
						
							|  |  |  |  |                         <canvas id="chart"></canvas> | 
					
						
							|  |  |  |  |                     </div> | 
					
						
							|  |  |  |  |                     <div style="flex:0 0 240px; max-height: 360px; overflow:auto; border-left:1px solid #eee; padding-left:12px;"> | 
					
						
							|  |  |  |  |                         <h3 style="margin:0 0 8px">Filtrer par type</h3> | 
					
						
							|  |  |  |  |                         <div style="margin-bottom:8px"> | 
					
						
							|  |  |  |  |                             <button id="selectAllBtn">Tout cocher</button> | 
					
						
							|  |  |  |  |                             <button id="clearAllBtn">Tout décocher</button> | 
					
						
							|  |  |  |  |                         </div> | 
					
						
							|  |  |  |  |                         <div style="margin-bottom:8px"> | 
					
						
							|  |  |  |  |                             <label style="display:flex; align-items:center; gap:6px;"> | 
					
						
							|  |  |  |  |                                 <input type="checkbox" id="onlyRealityCheck"> | 
					
						
							|  |  |  |  |                                 <span>Seulement avec reality_check</span> | 
					
						
							|  |  |  |  |                             </label> | 
					
						
							|  |  |  |  |                         </div> | 
					
						
							|  |  |  |  |                         <div id="filters"></div> | 
					
						
							|  |  |  |  |                     </div> | 
					
						
							|  |  |  |  |                 </div> | 
					
						
							|  |  |  |  |                 <div> | 
					
						
							|  |  |  |  |                 <div id="info_panel_content" class=""> | 
					
						
							|  |  |  |  |                         <!-- User Information Panel --> | 
					
						
							|  |  |  |  |                         <div id="user-info-panel" class="user-info-panel" style="display: none; background-color: #f5f5f5; border-radius: 4px; padding: 10px; margin: 10px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"> | 
					
						
							|  |  |  |  |                             <h3 style="margin-top: 0; margin-bottom: 10px; color: #333;">User Information</h3> | 
					
						
							|  |  |  |  |                             <p>Username: <strong id="username-display">Anonymous</strong></p> | 
					
						
							|  |  |  |  |                             <p>Points: <span id="points-display" style="font-weight: bold; color: #0078ff;">0</span></p> | 
					
						
							|  |  |  |  |                         </div> | 
					
						
							| 
									
										
										
										
											2025-09-27 01:10:47 +02:00
										 |  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-26 11:57:54 +02:00
										 |  |  |  |                          | 
					
						
							|  |  |  |  |                         <h3 id="endpoints_list_header">API Endpoints:</h3> | 
					
						
							|  |  |  |  |                         <ul id="endpoints_list"> | 
					
						
							|  |  |  |  |                             <li><a href="/" >/ - API Information</a></li> | 
					
						
							|  |  |  |  |                             <li><a href="/event" >/event - Get Events</a></li> | 
					
						
							|  |  |  |  |                             <li><a href="/stats" >/stats - Database Statistics</a></li> | 
					
						
							|  |  |  |  |                         </ul> | 
					
						
							|  |  |  |  |                         <h3 id="demo_pages_list_header">Demo Pages:</h3> | 
					
						
							|  |  |  |  |                         <ul id="demo_pages_list"> | 
					
						
							|  |  |  |  |                             <li><a href="/demo/search" >/demo/search - Advanced Search</a></li> | 
					
						
							|  |  |  |  |                             <li><a href="/demo/by-what" >/demo/by-what - Events by Type</a></li> | 
					
						
							|  |  |  |  |                             <li><a href="/demo/map-by-what" >/demo/map-by-what - Map by Event Type</a></li> | 
					
						
							|  |  |  |  |                             <li><a href="/demo/traffic" >/demo/traffic - Report Traffic Jam</a></li> | 
					
						
							|  |  |  |  |                             <li><a href="/demo/view-events" >/demo/view-events - View Saved Events</a></li> | 
					
						
							|  |  |  |  |                             <li><a href="/event?what=music" >Search Music Events</a></li> | 
					
						
							|  |  |  |  |                             <li><a href="/event?what=sport" >Search Sport Events</a></li> | 
					
						
							|  |  |  |  |                         </ul> | 
					
						
							|  |  |  |  |                         <p class="sources" style="text-align: center; margin-top: 10px;"> | 
					
						
							|  |  |  |  |                             <a href="https://source.cipherbliss.com/tykayn/oedb-backend" title="View Source Code on Cipherbliss" style="font-size: 24px;"> | 
					
						
							|  |  |  |  |                                 <i class="fas fa-code-branch"></i> sources | 
					
						
							|  |  |  |  |                             </a> | 
					
						
							|  |  |  |  |                         </p> | 
					
						
							|  |  |  |  |                     </div> | 
					
						
							|  |  |  |  |                 </div> | 
					
						
							|  |  |  |  |                 <div class="card"> | 
					
						
							|  |  |  |  |                     <h2 style="margin:0 0 8px">Arbre des familles d'évènements</h2> | 
					
						
							|  |  |  |  |                     <div id="familiesGraph" style="width:100%; height:360px; border:1px solid #eee; border-radius:4px;"></div> | 
					
						
							|  |  |  |  |                 </div> | 
					
						
							|  |  |  |  |                 <div class="card"> | 
					
						
							|  |  |  |  |                     <h2 style="margin:0 0 8px">Derniers évènements</h2> | 
					
						
							|  |  |  |  |                     <div style="overflow:auto; max-height: 50vh;"> | 
					
						
							|  |  |  |  |                         <table id="eventsTable"> | 
					
						
							|  |  |  |  |                             <thead> | 
					
						
							|  |  |  |  |                                 <tr> | 
					
						
							|  |  |  |  |                                     <th></th> | 
					
						
							|  |  |  |  |                                     <th>ID</th> | 
					
						
							|  |  |  |  |                                     <th>What</th> | 
					
						
							|  |  |  |  |                                     <th>Label</th> | 
					
						
							|  |  |  |  |                                     <th>Start</th> | 
					
						
							|  |  |  |  |                                     <th>Stop</th> | 
					
						
							|  |  |  |  |                                     <th>Lon</th> | 
					
						
							|  |  |  |  |                                     <th>Lat</th> | 
					
						
							|  |  |  |  |                                 </tr> | 
					
						
							|  |  |  |  |                             </thead> | 
					
						
							|  |  |  |  |                             <tbody></tbody> | 
					
						
							|  |  |  |  |                         </table> | 
					
						
							|  |  |  |  |                     </div> | 
					
						
							|  |  |  |  |                 </div> | 
					
						
							|  |  |  |  |             </div> | 
					
						
							|  |  |  |  |             <script> | 
					
						
							| 
									
										
										
										
											2025-09-26 17:38:30 +02:00
										 |  |  |  |                 const API_URL = 'https://api.openeventdatabase.org/event?when=last30days&limit=1000'; | 
					
						
							| 
									
										
										
										
											2025-09-26 11:57:54 +02:00
										 |  |  |  |                 let chart; | 
					
						
							|  |  |  |  |                 let allFeatures = []; | 
					
						
							|  |  |  |  |                 let familySet = new Set(); | 
					
						
							|  |  |  |  |                 const familyToColor = new Map(); | 
					
						
							|  |  |  |  |                 const pastel = ['#cce5ff','#c2f0f0','#d5f5e3','#fde2cf','#f6d5ff','#ffd6e7','#e3f2fd','#e8f5e9','#fff3e0','#f3e5f5','#f1f8e9','#e0f7fa','#fff8e1','#ede7f6','#fce4ec']; | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function bucket10min(date) { | 
					
						
							|  |  |  |  |                     const d = new Date(date); | 
					
						
							|  |  |  |  |                     if (isNaN(d.getTime())) return null; | 
					
						
							|  |  |  |  |                     // round down to 10 minute | 
					
						
							|  |  |  |  |                     d.setSeconds(0, 0); | 
					
						
							|  |  |  |  |                     const m = d.getMinutes(); | 
					
						
							|  |  |  |  |                     d.setMinutes(m - (m % 10)); | 
					
						
							|  |  |  |  |                     return d.toISOString(); | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function getFamily(p) { | 
					
						
							|  |  |  |  |                     const w = (p && p.what) ? String(p.what) : ''; | 
					
						
							|  |  |  |  |                     return w.split('.')[0] || 'other'; | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function ensureColors() { | 
					
						
							|  |  |  |  |                     let i = 0; | 
					
						
							|  |  |  |  |                     for (const fam of familySet) { | 
					
						
							|  |  |  |  |                         if (!familyToColor.has(fam)) { | 
					
						
							|  |  |  |  |                             familyToColor.set(fam, pastel[i % pastel.length]); | 
					
						
							|  |  |  |  |                             i++; | 
					
						
							|  |  |  |  |                         } | 
					
						
							|  |  |  |  |                     } | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function buildStackedHistogram(features, enabledFamilies, onlyReality) { | 
					
						
							|  |  |  |  |                     const allBuckets = new Set(); | 
					
						
							|  |  |  |  |                     const famBuckets = new Map(); | 
					
						
							|  |  |  |  |                     for (const f of features) { | 
					
						
							|  |  |  |  |                         const fam = getFamily(f.properties); | 
					
						
							|  |  |  |  |                         if (enabledFamilies && !enabledFamilies.has(fam)) continue; | 
					
						
							|  |  |  |  |                         if (onlyReality && !(f.properties && f.properties['reality_check'])) continue; | 
					
						
							|  |  |  |  |                         const t = f.properties && (f.properties.createdate || f.properties.start || f.properties.lastupdate); | 
					
						
							|  |  |  |  |                         const bucket = bucket10min(t); | 
					
						
							|  |  |  |  |                         if (!bucket) continue; | 
					
						
							|  |  |  |  |                         allBuckets.add(bucket); | 
					
						
							|  |  |  |  |                         if (!famBuckets.has(fam)) famBuckets.set(fam, new Map()); | 
					
						
							|  |  |  |  |                         const m = famBuckets.get(fam); | 
					
						
							|  |  |  |  |                         m.set(bucket, (m.get(bucket) || 0) + 1); | 
					
						
							|  |  |  |  |                     } | 
					
						
							|  |  |  |  |                     const labels = Array.from(allBuckets).sort(); | 
					
						
							|  |  |  |  |                     const datasets = []; | 
					
						
							|  |  |  |  |                     for (const [fam, mapCounts] of famBuckets.entries()) { | 
					
						
							|  |  |  |  |                         const data = labels.map(k => mapCounts.get(k) || 0); | 
					
						
							|  |  |  |  |                         datasets.push({ label: fam, data, backgroundColor: familyToColor.get(fam) || '#ddd', stack: 'events' }); | 
					
						
							|  |  |  |  |                     } | 
					
						
							|  |  |  |  |                     return { labels, datasets }; | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function renderChart(labels, datasets) { | 
					
						
							|  |  |  |  |                     const ctx = document.getElementById('chart'); | 
					
						
							|  |  |  |  |                     if (chart) chart.destroy(); | 
					
						
							|  |  |  |  |                     chart = new Chart(ctx, { | 
					
						
							|  |  |  |  |                         type: 'bar', | 
					
						
							|  |  |  |  |                         data: { labels, datasets }, | 
					
						
							|  |  |  |  |                         options: { | 
					
						
							|  |  |  |  |                             responsive: true, | 
					
						
							|  |  |  |  |                             maintainAspectRatio: false, | 
					
						
							|  |  |  |  |                             scales: { | 
					
						
							|  |  |  |  |                                 x: { stacked: true, ticks: { callback: (v, i) => new Date(labels[i]).toLocaleString() } }, | 
					
						
							|  |  |  |  |                                 y: { stacked: true, beginAtZero: true } | 
					
						
							|  |  |  |  |                             } | 
					
						
							|  |  |  |  |                         } | 
					
						
							|  |  |  |  |                     }); | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function renderTable(features, enabledFamilies) { | 
					
						
							|  |  |  |  |                     const tbody = document.querySelector('#eventsTable tbody'); | 
					
						
							|  |  |  |  |                     tbody.innerHTML = ''; | 
					
						
							|  |  |  |  |                     for (const f of features) { | 
					
						
							|  |  |  |  |                         const p = f.properties || {}; | 
					
						
							|  |  |  |  |                         const fam = getFamily(p); | 
					
						
							|  |  |  |  |                         if (enabledFamilies && !enabledFamilies.has(fam)) continue; | 
					
						
							|  |  |  |  |                         const onlyReality = document.getElementById('onlyRealityCheck')?.checked; | 
					
						
							|  |  |  |  |                         if (onlyReality && !p['reality_check']) continue; | 
					
						
							|  |  |  |  |                         const tr = document.createElement('tr'); | 
					
						
							|  |  |  |  |                         tr.style.background = (familyToColor.get(fam) || '#fff'); | 
					
						
							|  |  |  |  |                         let iconClass = 'info-circle'; | 
					
						
							|  |  |  |  |                         let iconColor = '#0078ff'; | 
					
						
							|  |  |  |  |                         const eventType = String(p.what || ''); | 
					
						
							|  |  |  |  |                         const labelLower = String(p.label || '').toLowerCase(); | 
					
						
							|  |  |  |  |                         if (labelLower.includes('travaux') || eventType.includes('roadwork')) { iconClass = 'hard-hat'; iconColor = '#ff9800'; } | 
					
						
							|  |  |  |  |                         else if (eventType.startsWith('weather')) { iconClass = 'cloud'; iconColor = '#00d1b2'; } | 
					
						
							|  |  |  |  |                         else if (eventType.startsWith('traffic')) { iconClass = 'car'; iconColor = '#ff3860'; } | 
					
						
							|  |  |  |  |                         else if (eventType.startsWith('sport')) { iconClass = 'futbol'; iconColor = '#3273dc'; } | 
					
						
							|  |  |  |  |                         else if (eventType.startsWith('culture')) { iconClass = 'theater-masks'; iconColor = '#ffdd57'; } | 
					
						
							|  |  |  |  |                         else if (eventType.startsWith('health')) { iconClass = 'heartbeat'; iconColor = '#ff3860'; } | 
					
						
							|  |  |  |  |                         else if (eventType.startsWith('education')) { iconClass = 'graduation-cap'; iconColor = '#3273dc'; } | 
					
						
							|  |  |  |  |                         else if (eventType.startsWith('politics')) { iconClass = 'landmark'; iconColor = '#209cee'; } | 
					
						
							|  |  |  |  |                         else if (eventType.startsWith('nature')) { iconClass = 'leaf'; iconColor = '#23d160'; } | 
					
						
							|  |  |  |  |                         const idHtml = p.id ? `<a href="/demo/by_id/${p.id}">${p.id}</a>` : ''; | 
					
						
							|  |  |  |  |                         tr.innerHTML = ` | 
					
						
							|  |  |  |  |                             <td style="width:28px;text-align:center"><i class="fas fa-${iconClass}" style="color:${iconColor}"></i></td> | 
					
						
							|  |  |  |  |                             <td>${idHtml}</td> | 
					
						
							|  |  |  |  |                             <td>${p.what || ''}</td> | 
					
						
							|  |  |  |  |                             <td>${(p.label || '').toString().slice(0,120)}</td> | 
					
						
							|  |  |  |  |                             <td>${p.start || ''}</td> | 
					
						
							|  |  |  |  |                             <td>${p.stop || ''}</td> | 
					
						
							|  |  |  |  |                             <td>${f.geometry && f.geometry.coordinates ? f.geometry.coordinates[0] : ''}</td> | 
					
						
							|  |  |  |  |                             <td>${f.geometry && f.geometry.coordinates ? f.geometry.coordinates[1] : ''}</td> | 
					
						
							|  |  |  |  |                         `; | 
					
						
							|  |  |  |  |                         tbody.appendChild(tr); | 
					
						
							|  |  |  |  |                     } | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 async function loadData() { | 
					
						
							|  |  |  |  |                     try { | 
					
						
							|  |  |  |  |                         const res = await fetch(API_URL); | 
					
						
							|  |  |  |  |                         const data = await res.json(); | 
					
						
							|  |  |  |  |                         allFeatures = (data && data.features) ? data.features : []; | 
					
						
							|  |  |  |  |                         familySet = new Set(allFeatures.map(f => getFamily(f.properties))); | 
					
						
							|  |  |  |  |                         ensureColors(); | 
					
						
							|  |  |  |  |                         buildFilters(); | 
					
						
							|  |  |  |  |                         applyFiltersAndRender(); | 
					
						
							|  |  |  |  |                         try { renderFamiliesGraph(allFeatures); } catch(e) { console.warn('Graph error', e); } | 
					
						
							|  |  |  |  |                         document.getElementById('lastUpdate').textContent = 'Mise à jour: ' + new Date().toLocaleString(); | 
					
						
							|  |  |  |  |                     } catch (e) { | 
					
						
							|  |  |  |  |                         console.error(e); | 
					
						
							|  |  |  |  |                     } | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function buildFilters() { | 
					
						
							|  |  |  |  |                     const cont = document.getElementById('filters'); | 
					
						
							|  |  |  |  |                     cont.innerHTML = ''; | 
					
						
							|  |  |  |  |                     const sorted = Array.from(familySet).sort(); | 
					
						
							|  |  |  |  |                     for (const fam of sorted) { | 
					
						
							|  |  |  |  |                         const id = 'fam_' + fam.replace(/[^a-z0-9]/gi, '_'); | 
					
						
							|  |  |  |  |                         const wrap = document.createElement('div'); | 
					
						
							|  |  |  |  |                         wrap.style.marginBottom = '6px'; | 
					
						
							|  |  |  |  |                         wrap.innerHTML = ` | 
					
						
							|  |  |  |  |                             <label style="display:flex; align-items:center; gap:6px;"> | 
					
						
							|  |  |  |  |                                 <input type="checkbox" id="${id}" checked> | 
					
						
							|  |  |  |  |                                 <span style="display:inline-block; width:12px; height:12px; background:${familyToColor.get(fam)}; border:1px solid #ddd"></span> | 
					
						
							|  |  |  |  |                                 <span>${fam}</span> | 
					
						
							|  |  |  |  |                             </label> | 
					
						
							|  |  |  |  |                         `; | 
					
						
							|  |  |  |  |         				cont.appendChild(wrap); | 
					
						
							|  |  |  |  |                         document.getElementById(id).addEventListener('change', applyFiltersAndRender); | 
					
						
							|  |  |  |  |                     } | 
					
						
							|  |  |  |  |                     document.getElementById('selectAllBtn').onclick = () => { cont.querySelectorAll('input[type=checkbox]').forEach(c => { c.checked = true; }); applyFiltersAndRender(); }; | 
					
						
							|  |  |  |  |                     document.getElementById('clearAllBtn').onclick = () => { cont.querySelectorAll('input[type=checkbox]').forEach(c => { c.checked = false; }); applyFiltersAndRender(); }; | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function getEnabledFamilies() { | 
					
						
							|  |  |  |  |                     const cont = document.getElementById('filters'); | 
					
						
							|  |  |  |  |                     const enabled = new Set(); | 
					
						
							|  |  |  |  |                     cont.querySelectorAll('input[type=checkbox]').forEach(c => { | 
					
						
							|  |  |  |  |                         const lbl = c.closest('label'); | 
					
						
							|  |  |  |  |                         const name = lbl && lbl.querySelector('span:last-child') ? lbl.querySelector('span:last-child').textContent : ''; | 
					
						
							|  |  |  |  |                         if (c.checked && name) enabled.add(name); | 
					
						
							|  |  |  |  |                     }); | 
					
						
							|  |  |  |  |                     return enabled; | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function applyFiltersAndRender() { | 
					
						
							|  |  |  |  |                     const enabled = getEnabledFamilies(); | 
					
						
							|  |  |  |  |                     const onlyReality = document.getElementById('onlyRealityCheck')?.checked; | 
					
						
							|  |  |  |  |                     const res = buildStackedHistogram(allFeatures, enabled, onlyReality); | 
					
						
							|  |  |  |  |                     renderChart(res.labels, res.datasets); | 
					
						
							|  |  |  |  |                     renderTable(allFeatures, enabled); | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 function buildFamilyGraph(features) { | 
					
						
							|  |  |  |  |                     const seen = new Set(); | 
					
						
							|  |  |  |  |                     const nodes = []; | 
					
						
							|  |  |  |  |                     const links = []; | 
					
						
							|  |  |  |  |                     function addNode(name) { | 
					
						
							|  |  |  |  |                         if (!seen.has(name)) { | 
					
						
							|  |  |  |  |                             seen.add(name); | 
					
						
							|  |  |  |  |                             const top = name.split('.')[0]; | 
					
						
							|  |  |  |  |                             nodes.push({ id: name, group: top }); | 
					
						
							|  |  |  |  |                         } | 
					
						
							|  |  |  |  |                     } | 
					
						
							|  |  |  |  |                     const whats = new Set(); | 
					
						
							|  |  |  |  |                     features.forEach(f => { const w = f.properties && f.properties.what; if (w) whats.add(String(w)); }); | 
					
						
							|  |  |  |  |                     whats.forEach(w => { | 
					
						
							|  |  |  |  |                         const parts = w.split('.'); | 
					
						
							|  |  |  |  |                         let cur = ''; | 
					
						
							|  |  |  |  |                         for (let i = 0; i < parts.length; i++) { | 
					
						
							|  |  |  |  |                             cur = i === 0 ? parts[0] : cur + '.' + parts[i]; | 
					
						
							|  |  |  |  |                             addNode(cur); | 
					
						
							|  |  |  |  |                             if (i > 0) { | 
					
						
							|  |  |  |  |                                 const parent = cur.slice(0, cur.lastIndexOf('.')); | 
					
						
							|  |  |  |  |                                 addNode(parent); | 
					
						
							|  |  |  |  |                                 links.push({ source: parent, target: cur }); | 
					
						
							|  |  |  |  |                             } | 
					
						
							|  |  |  |  |                         } | 
					
						
							|  |  |  |  |                     }); | 
					
						
							|  |  |  |  |                     return { nodes, links }; | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 let familiesSim = null; | 
					
						
							|  |  |  |  |                 function renderFamiliesGraph(features) { | 
					
						
							|  |  |  |  |                     const { nodes, links } = buildFamilyGraph(features); | 
					
						
							|  |  |  |  |                     const container = document.getElementById('familiesGraph'); | 
					
						
							|  |  |  |  |                     const width = container.clientWidth || 800; | 
					
						
							|  |  |  |  |                     const height = container.clientHeight || 360; | 
					
						
							|  |  |  |  |                     container.innerHTML = ''; | 
					
						
							|  |  |  |  |                     const svg = d3.select(container).append('svg').attr('width', width).attr('height', height); | 
					
						
							|  |  |  |  |                     const color = d => familyToColor.get(d.group) || '#bbb'; | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                     const link = svg.append('g').attr('stroke', '#aaa').attr('stroke-opacity', 0.7) | 
					
						
							|  |  |  |  |                         .selectAll('line').data(links).join('line').attr('stroke-width', 1.5); | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                     const node = svg.append('g').attr('stroke', '#fff').attr('stroke-width', 1.5) | 
					
						
							|  |  |  |  |                         .selectAll('circle').data(nodes).join('circle') | 
					
						
							|  |  |  |  |                         .attr('r', d => d.id.indexOf('.') === -1 ? 8 : 5) | 
					
						
							|  |  |  |  |                         .attr('fill', color) | 
					
						
							|  |  |  |  |                         .call(d3.drag() | 
					
						
							|  |  |  |  |                             .on('start', (event, d) => { if (!event.active) familiesSim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) | 
					
						
							|  |  |  |  |                             .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; }) | 
					
						
							|  |  |  |  |                             .on('end', (event, d) => { if (!event.active) familiesSim.alphaTarget(0); d.fx = null; d.fy = null; })); | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                     const labels = svg.append('g').selectAll('text').data(nodes).join('text') | 
					
						
							|  |  |  |  |                         .text(d => d.id) | 
					
						
							|  |  |  |  |                         .attr('font-size', '10px') | 
					
						
							|  |  |  |  |                         .attr('fill', '#333'); | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                     familiesSim = d3.forceSimulation(nodes) | 
					
						
							|  |  |  |  |                         .force('link', d3.forceLink(links).id(d => d.id).distance(d => d.target.id.indexOf('.') === -1 ? 40 : 25).strength(0.8)) | 
					
						
							|  |  |  |  |                         .force('charge', d3.forceManyBody().strength(-120)) | 
					
						
							|  |  |  |  |                         .force('center', d3.forceCenter(width / 2, height / 2)) | 
					
						
							|  |  |  |  |                         .on('tick', () => { | 
					
						
							|  |  |  |  |                             link.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y); | 
					
						
							|  |  |  |  |                             node.attr('cx', d => d.x).attr('cy', d => d.y); | 
					
						
							|  |  |  |  |                             labels.attr('x', d => d.x + 10).attr('y', d => d.y + 3); | 
					
						
							|  |  |  |  |                         }); | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                     const zoom = d3.zoom().scaleExtent([0.5, 5]).on('zoom', (event) => { | 
					
						
							|  |  |  |  |                         svg.selectAll('g').attr('transform', event.transform); | 
					
						
							|  |  |  |  |                     }); | 
					
						
							|  |  |  |  |                     svg.call(zoom); | 
					
						
							|  |  |  |  |                 } | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  |                 document.getElementById('refreshBtn').addEventListener('click', loadData); | 
					
						
							|  |  |  |  |                 loadData(); | 
					
						
							|  |  |  |  |                 setInterval(loadData, 60 * 1000); | 
					
						
							|  |  |  |  |             </script> | 
					
						
							|  |  |  |  |         </body> | 
					
						
							|  |  |  |  |         </html> | 
					
						
							|  |  |  |  |         """
 | 
					
						
							|  |  |  |  |         resp.text = html | 
					
						
							|  |  |  |  |         resp.status = falcon.HTTP_200 | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  | live = LiveResource() | 
					
						
							|  |  |  |  | 
 | 
					
						
							|  |  |  |  | 
 |