add live page
This commit is contained in:
parent
114bcca24e
commit
eb8c42d0c0
19 changed files with 2759 additions and 199 deletions
406
oedb/resources/live.py
Normal file
406
oedb/resources/live.py
Normal file
|
@ -0,0 +1,406 @@
|
|||
"""
|
||||
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=last7days&limit=2000';
|
||||
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()
|
||||
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue