432 lines
		
	
	
	
		
			23 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			432 lines
		
	
	
	
		
			23 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """
 | ||
| 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>
 | ||
| 
 | ||
| <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>
 | ||
| 
 | ||
|                 <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>
 | ||
| 
 | ||
|                         
 | ||
|                         <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>
 | ||
|                 const API_URL = 'https://api.openeventdatabase.org/event?when=last30days&limit=1000';
 | ||
|                 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()
 | ||
| 
 | ||
| 
 | 
