oedb-backend/oedb/resources/live.py
2025-09-27 01:10:47 +02:00

432 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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()