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