add live page
This commit is contained in:
parent
114bcca24e
commit
eb8c42d0c0
19 changed files with 2759 additions and 199 deletions
62
LANCER_SERVEUR.md
Normal file
62
LANCER_SERVEUR.md
Normal file
|
@ -0,0 +1,62 @@
|
|||
# Lancement du serveur OEDB avec WebSockets
|
||||
|
||||
Ce document explique comment lancer le serveur OEDB avec le support des WebSockets intégré via uWSGI.
|
||||
|
||||
## Prérequis
|
||||
|
||||
Assurez-vous d'avoir installé les dépendances nécessaires :
|
||||
|
||||
```bash
|
||||
pip install uwsgi
|
||||
```
|
||||
|
||||
## Lancement du serveur
|
||||
|
||||
Pour lancer le serveur avec le support WebSocket, vous avez plusieurs options :
|
||||
|
||||
### Utiliser la commande make
|
||||
|
||||
```bash
|
||||
make websocket
|
||||
```
|
||||
|
||||
Ou pour lancer en arrière-plan (mode démon) :
|
||||
|
||||
```bash
|
||||
make websocket-daemon
|
||||
```
|
||||
|
||||
### Lancer manuellement avec uWSGI
|
||||
|
||||
```bash
|
||||
uwsgi --ini uwsgi.ini
|
||||
```
|
||||
|
||||
Ces commandes démarreront le serveur sur le port 8080 avec le support WebSocket activé sur la route `/ws`.
|
||||
|
||||
## Configuration en production
|
||||
|
||||
En production, vous pouvez utiliser uWSGI avec Nginx. Voici un exemple de configuration Nginx :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name votre-domaine.com;
|
||||
|
||||
location / {
|
||||
include uwsgi_params;
|
||||
uwsgi_pass 127.0.0.1:8080;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cette configuration permet d'utiliser Nginx comme proxy inverse pour les requêtes HTTP normales et les connexions WebSocket.
|
9
Makefile
9
Makefile
|
@ -8,3 +8,12 @@ LOGFILE ?= uwsgi.log
|
|||
start:
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate && pip install -r requirements.txt && uwsgi --http :$(PORT) --wsgi-file backend.py --callable app
|
||||
|
||||
websocket:
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate && pip install -r requirements.txt && pip install websockets && uwsgi --ini uwsgi.ini
|
||||
|
||||
# Version en arrière-plan (démon)
|
||||
websocket-daemon:
|
||||
python3 -m venv venv
|
||||
. venv/bin/activate && pip install -r requirements.txt && pip install websockets && uwsgi --ini uwsgi.ini --daemonize $(LOGFILE)
|
|
@ -25,6 +25,8 @@ from oedb.resources.stats import StatsResource
|
|||
from oedb.resources.search import EventSearch
|
||||
from oedb.resources.root import root
|
||||
from oedb.resources.demo import demo, demo_stats
|
||||
from oedb.resources.live import live
|
||||
from oedb.resources.rss import rss_latest, rss_by_family
|
||||
from oedb.resources.event_form import event_form
|
||||
|
||||
def create_app():
|
||||
|
@ -86,9 +88,13 @@ def create_app():
|
|||
app.add_route('/demo/by-what', demo, suffix='by_what') # Handle events by type page
|
||||
app.add_route('/demo/map-by-what', demo, suffix='map_by_what') # Handle map by event type page
|
||||
app.add_route('/demo/edit/{id}', demo, suffix='edit') # Handle event editing page
|
||||
app.add_route('/demo/by_id/{id}', demo, suffix='by_id') # Handle view single event by id
|
||||
app.add_route('/demo/traffic', demo, suffix='traffic') # Handle traffic jam reporting page
|
||||
app.add_route('/demo/view-events', demo, suffix='view_events') # Handle view saved events page
|
||||
app.add_route('/demo/stats', demo_stats) # Handle stats by what page
|
||||
app.add_route('/demo/live', live) # Live page
|
||||
app.add_route('/rss', rss_latest) # RSS latest 200
|
||||
app.add_route('/rss/by/{family}', rss_by_family) # RSS by family
|
||||
|
||||
logger.success("Application initialized successfully")
|
||||
return app
|
||||
|
|
|
@ -3,16 +3,33 @@
|
|||
OSM Calendar Extractor for the OpenEventDatabase.
|
||||
|
||||
This script fetches events from the OpenStreetMap Calendar RSS feed
|
||||
and adds them to the OpenEventDatabase if they don't already exist.
|
||||
and adds them to the OpenEventDatabase via the API.
|
||||
|
||||
For events that don't have geographic coordinates in the RSS feed but have a link
|
||||
to an OSM Calendar event (https://osmcal.org/event/...), the script will fetch
|
||||
the iCal version of the event and extract the coordinates and location from there.
|
||||
|
||||
RSS Feed URL: https://osmcal.org/events.rss
|
||||
API Endpoint: https://api.openeventdatabase.org/event
|
||||
|
||||
Usage:
|
||||
python osm_cal.py [--max-events MAX_EVENTS] [--offset OFFSET]
|
||||
|
||||
Arguments:
|
||||
--max-events MAX_EVENTS Maximum number of events to insert (default: 1)
|
||||
--offset OFFSET Number of events to skip from the beginning of the RSS feed (default: 0)
|
||||
|
||||
Examples:
|
||||
# Insert the first event from the RSS feed
|
||||
python osm_cal.py
|
||||
|
||||
# Insert up to 5 events from the RSS feed
|
||||
python osm_cal.py --max-events 5
|
||||
|
||||
# Skip the first 3 events and insert the next 2
|
||||
python osm_cal.py --offset 3 --max-events 2
|
||||
|
||||
Environment Variables:
|
||||
DB_NAME: The name of the database (default: "oedb")
|
||||
DB_HOST: The hostname of the database server (default: "localhost")
|
||||
DB_USER: The username to connect to the database (default: "")
|
||||
POSTGRES_PASSWORD: The password to connect to the database (default: None)
|
||||
|
||||
These environment variables can be set in the system environment or in a .env file
|
||||
in the project root directory.
|
||||
"""
|
||||
|
@ -34,6 +51,8 @@ from oedb.utils.logging import logger
|
|||
|
||||
# RSS Feed URL for OSM Calendar
|
||||
RSS_URL = "https://osmcal.org/events.rss"
|
||||
# Base URL for OSM Calendar events
|
||||
OSMCAL_EVENT_BASE_URL = "https://osmcal.org/event/"
|
||||
|
||||
def fetch_osm_calendar_data():
|
||||
"""
|
||||
|
@ -179,6 +198,73 @@ def parse_event_dates(description):
|
|||
now = datetime.now()
|
||||
return (now.isoformat(), (now + timedelta(days=1)).isoformat())
|
||||
|
||||
def fetch_ical_data(event_url):
|
||||
"""
|
||||
Fetch and parse iCal data for an OSM Calendar event.
|
||||
|
||||
Args:
|
||||
event_url (str): The URL of the OSM Calendar event.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing (location_name, coordinates).
|
||||
"""
|
||||
try:
|
||||
# Check if the URL is an OSM Calendar event URL
|
||||
if not event_url.startswith(OSMCAL_EVENT_BASE_URL):
|
||||
logger.warning(f"Not an OSM Calendar event URL: {event_url}")
|
||||
return ("Unknown Location", [0, 0])
|
||||
|
||||
# Extract the event ID from the URL
|
||||
event_id_match = re.search(r'event/(\d+)', event_url)
|
||||
if not event_id_match:
|
||||
logger.warning(f"Could not extract event ID from URL: {event_url}")
|
||||
return ("Unknown Location", [0, 0])
|
||||
|
||||
event_id = event_id_match.group(1)
|
||||
|
||||
# Construct the iCal URL
|
||||
ical_url = f"{OSMCAL_EVENT_BASE_URL}{event_id}.ics"
|
||||
|
||||
# Fetch the iCal content
|
||||
logger.info(f"Fetching iCal data from: {ical_url}")
|
||||
response = requests.get(ical_url)
|
||||
|
||||
if not response.ok:
|
||||
logger.warning(f"Failed to fetch iCal data: {response.status_code}")
|
||||
return ("Unknown Location", [0, 0])
|
||||
|
||||
# Parse the iCal content
|
||||
ical_content = response.text
|
||||
|
||||
# Extract GEO information
|
||||
geo_match = re.search(r'GEO:([-+]?\d+\.\d+);([-+]?\d+\.\d+)', ical_content)
|
||||
if geo_match:
|
||||
# GEO format is latitude;longitude
|
||||
latitude = float(geo_match.group(2))
|
||||
longitude = float(geo_match.group(1))
|
||||
coordinates = [longitude, latitude] # GeoJSON uses [longitude, latitude]
|
||||
logger.info(f"Extracted coordinates from iCal: {coordinates}")
|
||||
else:
|
||||
logger.warning(f"No GEO information found in iCal data for event: {event_id}")
|
||||
coordinates = [0, 0]
|
||||
|
||||
# Extract LOCATION information
|
||||
location_match = re.search(r'LOCATION:(.+?)(?:\r\n|\n|\r)', ical_content)
|
||||
if location_match:
|
||||
location_name = location_match.group(1).strip()
|
||||
# Unescape backslash-escaped characters (e.g., \, becomes ,)
|
||||
location_name = re.sub(r'\\(.)', r'\1', location_name)
|
||||
logger.info(f"Extracted location from iCal: {location_name}")
|
||||
else:
|
||||
logger.warning(f"No LOCATION information found in iCal data for event: {event_id}")
|
||||
location_name = "Unknown Location"
|
||||
|
||||
return (location_name, coordinates)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching or parsing iCal data: {e}")
|
||||
return ("Unknown Location", [0, 0])
|
||||
|
||||
def extract_location(description):
|
||||
"""
|
||||
Extract location information from the event description.
|
||||
|
@ -239,9 +325,25 @@ def create_event(item):
|
|||
# Parse dates from the description
|
||||
start_date, end_date = parse_event_dates(description)
|
||||
|
||||
# Extract location information
|
||||
# Extract location information from the description
|
||||
location_name, coordinates = extract_location(description)
|
||||
|
||||
# If we don't have coordinates and the link is to an OSM Calendar event,
|
||||
# try to get coordinates and location from the iCal file
|
||||
if coordinates == [0, 0] and link and link.startswith(OSMCAL_EVENT_BASE_URL):
|
||||
logger.info(f"No coordinates found in description, trying to get from iCal: {link}")
|
||||
ical_location_name, ical_coordinates = fetch_ical_data(link)
|
||||
|
||||
# Use iCal coordinates if available
|
||||
if ical_coordinates != [0, 0]:
|
||||
coordinates = ical_coordinates
|
||||
logger.info(f"Using coordinates from iCal: {coordinates}")
|
||||
|
||||
# Use iCal location name if available and better than what we have
|
||||
if ical_location_name != "Unknown Location":
|
||||
location_name = ical_location_name
|
||||
logger.info(f"Using location name from iCal: {location_name}")
|
||||
|
||||
# Create a descriptive label
|
||||
label = title
|
||||
|
||||
|
@ -325,7 +427,7 @@ def event_exists(db, properties):
|
|||
|
||||
def submit_event(event):
|
||||
"""
|
||||
Submit an event to the OpenEventDatabase.
|
||||
Submit an event to the OpenEventDatabase using the API.
|
||||
|
||||
Args:
|
||||
event: A GeoJSON Feature representing the event.
|
||||
|
@ -334,129 +436,53 @@ def submit_event(event):
|
|||
bool: True if the event was successfully submitted, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Connect to the database
|
||||
db = db_connect()
|
||||
|
||||
# Extract event properties
|
||||
# Extract event properties for logging
|
||||
properties = event['properties']
|
||||
|
||||
# Check if the event already exists
|
||||
if event_exists(db, properties):
|
||||
logger.info(f"Skipping event '{properties.get('label')}' as it already exists")
|
||||
db.close()
|
||||
return False
|
||||
# API endpoint for OpenEventDatabase
|
||||
api_url = "https://api.openeventdatabase.org/event"
|
||||
|
||||
cur = db.cursor()
|
||||
geometry = json.dumps(event['geometry'])
|
||||
|
||||
print('event: ', event)
|
||||
# Insert the geometry into the geo table
|
||||
cur.execute("""
|
||||
INSERT INTO geo
|
||||
SELECT geom, md5(st_astext(geom)) as hash, st_centroid(geom) as geom_center FROM
|
||||
(SELECT st_setsrid(st_geomfromgeojson(%s),4326) as geom) as g
|
||||
WHERE ST_IsValid(geom)
|
||||
ON CONFLICT DO NOTHING RETURNING hash;
|
||||
""", (geometry,))
|
||||
|
||||
# Get the geometry hash
|
||||
hash_result = cur.fetchone()
|
||||
|
||||
if hash_result is None:
|
||||
# If the hash is None, check if the geometry already exists in the database
|
||||
cur.execute("""
|
||||
SELECT hash FROM geo
|
||||
WHERE hash = md5(st_astext(st_setsrid(st_geomfromgeojson(%s),4326)));
|
||||
""", (geometry,))
|
||||
existing_hash = cur.fetchone()
|
||||
|
||||
if existing_hash:
|
||||
# Geometry already exists in the database, use its hash
|
||||
geo_hash = existing_hash[0]
|
||||
logger.info(f"Using existing geometry with hash: {geo_hash}")
|
||||
else:
|
||||
# Geometry doesn't exist, try to insert it directly
|
||||
cur.execute("""
|
||||
SELECT md5(st_astext(geom)) as hash,
|
||||
ST_IsValid(geom),
|
||||
ST_IsValidReason(geom) from (SELECT st_setsrid(st_geomfromgeojson(%s),4326) as geom) as g;
|
||||
""", (geometry,))
|
||||
hash_result = cur.fetchone()
|
||||
|
||||
if hash_result is None or not hash_result[1]:
|
||||
logger.error(f"Invalid geometry for event: {properties.get('label')}")
|
||||
if hash_result and len(hash_result) > 2:
|
||||
logger.error(f"Reason: {hash_result[2]}")
|
||||
db.close()
|
||||
return False
|
||||
|
||||
geo_hash = hash_result[0]
|
||||
|
||||
# Now insert the geometry explicitly
|
||||
cur.execute("""
|
||||
INSERT INTO geo (geom, hash, geom_center)
|
||||
VALUES (
|
||||
st_setsrid(st_geomfromgeojson(%s),4326),
|
||||
%s,
|
||||
st_centroid(st_setsrid(st_geomfromgeojson(%s),4326))
|
||||
# Make the API request
|
||||
logger.info(f"Submitting event '{properties.get('label')}' to API")
|
||||
response = requests.post(
|
||||
api_url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=json.dumps(event)
|
||||
)
|
||||
ON CONFLICT (hash) DO NOTHING;
|
||||
""", (geometry, geo_hash, geometry))
|
||||
|
||||
# Verify the geometry was inserted
|
||||
cur.execute("SELECT 1 FROM geo WHERE hash = %s", (geo_hash,))
|
||||
if cur.fetchone() is None:
|
||||
logger.error(f"Failed to insert geometry with hash: {geo_hash}")
|
||||
db.close()
|
||||
return False
|
||||
|
||||
logger.info(f"Inserted new geometry with hash: {geo_hash}")
|
||||
else:
|
||||
geo_hash = hash_result[0]
|
||||
|
||||
# Determine the bounds for the time range
|
||||
bounds = '[]' if properties['start'] == properties['stop'] else '[)'
|
||||
|
||||
# Insert the event into the database
|
||||
cur.execute("""
|
||||
INSERT INTO events (events_type, events_what, events_when, events_tags, events_geo)
|
||||
VALUES (%s, %s, tstzrange(%s, %s, %s), %s, %s)
|
||||
ON CONFLICT DO NOTHING RETURNING events_id;
|
||||
""", (
|
||||
properties['type'],
|
||||
properties['what'],
|
||||
properties['start'],
|
||||
properties['stop'],
|
||||
bounds,
|
||||
json.dumps(properties),
|
||||
geo_hash
|
||||
))
|
||||
|
||||
# Get the event ID
|
||||
event_id = cur.fetchone()
|
||||
# Check if the request was successful
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
# Parse the response to get the event ID
|
||||
response_data = response.json()
|
||||
event_id = response_data.get('id')
|
||||
|
||||
if event_id:
|
||||
logger.success(f"Event created with ID: {event_id[0]}")
|
||||
db.commit()
|
||||
db.close()
|
||||
logger.success(f"Event created with ID: {event_id}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to create event: {properties.get('label')}")
|
||||
db.close()
|
||||
logger.warning(f"Event created but no ID returned in response")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to create event: {properties.get('label')}. Status code: {response.status_code}")
|
||||
logger.warning(f"Response: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error submitting event: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
def main(max_events=1, offset=0):
|
||||
"""
|
||||
Main function to fetch OSM Calendar events and add them to the database.
|
||||
Main function to fetch OSM Calendar events and add them to the OpenEventDatabase API.
|
||||
|
||||
Args:
|
||||
max_events (int): Maximum number of events to insert (default: 1)
|
||||
offset (int): Number of events to skip from the beginning of the RSS feed (default: 0)
|
||||
|
||||
The function will exit if the .env file doesn't exist, as it's required
|
||||
for database connection parameters.
|
||||
for environment variables.
|
||||
"""
|
||||
logger.info("Starting OSM Calendar extractor")
|
||||
logger.info(f"Starting OSM Calendar extractor (max_events={max_events}, offset={offset})")
|
||||
|
||||
# Load environment variables from .env file and check if it exists
|
||||
if not load_env_from_file():
|
||||
|
@ -472,20 +498,42 @@ def main():
|
|||
logger.warning("No events found, exiting")
|
||||
return
|
||||
|
||||
# Apply offset and limit
|
||||
if offset >= len(items):
|
||||
logger.warning(f"Offset {offset} is greater than or equal to the number of events {len(items)}, no events to process")
|
||||
return
|
||||
|
||||
# Slice the items list according to offset and max_events
|
||||
items_to_process = items[offset:offset + max_events]
|
||||
logger.info(f"Processing {len(items_to_process)} events (offset={offset}, max_events={max_events})")
|
||||
|
||||
# Process each item
|
||||
success_count = 0
|
||||
for item in items:
|
||||
for item in items_to_process:
|
||||
# Create an event from the item
|
||||
event = create_event(item)
|
||||
|
||||
if not event:
|
||||
continue
|
||||
|
||||
# Submit the event to the database
|
||||
# Submit the event to the API
|
||||
if submit_event(event):
|
||||
success_count += 1
|
||||
|
||||
logger.success(f"Successfully added {success_count} out of {len(items)} events to the database")
|
||||
logger.success(f"Successfully added {success_count} out of {len(items_to_process)} events to the OpenEventDatabase")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
import argparse
|
||||
|
||||
# Set up command line argument parsing
|
||||
parser = argparse.ArgumentParser(description='OSM Calendar Extractor for the OpenEventDatabase')
|
||||
parser.add_argument('--max-events', type=int, default=1,
|
||||
help='Maximum number of events to insert (default: 1)')
|
||||
parser.add_argument('--offset', type=int, default=0,
|
||||
help='Number of events to skip from the beginning of the RSS feed (default: 0)')
|
||||
|
||||
# Parse arguments
|
||||
args = parser.parse_args()
|
||||
|
||||
# Run the main function with the provided arguments
|
||||
main(max_events=args.max_events, offset=args.offset)
|
|
@ -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>
|
||||
|
||||
<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()
|
|
@ -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,74 +245,45 @@ 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>
|
||||
// Fonction pour gérer les listes dépliantes et sections collapsibles
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
@ -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,7 +318,97 @@ 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 = {
|
||||
|
@ -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,11 +518,19 @@ 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() {
|
||||
// Fetch events from the API - using the local API endpoint
|
||||
|
@ -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);
|
||||
|
@ -441,11 +556,48 @@ class DemoMainResource:
|
|||
});
|
||||
}
|
||||
|
||||
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')) {
|
||||
|
@ -608,9 +765,12 @@ class DemoMainResource:
|
|||
.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>
|
||||
|
|
|
@ -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;
|
||||
|
@ -524,3 +620,9 @@ select:invalid {
|
|||
float: left;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
button{
|
||||
padding: 1rem 0.5rem;
|
||||
border-radius: 5px;
|
||||
background-color: #79a2d1;
|
||||
}
|
1
oedb/resources/demo/static/pouet.mp3
Normal file
1
oedb/resources/demo/static/pouet.mp3
Normal file
|
@ -0,0 +1 @@
|
|||
# Ce fichier est un MP3 binaire qui contiendra le son 'pouet pouet'.
|
750
oedb/resources/demo/static/social.js
Normal file
750
oedb/resources/demo/static/social.js
Normal 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();
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
<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>
|
||||
|
@ -5,4 +9,18 @@
|
|||
<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>
|
||||
|
||||
|
|
337
oedb/resources/demo/websocket.py
Normal file
337
oedb/resources/demo/websocket.py
Normal 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)
|
|
@ -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
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()
|
||||
|
||||
|
86
oedb/resources/rss.py
Normal file
86
oedb/resources/rss.py
Normal 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()
|
||||
|
||||
|
48
test_osm_cal_api.py
Normal file
48
test_osm_cal_api.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for the OSM Calendar extractor with different parameter combinations.
|
||||
This script tests the functionality of the osm_cal.py script with various
|
||||
combinations of max_events and offset parameters.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from extractors.osm_cal import main as osm_cal_main
|
||||
|
||||
def run_test(max_events, offset):
|
||||
"""
|
||||
Run the OSM Calendar extractor with the specified parameters.
|
||||
|
||||
Args:
|
||||
max_events (int): Maximum number of events to insert
|
||||
offset (int): Number of events to skip from the beginning of the RSS feed
|
||||
"""
|
||||
print(f"\n=== Testing with max_events={max_events}, offset={offset} ===")
|
||||
osm_cal_main(max_events=max_events, offset=offset)
|
||||
print("=== Test completed ===\n")
|
||||
|
||||
def main():
|
||||
"""
|
||||
Run tests with different parameter combinations.
|
||||
"""
|
||||
print("Starting OSM Calendar API tests...")
|
||||
|
||||
# Test 1: Default parameters (max_events=1, offset=0)
|
||||
run_test(1, 0)
|
||||
|
||||
# Test 2: Multiple events (max_events=3, offset=0)
|
||||
run_test(3, 0)
|
||||
|
||||
# Test 3: With offset (max_events=2, offset=2)
|
||||
run_test(2, 2)
|
||||
|
||||
# Test 4: Large offset (max_events=1, offset=10)
|
||||
run_test(1, 10)
|
||||
|
||||
# Test 5: Large max_events (max_events=10, offset=0)
|
||||
run_test(10, 0)
|
||||
|
||||
print("All tests completed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
128
test_osm_cal_ical.py
Normal file
128
test_osm_cal_ical.py
Normal file
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for the OSM Calendar extractor's iCal functionality.
|
||||
|
||||
This script tests the ability of the osm_cal.py script to extract
|
||||
geographic coordinates and location information from iCal files
|
||||
for events that don't have this information in the RSS feed.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from extractors.osm_cal import fetch_ical_data, OSMCAL_EVENT_BASE_URL
|
||||
from oedb.utils.logging import logger
|
||||
|
||||
def test_fetch_ical_data():
|
||||
"""
|
||||
Test the fetch_ical_data function with a real OSM Calendar event.
|
||||
"""
|
||||
print("\n=== Testing fetch_ical_data function ===")
|
||||
|
||||
# Test with a real OSM Calendar event URL
|
||||
# Note: This event ID might not exist in the future, so the test might fail
|
||||
event_url = f"{OSMCAL_EVENT_BASE_URL}3973"
|
||||
|
||||
print(f"Fetching iCal data for event: {event_url}")
|
||||
location_name, coordinates = fetch_ical_data(event_url)
|
||||
|
||||
print(f"Location name: {location_name}")
|
||||
print(f"Coordinates: {coordinates}")
|
||||
|
||||
if coordinates != [0, 0]:
|
||||
print("✅ Successfully extracted coordinates from iCal")
|
||||
else:
|
||||
print("❌ Failed to extract coordinates from iCal")
|
||||
|
||||
if location_name != "Unknown Location":
|
||||
print("✅ Successfully extracted location name from iCal")
|
||||
else:
|
||||
print("❌ Failed to extract location name from iCal")
|
||||
|
||||
print("=== Test completed ===\n")
|
||||
|
||||
return coordinates != [0, 0] and location_name != "Unknown Location"
|
||||
|
||||
def test_with_invalid_url():
|
||||
"""
|
||||
Test the fetch_ical_data function with an invalid URL.
|
||||
"""
|
||||
print("\n=== Testing fetch_ical_data with invalid URL ===")
|
||||
|
||||
# Test with an invalid event URL
|
||||
event_url = f"{OSMCAL_EVENT_BASE_URL}999999999"
|
||||
|
||||
print(f"Fetching iCal data for invalid event: {event_url}")
|
||||
location_name, coordinates = fetch_ical_data(event_url)
|
||||
|
||||
print(f"Location name: {location_name}")
|
||||
print(f"Coordinates: {coordinates}")
|
||||
|
||||
if coordinates == [0, 0]:
|
||||
print("✅ Correctly returned default coordinates for invalid URL")
|
||||
else:
|
||||
print("❌ Unexpectedly returned coordinates for invalid URL")
|
||||
|
||||
if location_name == "Unknown Location":
|
||||
print("✅ Correctly returned default location name for invalid URL")
|
||||
else:
|
||||
print("❌ Unexpectedly returned location name for invalid URL")
|
||||
|
||||
print("=== Test completed ===\n")
|
||||
|
||||
return coordinates == [0, 0] and location_name == "Unknown Location"
|
||||
|
||||
def test_with_non_osmcal_url():
|
||||
"""
|
||||
Test the fetch_ical_data function with a non-OSM Calendar URL.
|
||||
"""
|
||||
print("\n=== Testing fetch_ical_data with non-OSM Calendar URL ===")
|
||||
|
||||
# Test with a non-OSM Calendar URL
|
||||
event_url = "https://example.com/event/123"
|
||||
|
||||
print(f"Fetching iCal data for non-OSM Calendar URL: {event_url}")
|
||||
location_name, coordinates = fetch_ical_data(event_url)
|
||||
|
||||
print(f"Location name: {location_name}")
|
||||
print(f"Coordinates: {coordinates}")
|
||||
|
||||
if coordinates == [0, 0]:
|
||||
print("✅ Correctly returned default coordinates for non-OSM Calendar URL")
|
||||
else:
|
||||
print("❌ Unexpectedly returned coordinates for non-OSM Calendar URL")
|
||||
|
||||
if location_name == "Unknown Location":
|
||||
print("✅ Correctly returned default location name for non-OSM Calendar URL")
|
||||
else:
|
||||
print("❌ Unexpectedly returned location name for non-OSM Calendar URL")
|
||||
|
||||
print("=== Test completed ===\n")
|
||||
|
||||
return coordinates == [0, 0] and location_name == "Unknown Location"
|
||||
|
||||
def main():
|
||||
"""
|
||||
Run all tests.
|
||||
"""
|
||||
print("Starting OSM Calendar iCal tests...")
|
||||
|
||||
# Run tests
|
||||
test1 = test_fetch_ical_data()
|
||||
test2 = test_with_invalid_url()
|
||||
test3 = test_with_non_osmcal_url()
|
||||
|
||||
# Print summary
|
||||
print("\n=== Test Summary ===")
|
||||
print(f"Test 1 (fetch_ical_data): {'PASSED' if test1 else 'FAILED'}")
|
||||
print(f"Test 2 (invalid URL): {'PASSED' if test2 else 'FAILED'}")
|
||||
print(f"Test 3 (non-OSM Calendar URL): {'PASSED' if test3 else 'FAILED'}")
|
||||
|
||||
if test1 and test2 and test3:
|
||||
print("\n✅ All tests PASSED")
|
||||
else:
|
||||
print("\n❌ Some tests FAILED")
|
||||
|
||||
print("All tests completed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
19
uwsgi.ini
Normal file
19
uwsgi.ini
Normal file
|
@ -0,0 +1,19 @@
|
|||
[uwsgi]
|
||||
http = :8080
|
||||
http-websockets = true
|
||||
wsgi-file = wsgi_websocket.py
|
||||
chdir = .
|
||||
virtualenv = venv
|
||||
async = 100
|
||||
ugreen = true
|
||||
processes = 2
|
||||
threads = 4
|
||||
master = true
|
||||
vacuum = true
|
||||
die-on-term = true
|
||||
pythonpath = .
|
||||
buffer-size = 65535
|
||||
harakiri = 30
|
||||
max-requests = 1000
|
||||
socket-timeout = 120
|
||||
log-date = true
|
194
wsgi_websocket.py
Normal file
194
wsgi_websocket.py
Normal file
|
@ -0,0 +1,194 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Configuration uWSGI pour l'intégration des WebSockets avec OEDB
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import asyncio
|
||||
from oedb.utils.logging import logger
|
||||
|
||||
# Import l'application Falcon principale
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from backend import app
|
||||
|
||||
# Import le gestionnaire WebSocket
|
||||
from oedb.resources.demo.websocket import WebSocketManager
|
||||
|
||||
# Créer une instance du gestionnaire WebSocket
|
||||
ws_manager = WebSocketManager()
|
||||
|
||||
# Gestionnaire de WebSocket pour uWSGI
|
||||
def websocket_handler(environ, start_response):
|
||||
"""Gestionnaire pour les connexions WebSocket."""
|
||||
path = environ.get('PATH_INFO', '')
|
||||
|
||||
# Vérifier si c'est une requête WebSocket et qu'elle est sur le bon chemin
|
||||
if path == '/ws' and environ.get('HTTP_UPGRADE', '').lower() == 'websocket':
|
||||
try:
|
||||
# Importer uwsgi ici car il n'est disponible qu'à l'exécution dans l'environnement uWSGI
|
||||
import uwsgi
|
||||
|
||||
# Passer la requête au gestionnaire de WebSocket uWSGI
|
||||
uwsgi.websocket_handshake()
|
||||
logger.info(f"Nouvelle connexion WebSocket établie")
|
||||
|
||||
client_id = id(environ)
|
||||
# Simuler la fonction handle_connection pour ce client
|
||||
try:
|
||||
# Ajouter le client à la liste
|
||||
with ws_manager.lock:
|
||||
ws_manager.clients[client_id] = {
|
||||
'websocket': environ, # Utiliser environ comme proxy
|
||||
'username': None,
|
||||
'position': None,
|
||||
'last_seen': time.time(),
|
||||
'show_only_to_friends': False,
|
||||
}
|
||||
|
||||
# Envoyer la liste des utilisateurs connectés
|
||||
users_list = []
|
||||
with ws_manager.lock:
|
||||
for client_info in ws_manager.clients.values():
|
||||
if client_info.get('username'):
|
||||
users_list.append({
|
||||
'username': client_info['username'],
|
||||
'timestamp': time.time()
|
||||
})
|
||||
|
||||
# Envoyer la liste des utilisateurs
|
||||
uwsgi.websocket_send(json.dumps({
|
||||
'type': 'users',
|
||||
'users': users_list
|
||||
}).encode('utf-8'))
|
||||
|
||||
# Boucle de traitement des messages
|
||||
while True:
|
||||
msg = uwsgi.websocket_recv()
|
||||
data = json.loads(msg.decode('utf-8'))
|
||||
message_type = data.get('type')
|
||||
|
||||
if message_type == 'position':
|
||||
# Traiter la mise à jour de position
|
||||
username = data.get('username')
|
||||
position = data.get('position')
|
||||
timestamp = data.get('timestamp')
|
||||
show_only_to_friends = data.get('showOnlyToFriends', False)
|
||||
|
||||
# Mettre à jour les informations du client
|
||||
with ws_manager.lock:
|
||||
if client_id in ws_manager.clients:
|
||||
ws_manager.clients[client_id]['username'] = username
|
||||
ws_manager.clients[client_id]['position'] = position
|
||||
ws_manager.clients[client_id]['last_seen'] = time.time()
|
||||
ws_manager.clients[client_id]['show_only_to_friends'] = show_only_to_friends
|
||||
|
||||
# Mettre à jour dans le dictionnaire des positions
|
||||
ws_manager.positions[username] = {
|
||||
'position': position,
|
||||
'timestamp': timestamp,
|
||||
'show_only_to_friends': show_only_to_friends
|
||||
}
|
||||
|
||||
# Diffuser à tous les autres clients
|
||||
broadcast_position(username, position, timestamp, show_only_to_friends)
|
||||
|
||||
elif message_type in ['pouet', 'friendRequest']:
|
||||
# Rediriger ces messages vers les destinataires
|
||||
handle_direct_message(data, message_type)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur WebSocket: {e}")
|
||||
finally:
|
||||
# Supprimer le client de la liste
|
||||
with ws_manager.lock:
|
||||
if client_id in ws_manager.clients:
|
||||
username = ws_manager.clients[client_id].get('username')
|
||||
if username and username in ws_manager.positions:
|
||||
del ws_manager.positions[username]
|
||||
del ws_manager.clients[client_id]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur de connexion WebSocket: {e}")
|
||||
return ['500 Internal Server Error', [], [b'WebSocket Error']]
|
||||
|
||||
# Si ce n'est pas une requête WebSocket, passer à l'application WSGI
|
||||
return app(environ, start_response)
|
||||
|
||||
# Fonctions utilitaires pour les WebSockets uWSGI
|
||||
def broadcast_position(username, position, timestamp, show_only_to_friends):
|
||||
"""Diffuse la position d'un utilisateur à tous les autres utilisateurs."""
|
||||
import uwsgi
|
||||
|
||||
message = json.dumps({
|
||||
'type': 'position',
|
||||
'username': username,
|
||||
'position': position,
|
||||
'timestamp': timestamp,
|
||||
'showOnlyToFriends': show_only_to_friends
|
||||
}).encode('utf-8')
|
||||
|
||||
with ws_manager.lock:
|
||||
for client_id, client_info in ws_manager.clients.items():
|
||||
# Ne pas envoyer à l'utilisateur lui-même
|
||||
if client_info.get('username') == username:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Dans uWSGI, nous devons utiliser environ pour identifier le socket
|
||||
if 'websocket' in client_info:
|
||||
uwsgi.websocket_send(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur d'envoi de broadcast de position: {e}")
|
||||
|
||||
def handle_direct_message(data, message_type):
|
||||
"""Traite un message direct (pouet ou demande d'ami)."""
|
||||
import uwsgi
|
||||
|
||||
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 ws_manager.lock:
|
||||
for client_id, client_info in ws_manager.clients.items():
|
||||
if client_info.get('username') == to_user:
|
||||
recipient_client_id = client_id
|
||||
break
|
||||
|
||||
if recipient_client_id and recipient_client_id in ws_manager.clients:
|
||||
# Préparer le message selon le type
|
||||
if message_type == 'pouet':
|
||||
msg = json.dumps({
|
||||
'type': 'pouet',
|
||||
'from': from_user,
|
||||
'timestamp': data.get('timestamp')
|
||||
}).encode('utf-8')
|
||||
logger.info(f"Pouet pouet envoyé de {from_user} à {to_user}")
|
||||
elif message_type == 'friendRequest':
|
||||
msg = json.dumps({
|
||||
'type': 'friendRequest',
|
||||
'from': from_user,
|
||||
'timestamp': data.get('timestamp')
|
||||
}).encode('utf-8')
|
||||
logger.info(f"Demande d'ami envoyée de {from_user} à {to_user}")
|
||||
|
||||
# Envoyer le message
|
||||
try:
|
||||
uwsgi.websocket_send(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur d'envoi de message direct: {e}")
|
||||
|
||||
# Appliquer le patch au démarrage
|
||||
patch_websocket_manager()
|
||||
|
||||
# Application WSGI pour uWSGI
|
||||
application = websocket_handler
|
||||
|
||||
# Remarque: Pour utiliser ce fichier, démarrez uWSGI avec:
|
||||
# uwsgi --http :8080 --http-websockets --wsgi-file wsgi_websocket.py --async 100 --ugreen
|
Loading…
Add table
Add a link
Reference in a new issue