oedb-backend/oedb/resources/live.py
2025-09-26 15:08:33 +02:00

406 lines
22 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>
<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>
<!-- Authentication section -->
<!--
# <div id="auth-section" class="auth-section">
# <h3>OpenStreetMap Authentication</h3>
#
<a href="https://www.openstreetmap.org/oauth2/authorize?client_id={client_id}&redirect_uri={client_redirect}&response_type=code&scope=read_prefs" class="osm-login-btn">
<span class="osm-logo"></span>
Login with OpenStreetMap
</a>
<script>
# // Replace server-side auth section with JavaScript-rendered version if available
document.addEventListener('DOMContentLoaded', function() {
fetchEvents();
if (window.osmAuth) {
const clientId = document.getElementById('osmClientId').value;
const redirectUri = document.getElementById('osmRedirectUri').value;
const authSection = document.getElementById('auth-section');
// Only replace if osmAuth is loaded and has renderAuthSection method
if (osmAuth.renderAuthSection) {
authSection.innerHTML = osmAuth.renderAuthSection(clientId, redirectUri);
}
}
});
</script>
</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?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()