add live page

This commit is contained in:
Tykayn 2025-09-26 11:57:54 +02:00 committed by tykayn
parent 114bcca24e
commit eb8c42d0c0
19 changed files with 2759 additions and 199 deletions

View file

@ -59,6 +59,8 @@ class DemoResource:
<title>Edit Event - OpenEventDatabase</title>
<script src="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.css" type="text/css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<style>
body {{
@ -231,7 +233,10 @@ class DemoResource:
<div class="note">Click on the map to set the event location</div>
</div>
<button type="submit">Update Event</button>
<div style="display: flex; gap: 10px;">
<button type="submit">Update Event</button>
<button type="button" id="deleteButton" style="background-color: #dc3545;">Delete Event</button>
</div>
</form>
<div id="result"></div>
@ -406,6 +411,50 @@ class DemoResource:
showResult(`Error: ${{error.message}}`, 'error');
}});
}});
// Handle delete button click
document.getElementById('deleteButton').addEventListener('click', function() {{
// Get event ID
const eventId = document.getElementById('eventId').value;
// Show confirmation dialog
if (confirm('Are you sure you want to delete this event? This action cannot be undone.')) {{
// Submit delete request to API
fetch(`/event/${{eventId}}`, {{
method: 'DELETE',
headers: {{
'Content-Type': 'application/json'
}}
}})
.then(response => {{
if (response.ok) {{
showResult('Event deleted successfully', 'success');
// Add link to go back to map
const resultElement = document.getElementById('result');
resultElement.innerHTML += `<p><a href="/demo">Back to Map</a></p>`;
// Disable form controls
const formElements = document.querySelectorAll('#eventForm input, #eventForm select, #eventForm button');
formElements.forEach(element => {{
element.disabled = true;
}});
// Redirect to demo page after 2 seconds
setTimeout(() => {{
window.location.href = '/demo';
}}, 2000);
}} else {{
return response.text().then(text => {{
throw new Error(text || response.statusText);
}});
}}
}})
.catch(error => {{
showResult(`Error deleting event: ${{error.message}}`, 'error');
}});
}}
}});
</script>
</body>
</html>
@ -1940,5 +1989,76 @@ class DemoResource:
"""
return demo_view_events.on_get(req, resp)
def on_get_by_id(self, req, resp, id):
"""
Handle GET requests to /demo/by_id/{id}.
Show a map with the event location and a table of its properties.
"""
import requests
logger.info(f"Processing GET request to /demo/by_id/{id}")
try:
resp.content_type = 'text/html'
r = requests.get(f"https://api.openeventdatabase.org/event/{id}")
r.raise_for_status()
feature = r.json()
html = f"""
<!DOCTYPE html>
<html lang=\"fr\">
<head>
<meta charset=\"UTF-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
<title>Event {id} - OpenEventDatabase</title>
<script src=\"https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.js\"></script>
<link href=\"https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css\" rel=\"stylesheet\" />
<style>
body {{ margin:0; font-family: Arial, sans-serif; }}
.container {{ max-width: 1100px; margin: 0 auto; padding: 12px; }}
#map {{ width:100%; height: 360px; border:1px solid #ddd; border-radius:4px; }}
table {{ width:100%; border-collapse: collapse; margin-top:12px; }}
th, td {{ padding: 6px 8px; border-bottom: 1px solid #eee; text-align:left; }}
th {{ background:#f9fafb; }}
.nav a {{ margin-right: 10px; }}
</style>
</head>
<body>
<div class=\"container\">
<div class=\"nav\">
<a href=\"/demo\">← Retour à la démo</a>
<a href=\"/demo/traffic\">Signaler trafic</a>
<a href=\"/demo/view-events\">Voir événements</a>
</div>
<h1>Évènement {id}</h1>
<div id=\"map\"></div>
<table>
<thead><tr><th>Clé</th><th>Valeur</th></tr></thead>
<tbody>
{''.join([f'<tr><td>{k}</td><td>{(v if not isinstance(v, dict) else str(v))}</td></tr>' for k,v in sorted((feature.get('properties') or {{}}).items())])}
</tbody>
</table>
</div>
<script>
const f = {feature};
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.openfreemap.org/styles/liberty',
center: f.geometry && f.geometry.coordinates ? f.geometry.coordinates : [2.3522,48.8566],
zoom: 12
});
map.addControl(new maplibregl.NavigationControl());
if (f.geometry && f.geometry.type === 'Point') {
new maplibregl.Marker().setLngLat(f.geometry.coordinates).addTo(map);
}
</script>
</body>
</html>
"""
resp.text = html
resp.status = falcon.HTTP_200
logger.success(f"Successfully processed GET request to /demo/by_id/{id}")
except Exception as e:
logger.error(f"Error processing GET request to /demo/by_id/{id}: {e}")
resp.status = falcon.HTTP_500
resp.text = f"Error: {str(e)}"
# Create a global instance of DemoResource
demo = DemoResource()

View file

@ -50,6 +50,8 @@ class DemoMainResource:
<link rel="stylesheet" href="/static/demo_styles.css">
<script defer src="https://use.fontawesome.com/releases/v5.15.4/js/all.js"></script>
<script src="/static/demo_auth.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="/static/social.js"></script>
<style>
body { margin: 0; padding: 0; font-family: Arial, sans-serif; }
.logo{
@ -243,72 +245,43 @@ class DemoMainResource:
<h2>
<img src="/static/oedb.png" class="logo" />
OpenEventDatabase Demo</h2>
<p>This map shows current events from the OpenEventDatabase.</p>
<!-- Event addition buttons - always visible -->
<p><a href="/demo/traffic" class="add-event-btn" style="display: block; text-align: center; margin-top: 15px; padding: 8px; background-color: #0078ff; color: white; border-radius: 4px; font-weight: bold;">+ Traffic event</a></p>
<p><a href="/demo/add" class="add-event-btn" style="display: block; text-align: center; margin-top: 15px; padding: 8px; background-color: #0078ff; color: white; border-radius: 4px; font-weight: bold;">+ Any Event</a></p>
<p><a href="/demo/live" class="live-event-btn" style="display: block; text-align: center; margin-top: 15px; padding: 8px; background-color: #0078ff; color: white; border-radius: 4px; font-weight: bold;"> Live</a></p>
<!-- Collapsible information section -->
<h3 id="info_panel_header" class="collapsible-header">Information Panel <span class="toggle-icon"></span></h3>
<div id="info_panel_content" class="collapsible-content">
<!-- 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>
<br/>
<br/>
<!-- Filtres pour les événements -->
<div class="event-filters" id="filters_panel" style="margin-top: 15px; padding: 10px; background-color: #f5f5f5; border-radius: 4px; display:none;">
<h3 id="filters_header" style="margin-top: 0; color: #0078ff; cursor:pointer;">Filtres</h3>
<div style="margin-top: 10px;">
<label style="display: block; margin-bottom: 5px;">Type d'événement:</label>
<select id="event-type-filter" style="width: 100%; padding: 5px; border-radius: 4px; border: 1px solid #ddd;">
<option value="">Tous</option>
<option value="traffic">Traffic</option>
<option value="weather">Météo</option>
<option value="gathering">Rassemblement</option>
<option value="incident">Incident</option>
</select>
</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 style="margin-top:12px; display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="autoRefreshToggle" checked>
<label for="autoRefreshToggle" style="margin:0;">Rafraîchissement automatique (30s)</label>
</div>
</div>
<div class="event-filters" style="margin-top: 10px; padding: 10px; background-color: #fff; border: 1px solid #e5e7eb; border-radius: 4px;">
<h3 style="margin-top: 0; color: #0078ff;">Histogramme des évènements</h3>
<canvas id="eventsHistogram" style="width:100%; height:220px;"></canvas>
</div>
</div>
<script>
@ -320,6 +293,8 @@ class DemoMainResource:
const demoPagesList = document.getElementById('demo_pages_list');
const infoPanelHeader = document.getElementById('info_panel_header');
const infoPanelContent = document.getElementById('info_panel_content');
const filtersPanel = document.getElementById('filters_panel');
const filtersHeader = document.getElementById('filters_header');
// Fonction pour basculer l'affichage d'une liste ou section
function toggleList(header, list) {
@ -343,8 +318,98 @@ class DemoMainResource:
toggleList(endpointsHeader, endpointsList);
toggleList(demoPagesHeader, demoPagesList);
toggleList(infoPanelHeader, infoPanelContent);
// Toggle pour le panneau de filtres via le titre "Filtres"
if (filtersHeader && filtersPanel) {
filtersHeader.addEventListener('click', function() {
if (filtersPanel.style.display === 'none' || filtersPanel.style.display === '') {
filtersPanel.style.display = 'block';
} else {
filtersPanel.style.display = 'none';
}
});
}
});
// Variable globale pour stocker les marqueurs d'événements
window.eventMarkers = [];
function addEventsToMap(geojsonData) {
if (!geojsonData || !geojsonData.features) return;
geojsonData.features.forEach(feature => {
// Créer un élément HTML pour le marqueur
const el = document.createElement('div');
el.className = 'event-marker';
el.style.width = '20px';
el.style.height = '20px';
el.style.borderRadius = '50%';
// Déterminer la couleur selon le type d'événement
let color = '#0078ff';
const eventType = feature.properties.what;
if (eventType) {
if (eventType.includes('traffic')) color = '#F44336';
else if (eventType.includes('weather')) color = '#4CAF50';
else if (eventType.includes('gathering')) color = '#FF9800';
else if (eventType.includes('incident')) color = '#9C27B0';
}
el.style.backgroundColor = color;
el.style.border = '2px solid white';
el.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
el.style.cursor = 'pointer';
// Créer le contenu de la popup
const popupContent = createEventPopupContent(feature);
// Créer la popup
const popup = new maplibregl.Popup({
closeButton: true,
closeOnClick: true
}).setHTML(popupContent);
// Créer et ajouter le marqueur
const marker = new maplibregl.Marker(el)
.setLngLat(feature.geometry.coordinates)
.setPopup(popup)
.addTo(map);
// Ajouter à la liste des marqueurs
window.eventMarkers.push(marker);
});
}
function createEventPopupContent(feature) {
const properties = feature.properties;
// Extraire les informations principales
const title = properties.title || 'Événement sans titre';
const what = properties.what || 'Non spécifié';
const when = properties.when ? formatDate(properties.when) : 'Date inconnue';
const description = properties.description || 'Aucune description disponible';
// Créer le HTML de la popup
return `
<div class="event-popup">
<h3 style="margin-top: 0; color: #0078ff;">${title}</h3>
<p><strong>Type:</strong> ${what}</p>
<p><strong>Date:</strong> ${when}</p>
<p><strong>Description:</strong> ${description}</p>
<p><a href="/demo/view/${properties.id}" style="color: #0078ff; font-weight: bold;">Voir détails</a></p>
</div>
`;
}
function formatDate(dateString) {
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch (e) {
return dateString;
}
}
// Map style URLs
const mapStyles = {
default: 'https://tiles.openfreemap.org/styles/liberty',
@ -401,10 +466,50 @@ class DemoMainResource:
// Style switcher functionality
let currentStyle = 'default';
let eventsData = null;
let histogramChart = null;
let refreshIntervalId = null;
// Array to store markers so they can be removed on refresh
// Store markers with their family/type for filtering
let currentMarkers = [];
function getFamily(what) {
if (!what) return 'unknown';
const s = String(what);
const dot = s.indexOf('.');
return dot === -1 ? s : s.slice(0, dot);
}
function applyTypeFilter() {
const sel = document.getElementById('event-type-filter');
const val = sel ? sel.value : '';
currentMarkers.forEach(rec => {
const el = rec.marker.getElement();
if (!val) {
el.style.display = '';
} else {
el.style.display = (rec.family === val) ? '' : 'none';
}
});
// Also filter vector circle layer if present
try {
if (!val) {
map.setFilter('events-circle', null);
} else {
const len = val.length;
// Show features where what starts with selected family
const filter = [
"any",
["!", ["has", "what"]],
["==", ["slice", ["get", "what"], 0, len], val]
];
map.setFilter('events-circle', filter);
}
} catch (e) {
// Layer may not be ready yet; ignore
}
}
// Fetch events when the map is loaded and every 30 seconds thereafter
@ -413,10 +518,18 @@ class DemoMainResource:
fetchEvents();
// Set up interval to fetch events every 30 seconds
setInterval(fetchEvents, 30000);
setupAutoRefresh();
console.log('Event refresh interval set: events will update every 30 seconds');
});
function setupAutoRefresh() {
const cb = document.getElementById('autoRefreshToggle');
const start = () => { if (!refreshIntervalId) { refreshIntervalId = setInterval(fetchEvents, 30000); } };
const stop = () => { if (refreshIntervalId) { clearInterval(refreshIntervalId); refreshIntervalId = null; } };
if (cb && cb.checked) start(); else stop();
if (cb) cb.addEventListener('change', () => { if (cb.checked) start(); else stop(); });
}
// Function to fetch events from the API
function fetchEvents() {
@ -427,6 +540,8 @@ class DemoMainResource:
if (data.features && data.features.length > 0) {
// Add events to the map
addEventsToMap(data);
// Render histogram for retrieved events
try { renderEventsHistogram(data.features); } catch(e) { console.warn('Histogram error', e); }
// Fit map to events bounds
fitMapToBounds(data);
@ -440,12 +555,49 @@ class DemoMainResource:
showErrorToast(`Erreur de chargement des événements: ${error.message}`);
});
}
function bucket10(dateStr) {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return null;
d.setSeconds(0,0);
const m = d.getMinutes();
d.setMinutes(m - (m % 10));
return d.toISOString();
}
function renderEventsHistogram(features) {
const counts = new Map();
features.forEach(f => {
const p = f.properties || {};
const t = p.createdate || p.start || p.lastupdate;
const b = bucket10(t);
if (!b) return;
counts.set(b, (counts.get(b) || 0) + 1);
});
const labels = Array.from(counts.keys()).sort();
const data = labels.map(k => counts.get(k));
const ctx = document.getElementById('eventsHistogram');
if (!ctx) return;
if (histogramChart) histogramChart.destroy();
histogramChart = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label:'Évènements / 10 min', data, backgroundColor:'#3b82f6' }] },
options: {
// maintainAspectRatio: false,
scales: {
x: { ticks: { callback: (v,i) => new Date(labels[i]).toLocaleString() } },
y: { beginAtZero: true }
}
}
});
}
// Function to add events to the map
function addEventsToMap(geojson) {
// Remove all existing markers
if (currentMarkers.length > 0) {
currentMarkers.forEach(marker => marker.remove());
currentMarkers.forEach(rec => rec.marker.remove());
currentMarkers = [];
console.log('Removed existing markers');
}
@ -569,7 +721,12 @@ class DemoMainResource:
let iconColor = '#0078ff'; // Default color
// Map event types to icons
if (eventType.startsWith('weather')) {
// Travaux detection (label or what)
const labelLower = String(properties.label || '').toLowerCase();
if (labelLower.includes('travaux') || eventType.includes('roadwork')) {
iconClass = 'hard-hat';
iconColor = '#ff9800';
} else if (eventType.startsWith('weather')) {
iconClass = 'cloud';
iconColor = '#00d1b2'; // Teal
} else if (eventType.startsWith('traffic')) {
@ -607,10 +764,13 @@ class DemoMainResource:
.setLngLat(coordinates)
.setPopup(popup)
.addTo(map);
// Store marker reference for later removal
currentMarkers.push(marker);
// Store marker with its family for filtering
currentMarkers.push({ marker, family: getFamily(eventType) });
});
// Re-apply current filter on fresh markers
applyTypeFilter();
}
// Function to calculate relative time (e.g., "2 hours 30 minutes ago")
@ -862,6 +1022,12 @@ class DemoMainResource:
// Initialisation des gestionnaires d'événements pour le toast d'erreur
initErrorToast();
// Hook filters
const typeSel = document.getElementById('event-type-filter');
const applyBtn = document.getElementById('apply-filters');
if (typeSel) typeSel.addEventListener('change', applyTypeFilter);
if (applyBtn) applyBtn.addEventListener('click', applyTypeFilter);
});
// Fonction pour initialiser le toast d'erreur
@ -892,7 +1058,21 @@ class DemoMainResource:
}, 6000);
}
// Initialiser automatiquement le mode social quand la carte est chargée
map.on('load', function() {
// Vérifier si l'objet social existe
if (window.oedbSocial) {
console.log('Initialisation automatique du mode social...');
setTimeout(() => {
// Trouver le bouton d'activation du mode social et simuler un clic
const socialButton = document.querySelector('.toggle-social-btn');
if (socialButton) {
socialButton.click();
console.log('Mode social activé automatiquement');
}
}, 2000); // Attendre 2 secondes pour que tout soit bien chargé
}
});
</script>
</body>
</html>

View file

@ -108,6 +108,9 @@ button:hover {
.nav-links {
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.nav-links a {
@ -120,6 +123,99 @@ button:hover {
text-decoration: underline;
}
/* Navigation container */
.nav-container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
}
/* Hamburger menu for mobile */
.menu-toggle {
display: none;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #0078ff;
padding: 5px;
}
/* Responsive styles */
@media (max-width: 768px) {
.nav-container {
flex-direction: column;
align-items: flex-start;
}
.menu-toggle {
display: block;
align-self: flex-end;
margin-bottom: 10px;
}
.nav-links {
display: none;
flex-direction: column;
width: 100%;
}
.nav-links.active {
display: flex;
}
.nav-links a {
padding: 10px 0;
border-bottom: 1px solid #eee;
margin-right: 0;
}
}
/* Collapsible panel styles */
.collapsible-panel {
margin-bottom: 15px;
}
.collapsible-header {
background-color: #f8f9fa;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid #e9ecef;
}
.collapsible-header h3 {
margin: 0;
font-size: 16px;
}
.collapsible-header .toggle-icon {
transition: transform 0.3s ease;
}
.collapsible-header.active .toggle-icon {
transform: rotate(180deg);
}
.collapsible-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
border-left: 1px solid #e9ecef;
border-right: 1px solid #e9ecef;
border-bottom: 1px solid #e9ecef;
border-radius: 0 0 5px 5px;
}
.collapsible-content.active {
max-height: 1000px;
}
/* Authentication section styles */
.auth-section {
background-color: #f8f9fa;
@ -523,4 +619,10 @@ select:invalid {
.add-event-btn{
float: left;
width: 130px;
}
button{
padding: 1rem 0.5rem;
border-radius: 5px;
background-color: #79a2d1;
}

View file

@ -0,0 +1 @@
# Ce fichier est un MP3 binaire qui contiendra le son 'pouet pouet'.

View file

@ -0,0 +1,750 @@
// Fonctionnalités sociales pour OEDB
class OEDBSocial {
constructor() {
this.socket = null;
this.position = null;
this.username = '';
this.friends = [];
this.markers = {};
this.map = null;
this.lastPouetTime = 0;
this.showOnlyFriends = false;
// Charger les amis depuis le localStorage
this.loadFriends();
// Boutons pour l'interface sociale
this.createSocialUI();
}
// Initialiser la connexion WebSocket
init(map, username) {
this.map = map;
this.username = username || localStorage.getItem('oedb_social_username') || '';
if (!this.username) {
this.promptForUsername();
} else {
localStorage.setItem('oedb_social_username', this.username);
}
// Créer la connexion WebSocket
// Utiliser l'URL relative au serveur actuel
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const wsUrl = `${wsProtocol}${window.location.host}/ws`;
this.socket = new WebSocket(wsUrl);
this.socket.onopen = () => {
console.log('Connexion WebSocket établie');
this.startSendingPosition();
};
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleSocketMessage(data);
};
this.socket.onclose = () => {
console.log('Connexion WebSocket fermée');
// Tentative de reconnexion après 5 secondes
setTimeout(() => this.init(this.map, this.username), 5000);
};
this.socket.onerror = (error) => {
console.error('Erreur WebSocket:', error);
this.showToast(`Erreur de connexion au serveur WebSocket. Vérifiez que le serveur est en cours d'exécution sur le port 8765.`, 'error');
};
}
// Demander le pseudo à l'utilisateur
promptForUsername() {
// Créer une boîte de dialogue modale pour demander le pseudo
const modalOverlay = document.createElement('div');
modalOverlay.className = 'modal-overlay';
modalOverlay.style.position = 'fixed';
modalOverlay.style.top = '0';
modalOverlay.style.left = '0';
modalOverlay.style.width = '100%';
modalOverlay.style.height = '100%';
modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
modalOverlay.style.zIndex = '1000';
modalOverlay.style.display = 'flex';
modalOverlay.style.justifyContent = 'center';
modalOverlay.style.alignItems = 'center';
const modalContent = document.createElement('div');
modalContent.className = 'modal-content';
modalContent.style.backgroundColor = '#fff';
modalContent.style.padding = '20px';
modalContent.style.borderRadius = '5px';
modalContent.style.maxWidth = '400px';
modalContent.style.width = '80%';
const title = document.createElement('h3');
title.textContent = 'Choisissez un pseudo';
title.style.marginBottom = '15px';
const form = document.createElement('form');
form.onsubmit = (e) => {
e.preventDefault();
const input = document.getElementById('username-input');
const username = input.value.trim();
if (username) {
this.username = username;
localStorage.setItem('oedb_social_username', username);
document.body.removeChild(modalOverlay);
this.startSendingPosition();
}
};
const input = document.createElement('input');
input.type = 'text';
input.id = 'username-input';
input.placeholder = 'Votre pseudo';
input.style.width = '100%';
input.style.padding = '8px';
input.style.marginBottom = '15px';
input.style.borderRadius = '4px';
input.style.border = '1px solid #ddd';
const button = document.createElement('button');
button.type = 'submit';
button.textContent = 'Valider';
button.style.padding = '8px 15px';
button.style.backgroundColor = '#0078ff';
button.style.color = 'white';
button.style.border = 'none';
button.style.borderRadius = '4px';
button.style.cursor = 'pointer';
form.appendChild(input);
form.appendChild(button);
modalContent.appendChild(title);
modalContent.appendChild(form);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
input.focus();
}
// Commencer à envoyer sa position
startSendingPosition() {
if (!this.username || !this.socket) return;
// Obtenir la position actuelle
this.getCurrentPosition();
// Mettre à jour la position toutes les 5 secondes
setInterval(() => {
this.getCurrentPosition();
}, 5000);
}
// Obtenir la position GPS actuelle
getCurrentPosition() {
if (navigator.geolocation && this.socket && this.socket.readyState === WebSocket.OPEN) {
navigator.geolocation.getCurrentPosition(
(position) => {
this.position = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
// Envoyer la position au serveur WebSocket
this.socket.send(JSON.stringify({
type: 'position',
username: this.username,
position: this.position,
timestamp: new Date().toISOString(),
showOnlyToFriends: this.showOnlyFriends
}));
},
(error) => {
console.error('Erreur lors de la récupération de la position:', error);
}
);
}
}
// Traiter les messages reçus par WebSocket
handleSocketMessage(data) {
switch (data.type) {
case 'position':
this.updateUserPosition(data);
break;
case 'pouet':
this.receivePouet(data);
break;
case 'friendRequest':
this.receiveFriendRequest(data);
break;
case 'users':
this.updateAllUsers(data.users);
break;
}
}
// Mettre à jour la position d'un utilisateur sur la carte
updateUserPosition(data) {
// Ignorer les mises à jour de notre propre position
if (data.username === this.username) return;
// Vérifier si l'utilisateur est visible uniquement pour ses amis
if (data.showOnlyToFriends && !this.friends.includes(data.username)) return;
// Supprimer l'ancien marqueur s'il existe
if (this.markers[data.username]) {
this.markers[data.username].remove();
}
// Créer un élément HTML personnalisé pour le marqueur
const el = document.createElement('div');
el.className = 'user-marker';
// Styles de base pour le marqueur
el.style.width = '40px';
el.style.height = '40px';
el.style.borderRadius = '50%';
el.style.backgroundColor = this.friends.includes(data.username) ? '#4CAF50' : '#0078ff';
el.style.border = '2px solid white';
el.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
el.style.display = 'flex';
el.style.justifyContent = 'center';
el.style.alignItems = 'center';
el.style.color = 'white';
el.style.fontWeight = 'bold';
el.style.fontSize = '12px';
el.style.cursor = 'pointer';
// Ajouter les initiales de l'utilisateur
const initials = data.username.substring(0, 2).toUpperCase();
el.textContent = initials;
// Ajouter un tooltip avec le nom complet
el.title = data.username;
// Créer le popup
const popupContent = `
<div class="user-popup" style="padding: 10px; max-width: 200px;">
<h3 style="margin-top: 0;">${data.username}</h3>
<p style="margin-bottom: 10px;">Position mise à jour: ${this.formatTimestamp(data.timestamp)}</p>
<div style="display: flex; justify-content: space-between;">
<button class="pouet-btn" style="padding: 5px 10px; background-color: #FFC107; border: none; border-radius: 3px; cursor: pointer;">Pouet Pouet!</button>
${!this.friends.includes(data.username) ? `
<button class="add-friend-btn" style="padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer;">Ajouter ami</button>
` : ''}
</div>
</div>
`;
const popup = new maplibregl.Popup({
closeButton: true,
closeOnClick: true
}).setHTML(popupContent);
// Ajouter des gestionnaires d'événements au popup
popup.on('open', () => {
// Gérer le clic sur le bouton Pouet Pouet
setTimeout(() => {
const pouetBtn = document.querySelector('.pouet-btn');
if (pouetBtn) {
pouetBtn.addEventListener('click', () => {
this.sendPouet(data.username);
});
}
// Gérer le clic sur le bouton Ajouter ami
const addFriendBtn = document.querySelector('.add-friend-btn');
if (addFriendBtn) {
addFriendBtn.addEventListener('click', () => {
this.sendFriendRequest(data.username);
});
}
}, 100);
});
// Créer le marqueur et l'ajouter à la carte
const marker = new maplibregl.Marker(el)
.setLngLat([data.position.lng, data.position.lat])
.setPopup(popup)
.addTo(this.map);
// Stocker le marqueur pour pouvoir le supprimer plus tard
this.markers[data.username] = marker;
}
// Mettre à jour tous les utilisateurs actifs
updateAllUsers(users) {
// Supprimer les marqueurs des utilisateurs qui ne sont plus actifs
Object.keys(this.markers).forEach(username => {
if (!users.find(user => user.username === username)) {
this.markers[username].remove();
delete this.markers[username];
}
});
}
// Envoyer un pouet à un utilisateur
sendPouet(username) {
const now = Date.now();
// Vérifier si on peut envoyer un pouet (limité à 1 toutes les 10 secondes)
if (now - this.lastPouetTime < 10000) {
const remainingTime = Math.ceil((10000 - (now - this.lastPouetTime)) / 1000);
this.showToast(`Merci d'attendre encore ${remainingTime} secondes avant d'envoyer un autre pouet!`, 'warning');
return;
}
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
type: 'pouet',
from: this.username,
to: username,
timestamp: new Date().toISOString()
}));
this.lastPouetTime = now;
this.showToast(`Pouet pouet envoyé à ${username}!`, 'success');
}
}
// Recevoir un pouet
receivePouet(data) {
this.showToast(`${data.from} vous a envoyé un pouet pouet!`, 'info');
// Jouer un son
const audio = new Audio('/static/pouet.mp3');
audio.play().catch(e => console.log('Erreur lors de la lecture du son:', e));
}
// Envoyer une demande d'ami
sendFriendRequest(username) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({
type: 'friendRequest',
from: this.username,
to: username,
timestamp: new Date().toISOString()
}));
this.showToast(`Demande d'ami envoyée à ${username}!`, 'success');
}
}
// Recevoir une demande d'ami
receiveFriendRequest(data) {
// Créer une boîte de dialogue modale pour la demande d'ami
const modalOverlay = document.createElement('div');
modalOverlay.className = 'modal-overlay';
modalOverlay.style.position = 'fixed';
modalOverlay.style.top = '0';
modalOverlay.style.left = '0';
modalOverlay.style.width = '100%';
modalOverlay.style.height = '100%';
modalOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
modalOverlay.style.zIndex = '1000';
modalOverlay.style.display = 'flex';
modalOverlay.style.justifyContent = 'center';
modalOverlay.style.alignItems = 'center';
const modalContent = document.createElement('div');
modalContent.className = 'modal-content';
modalContent.style.backgroundColor = '#fff';
modalContent.style.padding = '20px';
modalContent.style.borderRadius = '5px';
modalContent.style.maxWidth = '400px';
modalContent.style.width = '80%';
const title = document.createElement('h3');
title.textContent = 'Demande d\'ami';
title.style.marginBottom = '15px';
const message = document.createElement('p');
message.textContent = `${data.from} souhaite vous ajouter à sa liste d'amis.`;
message.style.marginBottom = '20px';
const buttonsContainer = document.createElement('div');
buttonsContainer.style.display = 'flex';
buttonsContainer.style.justifyContent = 'space-between';
const acceptButton = document.createElement('button');
acceptButton.textContent = 'Accepter';
acceptButton.style.padding = '8px 15px';
acceptButton.style.backgroundColor = '#4CAF50';
acceptButton.style.color = 'white';
acceptButton.style.border = 'none';
acceptButton.style.borderRadius = '4px';
acceptButton.style.cursor = 'pointer';
acceptButton.onclick = () => {
this.addFriend(data.from);
document.body.removeChild(modalOverlay);
};
const rejectButton = document.createElement('button');
rejectButton.textContent = 'Refuser';
rejectButton.style.padding = '8px 15px';
rejectButton.style.backgroundColor = '#f44336';
rejectButton.style.color = 'white';
rejectButton.style.border = 'none';
rejectButton.style.borderRadius = '4px';
rejectButton.style.cursor = 'pointer';
rejectButton.onclick = () => {
document.body.removeChild(modalOverlay);
};
buttonsContainer.appendChild(acceptButton);
buttonsContainer.appendChild(rejectButton);
modalContent.appendChild(title);
modalContent.appendChild(message);
modalContent.appendChild(buttonsContainer);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
}
// Ajouter un ami à la liste d'amis
addFriend(username) {
if (!this.friends.includes(username)) {
this.friends.push(username);
this.saveFriends();
this.showToast(`${username} a été ajouté à votre liste d'amis!`, 'success');
// Mettre à jour le marqueur de cet ami s'il est visible
if (this.markers[username]) {
const position = this.markers[username].getLngLat();
this.markers[username].remove();
delete this.markers[username];
// Simuler une mise à jour de position pour recréer le marqueur
this.updateUserPosition({
username: username,
position: {
lng: position.lng,
lat: position.lat
},
timestamp: new Date().toISOString()
});
}
}
}
// Sauvegarder la liste d'amis dans le localStorage
saveFriends() {
localStorage.setItem('oedb_social_friends', JSON.stringify(this.friends));
}
// Charger la liste d'amis depuis le localStorage
loadFriends() {
try {
const friendsJson = localStorage.getItem('oedb_social_friends');
if (friendsJson) {
this.friends = JSON.parse(friendsJson);
}
} catch (e) {
console.error('Erreur lors du chargement des amis:', e);
this.friends = [];
}
}
// Créer l'interface utilisateur pour les fonctionnalités sociales
createSocialUI() {
// Conteneur principal pour les contrôles sociaux
const socialContainer = document.createElement('div');
socialContainer.className = 'social-controls';
socialContainer.style.position = 'absolute';
socialContainer.style.top = '10px';
socialContainer.style.right = '10px';
socialContainer.style.backgroundColor = 'rgba(255, 255, 255, 0.9)';
socialContainer.style.padding = '10px';
socialContainer.style.borderRadius = '5px';
socialContainer.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.1)';
socialContainer.style.zIndex = '10';
socialContainer.style.display = 'flex';
socialContainer.style.flexDirection = 'column';
socialContainer.style.gap = '10px';
// Titre
const title = document.createElement('h3');
title.textContent = 'Mode Social';
title.style.margin = '0 0 10px 0';
title.style.textAlign = 'center';
// Bouton pour activer/désactiver le mode social
const toggleButton = document.createElement('button');
toggleButton.className = 'toggle-social-btn';
toggleButton.textContent = 'Activer le mode social';
toggleButton.style.padding = '8px';
toggleButton.style.backgroundColor = '#0078ff';
toggleButton.style.color = 'white';
toggleButton.style.border = 'none';
toggleButton.style.borderRadius = '4px';
toggleButton.style.cursor = 'pointer';
toggleButton.style.fontWeight = 'bold';
let socialActive = false;
toggleButton.addEventListener('click', () => {
socialActive = !socialActive;
if (socialActive) {
toggleButton.textContent = 'Désactiver le mode social';
toggleButton.style.backgroundColor = '#f44336';
this.init(this.map);
} else {
toggleButton.textContent = 'Activer le mode social';
toggleButton.style.backgroundColor = '#0078ff';
// Fermer la connexion WebSocket
if (this.socket) {
this.socket.close();
this.socket = null;
}
// Supprimer tous les marqueurs
Object.values(this.markers).forEach(marker => marker.remove());
this.markers = {};
}
// Afficher/masquer les options supplémentaires
optionsContainer.style.display = socialActive ? 'block' : 'none';
});
// Conteneur pour les options supplémentaires
const optionsContainer = document.createElement('div');
optionsContainer.className = 'social-options';
optionsContainer.style.display = 'none';
// Bouton pour changer de pseudo
const changeUsernameBtn = document.createElement('button');
changeUsernameBtn.textContent = 'Changer de pseudo';
changeUsernameBtn.style.width = '100%';
changeUsernameBtn.style.padding = '8px';
changeUsernameBtn.style.backgroundColor = '#FFC107';
changeUsernameBtn.style.border = 'none';
changeUsernameBtn.style.borderRadius = '4px';
changeUsernameBtn.style.marginBottom = '10px';
changeUsernameBtn.style.cursor = 'pointer';
changeUsernameBtn.addEventListener('click', () => {
this.promptForUsername();
});
// Case à cocher pour la visibilité uniquement aux amis
const visibilityContainer = document.createElement('div');
visibilityContainer.style.display = 'flex';
visibilityContainer.style.alignItems = 'center';
visibilityContainer.style.marginBottom = '10px';
const visibilityCheckbox = document.createElement('input');
visibilityCheckbox.type = 'checkbox';
visibilityCheckbox.id = 'visibility-checkbox';
visibilityCheckbox.checked = this.showOnlyFriends;
const visibilityLabel = document.createElement('label');
visibilityLabel.htmlFor = 'visibility-checkbox';
visibilityLabel.textContent = 'Visible uniquement par mes amis';
visibilityLabel.style.marginLeft = '5px';
visibilityContainer.appendChild(visibilityCheckbox);
visibilityContainer.appendChild(visibilityLabel);
visibilityCheckbox.addEventListener('change', () => {
this.showOnlyFriends = visibilityCheckbox.checked;
this.getCurrentPosition(); // Mettre à jour immédiatement avec le nouveau paramètre
});
// Gestionnaire d'amis
const friendsManager = document.createElement('div');
friendsManager.style.marginTop = '10px';
const friendsTitle = document.createElement('h4');
friendsTitle.textContent = 'Mes amis';
friendsTitle.style.margin = '0 0 5px 0';
const friendsList = document.createElement('ul');
friendsList.style.listStyle = 'none';
friendsList.style.padding = '0';
friendsList.style.margin = '0';
friendsList.style.maxHeight = '150px';
friendsList.style.overflowY = 'auto';
friendsList.style.border = '1px solid #ddd';
friendsList.style.borderRadius = '4px';
friendsList.style.padding = '5px';
// Fonction pour mettre à jour la liste d'amis
const updateFriendsList = () => {
friendsList.innerHTML = '';
if (this.friends.length === 0) {
const emptyItem = document.createElement('li');
emptyItem.textContent = 'Aucun ami pour l\'instant';
emptyItem.style.fontStyle = 'italic';
emptyItem.style.padding = '5px';
friendsList.appendChild(emptyItem);
} else {
this.friends.forEach(friend => {
const listItem = document.createElement('li');
listItem.style.display = 'flex';
listItem.style.justifyContent = 'space-between';
listItem.style.alignItems = 'center';
listItem.style.padding = '5px';
listItem.style.borderBottom = '1px solid #eee';
const friendName = document.createElement('span');
friendName.textContent = friend;
const removeBtn = document.createElement('button');
removeBtn.textContent = 'X';
removeBtn.style.backgroundColor = '#f44336';
removeBtn.style.color = 'white';
removeBtn.style.border = 'none';
removeBtn.style.borderRadius = '50%';
removeBtn.style.width = '20px';
removeBtn.style.height = '20px';
removeBtn.style.fontSize = '10px';
removeBtn.style.cursor = 'pointer';
removeBtn.style.display = 'flex';
removeBtn.style.justifyContent = 'center';
removeBtn.style.alignItems = 'center';
removeBtn.addEventListener('click', () => {
this.friends = this.friends.filter(f => f !== friend);
this.saveFriends();
updateFriendsList();
});
listItem.appendChild(friendName);
listItem.appendChild(removeBtn);
friendsList.appendChild(listItem);
});
}
};
// Initialiser la liste d'amis
updateFriendsList();
friendsManager.appendChild(friendsTitle);
friendsManager.appendChild(friendsList);
// Ajouter tous les éléments au conteneur d'options
optionsContainer.appendChild(changeUsernameBtn);
optionsContainer.appendChild(visibilityContainer);
optionsContainer.appendChild(friendsManager);
// Ajouter tous les éléments au conteneur principal
socialContainer.appendChild(title);
socialContainer.appendChild(toggleButton);
socialContainer.appendChild(optionsContainer);
// Ajouter le conteneur au document après le chargement du DOM
document.addEventListener('DOMContentLoaded', () => {
document.body.appendChild(socialContainer);
});
}
// Afficher un toast (message flottant)
showToast(message, type = 'info') {
// Créer le conteneur de toast s'il n'existe pas
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.style.position = 'fixed';
toastContainer.style.top = '20px';
toastContainer.style.left = '50%';
toastContainer.style.transform = 'translateX(-50%)';
toastContainer.style.zIndex = '1000';
toastContainer.style.display = 'flex';
toastContainer.style.flexDirection = 'column';
toastContainer.style.alignItems = 'center';
toastContainer.style.gap = '10px';
document.body.appendChild(toastContainer);
}
// Créer le toast
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.style.padding = '10px 15px';
toast.style.borderRadius = '5px';
toast.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.2)';
toast.style.minWidth = '250px';
toast.style.textAlign = 'center';
toast.style.animation = 'fadeIn 0.3s, fadeOut 0.3s 2.7s';
toast.style.opacity = '0';
toast.style.maxWidth = '80vw';
// Définir la couleur en fonction du type
switch (type) {
case 'success':
toast.style.backgroundColor = '#4CAF50';
toast.style.color = 'white';
break;
case 'warning':
toast.style.backgroundColor = '#FFC107';
toast.style.color = 'black';
break;
case 'error':
toast.style.backgroundColor = '#f44336';
toast.style.color = 'white';
break;
default: // info
toast.style.backgroundColor = '#0078ff';
toast.style.color = 'white';
}
toast.textContent = message;
// Ajouter le style d'animation s'il n'existe pas
if (!document.getElementById('toast-style')) {
const style = document.createElement('style');
style.id = 'toast-style';
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-20px); }
}
`;
document.head.appendChild(style);
}
// Ajouter le toast au conteneur
toastContainer.appendChild(toast);
// Animer l'entrée
setTimeout(() => {
toast.style.opacity = '1';
toast.style.transform = 'translateY(0)';
}, 10);
// Supprimer le toast après 3 secondes
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(-20px)';
setTimeout(() => {
toastContainer.removeChild(toast);
}, 300);
}, 3000);
}
// Formatter un timestamp en heure locale
formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString();
}
}
// Initialiser l'objet social lorsque le DOM est chargé
document.addEventListener('DOMContentLoaded', () => {
// Créer l'instance sociale
window.oedbSocial = new OEDBSocial();
});

View file

@ -7,6 +7,23 @@ let existingMarkers = [];
const PANORAMAX_TOKEN_STORAGE_KEY = 'oedb_panoramax_token';
let mediaStream = null;
// Fonction pour créer un marqueur personnalisé avec emoji
function createCustomMarker(emoji, backgroundColor) {
const markerElement = document.createElement('div');
markerElement.className = 'custom-marker';
markerElement.style.width = '30px';
markerElement.style.height = '30px';
markerElement.style.borderRadius = '50%';
markerElement.style.backgroundColor = backgroundColor;
markerElement.style.display = 'flex';
markerElement.style.justifyContent = 'center';
markerElement.style.alignItems = 'center';
markerElement.style.fontSize = '16px';
markerElement.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)';
markerElement.innerHTML = emoji;
return markerElement;
}
function setDefaultDates() {
const now = new Date();
const nowISO = now.toISOString().slice(0, 16);
@ -70,8 +87,22 @@ function fetchExistingTrafficEvents() {
if (event.geometry && event.geometry.type === 'Point') {
const coords = event.geometry.coordinates;
const needsRealityCheck = checkIfNeedsRealityCheck(event);
const markerColor = needsRealityCheck ? '#ff9800' : '#888888';
const em = new maplibregl.Marker({ color: markerColor }).setLngLat(coords).addTo(map);
let markerColor = needsRealityCheck ? '#ff9800' : '#888888';
let markerOptions = { color: markerColor };
// Check if event title contains "vélo" or "travaux"
const eventTitle = event.properties.label || '';
if (eventTitle.toLowerCase().includes('vélo')) {
markerOptions = {
element: createCustomMarker('🚲', markerColor)
};
} else if (eventTitle.toLowerCase().includes('travaux')) {
markerOptions = {
element: createCustomMarker('🚧', markerColor)
};
}
const em = new maplibregl.Marker(markerOptions).setLngLat(coords).addTo(map);
let popupContent = `\n<h3>${event.properties.label || 'Traffic Event'}</h3>\n<p>Type: ${event.properties.what || 'Unknown'}</p>\n<p>Start: ${event.properties.start || 'Unknown'}</p>\n<p>End: ${event.properties.stop || 'Unknown'}</p>`;
if (needsRealityCheck) {
popupContent += `\n<div class="reality-check">\n<p>Is this traffic event still present?</p>\n<div class="reality-check-buttons">\n<button class="confirm-btn" onclick="confirmEvent('${event.properties.id}', true)">Yes, still there</button>\n<button class="deny-btn" onclick="confirmEvent('${event.properties.id}', false)">No, it's gone</button>\n</div>\n</div>`;
@ -590,11 +621,24 @@ function updateUserInfoDisplay() {
userInfoPanel.innerHTML = `\n<h3>User Information</h3>\n<p>Username: <strong>${username}</strong></p>\n<p>Points: <span class="user-points">${points}</span></p>`;
}
// Initialize collapsible panels
function initCollapsiblePanels() {
const headers = document.querySelectorAll('.collapsible-header');
headers.forEach(header => {
header.addEventListener('click', function() {
this.classList.toggle('active');
const content = this.nextElementSibling;
content.classList.toggle('active');
});
});
}
document.addEventListener('DOMContentLoaded', function() {
setDefaultDates();
initTabs();
initMap();
updateUserInfoDisplay();
initCollapsiblePanels();
});
// Contrôles Caméra

View file

@ -1,8 +1,26 @@
<div class="nav-links">
<a href="/demo">← Retour à la démo</a>
<a href="/demo/traffic">Signaler trafic</a>
<a href="/demo/view-events">Voir événements</a>
<a href="/demo/map-by-what">Carte par type</a>
<a href="/demo/stats">Stats</a>
<div class="nav-container">
<button class="menu-toggle" aria-label="Toggle menu">
<i class="fas fa-bars"></i>
</button>
<div class="nav-links">
<a href="/demo">← Retour à la démo</a>
<a href="/demo/traffic">Signaler trafic</a>
<a href="/demo/view-events">Voir événements</a>
<a href="/demo/map-by-what">Carte par type</a>
<a href="/demo/stats">Stats</a>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const menuToggle = document.querySelector('.menu-toggle');
const navLinks = document.querySelector('.nav-links');
if (menuToggle) {
menuToggle.addEventListener('click', function() {
navLinks.classList.toggle('active');
});
}
});
</script>

View file

@ -0,0 +1,337 @@
import json
import time
import threading
import asyncio
import websockets
from oedb.utils.logging import logger
class WebSocketManager:
"""
Gestionnaire de WebSockets pour les fonctionnalités sociales d'OEDB.
Gère les connexions WebSocket des utilisateurs et distribue les messages.
Peut être utilisé soit de façon autonome, soit intégré avec uWSGI.
"""
def __init__(self):
self.clients = {}
self.positions = {}
self.lock = threading.Lock()
self.server = None
async def handle_connection(self, websocket, path):
"""
Gère une connexion WebSocket entrante.
Args:
websocket: La connexion WebSocket.
path: Le chemin de la demande.
"""
client_id = id(websocket)
logger.debug(f"Tentative de connexion WebSocket reçue: {client_id} - {path}")
try:
logger.info(f"Nouvelle connexion WebSocket: {client_id} - {path}")
# Ajouter le client à la liste
with self.lock:
self.clients[client_id] = {
'websocket': websocket,
'username': None,
'position': None,
'last_seen': time.time(),
'show_only_to_friends': False,
}
# Envoyer la liste des utilisateurs connectés
await self.send_users_list(websocket)
async for message in websocket:
await self.handle_message(client_id, message)
except websockets.exceptions.ConnectionClosed:
logger.info(f"Connexion WebSocket fermée: {client_id}")
except Exception as e:
logger.error(f"Erreur WebSocket: {e}")
finally:
# Supprimer le client de la liste
with self.lock:
if client_id in self.clients:
username = self.clients[client_id].get('username')
if username and username in self.positions:
del self.positions[username]
del self.clients[client_id]
# Informer les autres clients de la déconnexion
await self.broadcast_users_list()
async def handle_message(self, client_id, message):
"""
Traite un message WebSocket reçu.
Args:
client_id: L'ID du client qui a envoyé le message.
message: Le message JSON reçu.
"""
try:
data = json.loads(message)
message_type = data.get('type')
if message_type == 'position':
await self.handle_position_update(client_id, data)
elif message_type == 'pouet':
await self.handle_pouet(data)
elif message_type == 'friendRequest':
await self.handle_friend_request(data)
except json.JSONDecodeError:
logger.error(f"Message JSON invalide: {message}")
except Exception as e:
logger.error(f"Erreur de traitement du message: {e}")
async def handle_position_update(self, client_id, data):
"""
Traite une mise à jour de position d'un utilisateur.
Args:
client_id: L'ID du client qui a envoyé la mise à jour.
data: Les données de position.
"""
username = data.get('username')
position = data.get('position')
show_only_to_friends = data.get('showOnlyToFriends', False)
if not username or not position:
return
# Mettre à jour les informations du client
with self.lock:
if client_id in self.clients:
self.clients[client_id]['username'] = username
self.clients[client_id]['position'] = position
self.clients[client_id]['last_seen'] = time.time()
self.clients[client_id]['show_only_to_friends'] = show_only_to_friends
# Mettre à jour la position dans le dictionnaire des positions
self.positions[username] = {
'position': position,
'timestamp': data.get('timestamp'),
'show_only_to_friends': show_only_to_friends
}
# Diffuser la position à tous les autres clients
await self.broadcast_position(username, position, data.get('timestamp'), show_only_to_friends)
# Envoyer la liste mise à jour des utilisateurs
await self.broadcast_users_list()
async def handle_pouet(self, data):
"""
Traite un 'pouet pouet' envoyé d'un utilisateur à un autre.
Args:
data: Les données du pouet pouet.
"""
from_user = data.get('from')
to_user = data.get('to')
if not from_user or not to_user:
return
# Trouver le client destinataire
recipient_client_id = None
with self.lock:
for client_id, client_info in self.clients.items():
if client_info.get('username') == to_user:
recipient_client_id = client_id
break
if recipient_client_id and recipient_client_id in self.clients:
# Envoyer le pouet au destinataire
try:
await self.clients[recipient_client_id]['websocket'].send(json.dumps({
'type': 'pouet',
'from': from_user,
'timestamp': data.get('timestamp')
}))
logger.info(f"Pouet pouet envoyé de {from_user} à {to_user}")
except Exception as e:
logger.error(f"Erreur d'envoi de pouet pouet: {e}")
async def handle_friend_request(self, data):
"""
Traite une demande d'ami d'un utilisateur à un autre.
Args:
data: Les données de la demande d'ami.
"""
from_user = data.get('from')
to_user = data.get('to')
if not from_user or not to_user:
return
# Trouver le client destinataire
recipient_client_id = None
with self.lock:
for client_id, client_info in self.clients.items():
if client_info.get('username') == to_user:
recipient_client_id = client_id
break
if recipient_client_id and recipient_client_id in self.clients:
# Envoyer la demande d'ami au destinataire
try:
await self.clients[recipient_client_id]['websocket'].send(json.dumps({
'type': 'friendRequest',
'from': from_user,
'timestamp': data.get('timestamp')
}))
logger.info(f"Demande d'ami envoyée de {from_user} à {to_user}")
except Exception as e:
logger.error(f"Erreur d'envoi de demande d'ami: {e}")
async def broadcast_position(self, username, position, timestamp, show_only_to_friends):
"""
Diffuse la position d'un utilisateur à tous les autres utilisateurs.
Args:
username: Le nom d'utilisateur.
position: La position de l'utilisateur.
timestamp: L'horodatage de la mise à jour.
show_only_to_friends: Indique si la position est visible uniquement par les amis.
"""
message = json.dumps({
'type': 'position',
'username': username,
'position': position,
'timestamp': timestamp,
'showOnlyToFriends': show_only_to_friends
})
with self.lock:
for client_id, client_info in self.clients.items():
# Ne pas envoyer à l'utilisateur lui-même
if client_info.get('username') == username:
continue
try:
await client_info['websocket'].send(message)
except Exception as e:
logger.error(f"Erreur d'envoi de broadcast de position: {e}")
async def send_users_list(self, websocket):
"""
Envoie la liste des utilisateurs connectés à un client spécifique.
Args:
websocket: La connexion WebSocket du client.
"""
users = []
with self.lock:
for client_info in self.clients.values():
if client_info.get('username'):
users.append({
'username': client_info['username'],
'timestamp': time.time()
})
try:
await websocket.send(json.dumps({
'type': 'users',
'users': users
}))
except Exception as e:
logger.error(f"Erreur d'envoi de liste d'utilisateurs: {e}")
async def broadcast_users_list(self):
"""
Diffuse la liste des utilisateurs connectés à tous les clients.
"""
users = []
with self.lock:
for client_info in self.clients.values():
if client_info.get('username'):
users.append({
'username': client_info['username'],
'timestamp': time.time()
})
message = json.dumps({
'type': 'users',
'users': users
})
with self.lock:
for client_info in self.clients.values():
try:
await client_info['websocket'].send(message)
except Exception as e:
logger.error(f"Erreur de broadcast de liste d'utilisateurs: {e}")
async def cleanup_inactive_clients(self):
"""
Nettoie les clients inactifs (pas de mise à jour depuis plus de 5 minutes).
"""
inactive_clients = []
with self.lock:
current_time = time.time()
for client_id, client_info in self.clients.items():
if current_time - client_info['last_seen'] > 300: # 5 minutes
inactive_clients.append(client_id)
for client_id in inactive_clients:
username = self.clients[client_id].get('username')
if username and username in self.positions:
del self.positions[username]
del self.clients[client_id]
if inactive_clients:
logger.info(f"Nettoyage de {len(inactive_clients)} clients inactifs")
await self.broadcast_users_list()
async def cleanup_task(self):
"""
Tâche périodique pour nettoyer les clients inactifs.
"""
while True:
await asyncio.sleep(60) # Exécuter toutes les minutes
await self.cleanup_inactive_clients()
async def start_server(self, host='0.0.0.0', port=8765):
"""
Démarre le serveur WebSocket.
Args:
host: L'hôte à écouter.
port: Le port à écouter.
"""
self.server = await websockets.serve(self.handle_connection, host, port)
logger.info(f"Serveur WebSocket démarré sur {host}:{port}")
# Démarrer la tâche de nettoyage
asyncio.create_task(self.cleanup_task())
# Garder le serveur en cours d'exécution
await asyncio.Future()
def start(self, host='0.0.0.0', port=8765):
"""
Démarre le serveur WebSocket dans un thread séparé.
Args:
host: L'hôte à écouter.
port: Le port à écouter.
"""
def run_server():
asyncio.run(self.start_server(host, port))
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
logger.info(f"Serveur WebSocket démarré dans un thread séparé sur {host}:{port}")
# Créer une instance du gestionnaire WebSocket
ws_manager = WebSocketManager()
# Démarrer automatiquement le serveur WebSocket
ws_manager.start(host='127.0.0.1', port=8765)

View file

@ -36,6 +36,8 @@ class EventFormResource:
<title>Add Event - OpenEventDatabase</title>
<script src="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.css" type="text/css" />
<style>
body {
margin: 0;

406
oedb/resources/live.py Normal file
View 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()

86
oedb/resources/rss.py Normal file
View file

@ -0,0 +1,86 @@
"""
RSS feeds for recent events and by family.
"""
import falcon
import html
from datetime import datetime
from oedb.utils.logging import logger
from oedb.utils.db import db_connect
def _rss_header(title: str, link: str, desc: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>{html.escape(title)}</title>
<link>{html.escape(link)}</link>
<description>{html.escape(desc)}</description>
"""
def _rss_footer() -> str:
return """
</channel>
</rss>
"""
def _row_to_item(row) -> str:
events_id, events_tags, createdate = row
title = html.escape(str(events_tags.get('label') or events_tags.get('what') or f"event {events_id}"))
link = f"https://api.openeventdatabase.org/event/{events_id}"
guid = link
pubdate = datetime.fromisoformat(str(createdate)).strftime('%a, %d %b %Y %H:%M:%S %z') if createdate else ''
description = html.escape(str(events_tags))
return f"""
<item>
<title>{title}</title>
<link>{link}</link>
<guid>{guid}</guid>
<pubDate>{pubdate}</pubDate>
<description>{description}</description>
</item>
"""
class RSSLatestResource:
def on_get(self, req, resp):
logger.info("Processing GET /rss")
resp.content_type = 'application/rss+xml; charset=utf-8'
db = db_connect()
cur = db.cursor()
cur.execute("""
SELECT events_id, events_tags, createdate
FROM events
ORDER BY createdate DESC
LIMIT 200
""")
items = ''.join(_row_to_item(r) for r in cur.fetchall())
xml = _rss_header('OEDB - Latest events', 'https://api.openeventdatabase.org/event', 'Latest 200 events') + items + _rss_footer()
resp.text = xml
class RSSByFamilyResource:
def on_get(self, req, resp, family: str):
logger.info(f"Processing GET /rss/by/{family}")
resp.content_type = 'application/rss+xml; charset=utf-8'
db = db_connect()
cur = db.cursor()
like = family + '%'
cur.execute("""
SELECT events_id, events_tags, createdate
FROM events
WHERE events_what LIKE %s
ORDER BY createdate DESC
LIMIT 200
""", (like,))
items = ''.join(_row_to_item(r) for r in cur.fetchall())
xml = _rss_header(f'OEDB - {family}', f'https://api.openeventdatabase.org/event?what={family}', f'Latest 200 events in {family}') + items + _rss_footer()
resp.text = xml
rss_latest = RSSLatestResource()
rss_by_family = RSSByFamilyResource()