406 lines
22 KiB
Python
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?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()
|
|
|
|
|