diff --git a/backend.py b/backend.py
index fb86c36..6b8d276 100644
--- a/backend.py
+++ b/backend.py
@@ -12,7 +12,7 @@ import falcon
# Import utility modules
from oedb.utils.logging import logger
-from oedb.utils.db import check_db_connection
+from oedb.utils.db import check_db_connection, load_env_from_file
# Import middleware
from oedb.middleware.headers import HeaderMiddleware
@@ -24,7 +24,7 @@ from oedb.resources.event import event
from oedb.resources.stats import StatsResource
from oedb.resources.search import EventSearch
from oedb.resources.root import root
-from oedb.resources.demo import demo
+from oedb.resources.demo import demo, demo_stats
from oedb.resources.event_form import event_form
def create_app():
@@ -34,6 +34,9 @@ def create_app():
Returns:
falcon.App: The configured Falcon application.
"""
+ # Load environment variables from .env (if present)
+ load_env_from_file()
+
# Create the Falcon application with middleware
logger.info("Initializing Falcon application")
app = falcon.App(middleware=[
@@ -47,6 +50,21 @@ def create_app():
static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'oedb', 'resources', 'demo', 'static'))
app.add_static_route('/static/', static_dir)
+ # Check environment variables
+ required_env = [
+ 'DB_NAME', 'DB_HOST', 'DB_USER', 'POSTGRES_PASSWORD',
+ 'CLIENT_ID', 'CLIENT_SECRET', 'CLIENT_REDIRECT', 'CLIENT_AUTHORIZATIONS'
+ ]
+ optional_env = [
+ 'PANORAMAX_UPLOAD_URL', 'PANORAMAX_TOKEN'
+ ]
+ missing_required = [k for k in required_env if not os.getenv(k)]
+ missing_optional = [k for k in optional_env if not os.getenv(k)]
+ if missing_required:
+ logger.warning(f"Missing required environment variables: {', '.join(missing_required)}")
+ if missing_optional:
+ logger.info(f"Optional environment variables not set: {', '.join(missing_optional)}")
+
# Check database connection before continuing
if not check_db_connection():
logger.error("Cannot start server - PostgreSQL database is not responding")
@@ -70,6 +88,7 @@ def create_app():
app.add_route('/demo/edit/{id}', demo, suffix='edit') # Handle event editing page
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
logger.success("Application initialized successfully")
return app
diff --git a/oedb/resources/demo.py b/oedb/resources/demo.py
index 0ecda08..70e7490 100644
--- a/oedb/resources/demo.py
+++ b/oedb/resources/demo.py
@@ -1536,8 +1536,8 @@ class DemoResource:
// Update event info
document.getElementById('event-info').innerHTML = '
Loading events...
';
- // Fetch events from the API - using limit=1000 to get more events
- fetch('/event?limit=1000')
+ // Fetch events from the public API - using limit=1000 to get more events
+ fetch('https://api.openeventdatabase.org/event?limit=1000')
.then(response => response.json())
.then(data => {
if (data.features && data.features.length > 0) {
diff --git a/oedb/resources/demo/__init__.py b/oedb/resources/demo/__init__.py
index f47cd7f..eddd307 100644
--- a/oedb/resources/demo/__init__.py
+++ b/oedb/resources/demo/__init__.py
@@ -5,6 +5,7 @@ This package contains modules for the demo endpoints.
from oedb.resources.demo.demo_main import demo_main
from oedb.resources.demo.demo_traffic import demo_traffic
+from oedb.resources.demo.demo_stats import demo_stats
from oedb.resources.demo.demo_view_events import demo_view_events
# Import DemoResource class from the original demo.py file
@@ -25,4 +26,4 @@ spec.loader.exec_module(demo_original)
demo = demo_original.demo
# Export the demo resources and the demo object
-__all__ = ['demo_main', 'demo_traffic', 'demo_view_events', 'demo']
\ No newline at end of file
+__all__ = ['demo_main', 'demo_traffic', 'demo_view_events', 'demo_stats', 'demo']
\ No newline at end of file
diff --git a/oedb/resources/demo/demo_main.py b/oedb/resources/demo/demo_main.py
index c631f71..3df856a 100644
--- a/oedb/resources/demo/demo_main.py
+++ b/oedb/resources/demo/demo_main.py
@@ -492,13 +492,26 @@ class DemoMainResource:
// Function to check if an event needs a reality check (created more than 1 hour ago)
function checkIfNeedsRealityCheck(event) {
-
-
- // Check if the event is a traffic event
+ // Skip if event already has a reality check
+ if (event.properties['reality_check']) {
+ return false;
+ }
+ // Only for traffic events
if (!event.properties.what || !event.properties.what.startsWith('traffic')) {
return false;
}
- return false;
+ // Must have a creation date
+ const createDate = event.properties.createdate;
+ if (!createDate) {
+ return false;
+ }
+ const createTime = new Date(createDate).getTime();
+ if (isNaN(createTime)) {
+ return false;
+ }
+ const currentTime = new Date().getTime();
+ const oneHourInMs = 60 * 60 * 1000;
+ return (currentTime - createTime) > oneHourInMs;
}
// Function to fit map to events bounds
diff --git a/oedb/resources/demo/demo_stats.py b/oedb/resources/demo/demo_stats.py
new file mode 100644
index 0000000..9602a79
--- /dev/null
+++ b/oedb/resources/demo/demo_stats.py
@@ -0,0 +1,53 @@
+"""
+Stats page for demo: list counts per what, and optional map per selected type.
+"""
+
+import os
+import falcon
+from collections import Counter
+from oedb.utils.logging import logger
+from oedb.utils.db import db_connect
+import jinja2
+
+
+class DemoStatsResource:
+ def __init__(self):
+ template_dir = os.path.join(os.path.dirname(__file__), 'templates')
+ self.jinja_env = jinja2.Environment(
+ loader=jinja2.FileSystemLoader(template_dir),
+ autoescape=jinja2.select_autoescape(['html', 'xml'])
+ )
+
+ def on_get(self, req, resp):
+ logger.info("Processing GET request to /demo/stats")
+ resp.content_type = 'text/html'
+
+ selected_what = req.get_param('what')
+
+ try:
+ db = db_connect()
+ cur = db.cursor()
+ # Compter par what
+ cur.execute("""
+ SELECT events_what, COUNT(*)
+ FROM events
+ GROUP BY events_what
+ ORDER BY COUNT(*) DESC
+ """)
+ rows = cur.fetchall()
+ counts = [(r[0], r[1]) for r in rows]
+ total_events = sum(c for _, c in counts)
+ template = self.jinja_env.get_template('stats.html')
+ html = template.render(counts=counts, total_events=total_events, selected_what=selected_what)
+ resp.text = html
+ resp.status = falcon.HTTP_200
+ logger.success("Successfully processed GET request to /demo/stats")
+ except Exception as e:
+ logger.error(f"Error processing GET request to /demo/stats: {e}")
+ resp.status = falcon.HTTP_500
+ resp.text = f"Error: {str(e)}"
+
+
+demo_stats = DemoStatsResource()
+
+
diff --git a/oedb/resources/demo/demo_traffic.py b/oedb/resources/demo/demo_traffic.py
index 9214fab..770a5a7 100644
--- a/oedb/resources/demo/demo_traffic.py
+++ b/oedb/resources/demo/demo_traffic.py
@@ -48,7 +48,7 @@ class DemoTrafficResource:
# Get OAuth2 configuration parameters
client_id = os.getenv("CLIENT_ID", "")
client_secret = os.getenv("CLIENT_SECRET", "")
- client_authorizations = os.getenv("CLIENT_AUTORIZATIONS", "read_prefs")
+ client_authorizations = os.getenv("CLIENT_AUTHORIZATIONS", "read_prefs")
client_redirect = os.getenv("CLIENT_REDIRECT", "")
# Check if we have an authorization code in the query parameters
@@ -114,7 +114,9 @@ class DemoTrafficResource:
client_authorizations=client_authorizations,
is_authenticated=is_authenticated,
osm_username=osm_username,
- osm_user_id=osm_user_id
+ osm_user_id=osm_user_id,
+ panoramax_upload_url=os.getenv("PANORAMAX_UPLOAD_URL", ""),
+ panoramax_token=os.getenv("PANORAMAX_TOKEN", "")
)
# Set the response body and status
diff --git a/oedb/resources/demo/static/traffic.css b/oedb/resources/demo/static/traffic.css
new file mode 100644
index 0000000..28d16e5
--- /dev/null
+++ b/oedb/resources/demo/static/traffic.css
@@ -0,0 +1,99 @@
+/* Styles spécifiques à la page /demo/traffic */
+
+.user-info-panel {
+ background-color: #f5f5f5;
+ border-radius: 4px;
+ padding: 10px;
+ margin: 10px 0;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.user-info-panel h3 {
+ margin-top: 0;
+ margin-bottom: 10px;
+ color: #333;
+}
+
+.user-info-panel p {
+ margin: 5px 0;
+}
+
+.user-points {
+ font-weight: bold;
+ color: #0078ff;
+}
+
+.reality-check {
+ margin-top: 10px;
+ padding: 10px;
+ background-color: #fff3e0;
+ border-radius: 4px;
+}
+
+.reality-check-buttons {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 8px;
+}
+
+.confirm-btn, .deny-btn {
+ padding: 5px 10px;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: bold;
+}
+
+.confirm-btn {
+ background-color: #4caf50;
+ color: white;
+}
+
+.deny-btn {
+ background-color: #f44336;
+ color: white;
+}
+
+.reality-check-info {
+ margin-top: 10px;
+ padding: 8px;
+ background-color: #e8f5e9;
+ border-radius: 4px;
+ font-size: 0.9em;
+}
+
+#photoPreviewContainer {
+ margin-top: 8px;
+ display: none;
+}
+
+#photoPreview {
+ max-width: 100%;
+ border-radius: 4px;
+}
+
+.camera-block {
+ margin-top: 12px;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ padding: 10px;
+}
+.camera-block .note { font-size: 12px; color: #666; }
+.camera-controls {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+.camera-preview video {
+ width: 100%;
+ max-height: 260px;
+ background: #000;
+ border-radius: 4px;
+}
+.gps-status {
+ margin-left: 8px;
+ font-size: 12px;
+ color: #555;
+}
+
diff --git a/oedb/resources/demo/static/traffic.js b/oedb/resources/demo/static/traffic.js
new file mode 100644
index 0000000..a36aea1
--- /dev/null
+++ b/oedb/resources/demo/static/traffic.js
@@ -0,0 +1,676 @@
+// Logique JavaScript spécifique à /demo/traffic
+
+// Variables globales faibles (map, marker)
+let map;
+let marker;
+let existingMarkers = [];
+const PANORAMAX_TOKEN_STORAGE_KEY = 'oedb_panoramax_token';
+let mediaStream = null;
+
+function setDefaultDates() {
+ const now = new Date();
+ const nowISO = now.toISOString().slice(0, 16);
+ document.getElementById('start').value = nowISO;
+ const sixHoursLater = new Date(now.getTime() + 6 * 60 * 60 * 1000);
+ document.getElementById('stop').value = sixHoursLater.toISOString().slice(0, 16);
+}
+
+function initTabs() {
+ const tabItems = document.querySelectorAll('.tab-item');
+ tabItems.forEach(tab => {
+ tab.addEventListener('click', function() {
+ tabItems.forEach(item => item.classList.remove('active'));
+ this.classList.add('active');
+ const tabName = this.getAttribute('data-tab');
+ document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('active'));
+ document.getElementById(tabName + '-tab').classList.add('active');
+ localStorage.setItem('activeTab', tabName);
+ });
+ });
+ const activeTab = localStorage.getItem('activeTab');
+ if (activeTab) {
+ const tabItem = document.querySelector(`.tab-item[data-tab="${activeTab}"]`);
+ if (tabItem) tabItem.click();
+ }
+}
+
+function initMap() {
+ map = new maplibregl.Map({
+ container: 'map',
+ style: 'https://demotiles.maplibre.org/style.json',
+ center: [2.2137, 46.2276],
+ zoom: 5
+ });
+ map.addControl(new maplibregl.NavigationControl());
+ marker = new maplibregl.Marker({ draggable: true, color: '#ff3860' });
+ map.on('load', fetchExistingTrafficEvents);
+ map.on('click', function(e) {
+ marker.setLngLat(e.lngLat).addTo(map);
+ setTimeout(validateForm, 100);
+ });
+}
+
+function setGpsStatus(text, ok = null) {
+ const el = document.getElementById('gpsStatus');
+ if (!el) return;
+ el.textContent = `GPS: ${text}`;
+ if (ok === true) { el.style.color = '#2e7d32'; }
+ else if (ok === false) { el.style.color = '#c62828'; }
+ else { el.style.color = '#555'; }
+}
+
+function fetchExistingTrafficEvents() {
+ existingMarkers.forEach(m => m.remove());
+ existingMarkers = [];
+ fetch('https://api.openeventdatabase.org/event?what=traffic')
+ .then(r => { if (!r.ok) throw new Error('Failed to fetch existing traffic events'); return r.json(); })
+ .then(data => {
+ if (!data || !Array.isArray(data.features)) return;
+ data.features.forEach(event => {
+ 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 popupContent = `\n${event.properties.label || 'Traffic Event'}
\nType: ${event.properties.what || 'Unknown'}
\nStart: ${event.properties.start || 'Unknown'}
\nEnd: ${event.properties.stop || 'Unknown'}
`;
+ if (needsRealityCheck) {
+ popupContent += `\n\n
Is this traffic event still present?
\n
\n\n\n
\n
`;
+ } else if (event.properties['reality_check']) {
+ popupContent += `\n\n
Reality check: ${event.properties['reality_check']}
\n
`;
+ }
+ em.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(popupContent));
+ existingMarkers.push(em);
+ }
+ });
+ });
+}
+
+function checkIfNeedsRealityCheck(event) {
+ if (event.properties['reality_check']) return false;
+ if (!event.properties.what || !event.properties.what.startsWith('traffic')) return false;
+ const createDate = event.properties.createdate;
+ if (!createDate) return false;
+ const createTime = new Date(createDate).getTime();
+ const currentTime = new Date().getTime();
+ return (currentTime - createTime) > (60 * 60 * 1000);
+}
+
+function fillForm(issueType) {
+ const labelInput = document.getElementById('label');
+ const issueTypeInput = document.getElementById('issueType');
+ const severitySelect = document.getElementById('severity');
+ let currentLngLat = marker.getLngLat ? marker.getLngLat() : null;
+ marker.remove();
+ let markerColor = '#ff3860';
+ switch(issueType) {
+ case 'bike_obstacle':
+ labelInput.value = 'Obstacle vélo';
+ issueTypeInput.value = 'mobility.cycling.obstacle';
+ severitySelect.value = 'medium';
+ markerColor = '#388e3c';
+ break;
+ case 'illegal_dumping':
+ labelInput.value = 'Décharge sauvage';
+ issueTypeInput.value = 'environment.dumping.illegal';
+ severitySelect.value = 'medium';
+ markerColor = '#795548';
+ break;
+ case 'pothole':
+ labelInput.value = 'Nid de poule';
+ issueTypeInput.value = 'traffic.hazard.pothole';
+ severitySelect.value = 'medium';
+ markerColor = '#ff9800';
+ break;
+ case 'obstacle':
+ labelInput.value = 'Obstacle';
+ issueTypeInput.value = 'traffic.hazard.obstacle';
+ severitySelect.value = 'high';
+ markerColor = '#f44336';
+ break;
+ case 'vehicle':
+ labelInput.value = 'Véhicule sur le bas côté de la route';
+ issueTypeInput.value = 'traffic.hazard.vehicle';
+ severitySelect.value = 'low';
+ markerColor = '#2196f3';
+ break;
+ case 'danger':
+ labelInput.value = 'Danger non classé';
+ issueTypeInput.value = 'traffic.hazard.danger';
+ severitySelect.value = 'high';
+ markerColor = '#9c27b0';
+ break;
+ case 'emergency_alert':
+ labelInput.value = "Alerte d'urgence (SAIP)";
+ issueTypeInput.value = 'alert.emergency';
+ severitySelect.value = 'high';
+ markerColor = '#e91e63';
+ break;
+ case 'daylight_saving':
+ labelInput.value = "Période d'heure d'été";
+ issueTypeInput.value = 'time.daylight.summer';
+ severitySelect.value = 'low';
+ markerColor = '#ffc107';
+ break;
+ case 'accident':
+ labelInput.value = 'Accident de la route';
+ issueTypeInput.value = 'traffic.accident';
+ severitySelect.value = 'high';
+ markerColor = '#d32f2f';
+ break;
+ case 'flooded_road':
+ labelInput.value = 'Route inondée';
+ issueTypeInput.value = 'traffic.closed.flood';
+ severitySelect.value = 'high';
+ markerColor = '#1976d2';
+ break;
+ case 'black_traffic':
+ labelInput.value = 'Période noire bison futé vers la province';
+ issueTypeInput.value = 'traffic.forecast.black.out';
+ severitySelect.value = 'high';
+ markerColor = '#212121';
+ break;
+ case 'roadwork':
+ labelInput.value = 'Travaux';
+ issueTypeInput.value = 'traffic.roadwork';
+ severitySelect.value = 'medium';
+ markerColor = '#ff5722';
+ break;
+ case 'flood_danger':
+ labelInput.value = 'Vigilance rouge inondation';
+ issueTypeInput.value = 'weather.danger.flood';
+ severitySelect.value = 'high';
+ markerColor = '#b71c1c';
+ break;
+ case 'thunderstorm_alert':
+ labelInput.value = 'Vigilance orange orages';
+ issueTypeInput.value = 'weather.alert.thunderstorm';
+ severitySelect.value = 'medium';
+ markerColor = '#ff9800';
+ break;
+ case 'fog_warning':
+ labelInput.value = 'Vigilance jaune brouillard';
+ issueTypeInput.value = 'weather.warning.fog';
+ severitySelect.value = 'low';
+ markerColor = '#ffeb3b';
+ break;
+ case 'unattended_luggage':
+ labelInput.value = 'Bagage abandonné';
+ issueTypeInput.value = 'public_transport.incident.unattended_luggage';
+ severitySelect.value = 'medium';
+ markerColor = '#673ab7';
+ break;
+ case 'transport_delay':
+ labelInput.value = 'Retard';
+ issueTypeInput.value = 'public_transport.delay';
+ severitySelect.value = 'low';
+ markerColor = '#ffc107';
+ break;
+ case 'major_transport_delay':
+ labelInput.value = 'Retard important';
+ issueTypeInput.value = 'public_transport.delay.major';
+ severitySelect.value = 'medium';
+ markerColor = '#ff5722';
+ break;
+ default:
+ labelInput.value = 'Bouchon';
+ issueTypeInput.value = 'traffic.jam';
+ severitySelect.value = 'medium';
+ markerColor = '#ff3860';
+ }
+ marker = new maplibregl.Marker({ draggable: true, color: markerColor });
+ if (currentLngLat) marker.setLngLat(currentLngLat).addTo(map);
+ validateForm();
+}
+
+document.getElementById('geolocateBtn').addEventListener('click', function() {
+ document.getElementById('geolocateSpinner').style.display = 'inline-block';
+ this.disabled = true;
+ if (navigator.geolocation) {
+ navigator.geolocation.getCurrentPosition(function(position) {
+ const lat = position.coords.latitude;
+ const lng = position.coords.longitude;
+ marker.setLngLat([lng, lat]).addTo(map);
+ map.flyTo({ center: [lng, lat], zoom: 14 });
+ document.getElementById('geolocateSpinner').style.display = 'none';
+ document.getElementById('geolocateBtn').disabled = false;
+ showResult('Current location detected successfully', 'success');
+ setGpsStatus('actif', true);
+ validateForm();
+ fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`)
+ .then(r => r.json())
+ .then(data => {
+ if (data && data.address) {
+ let location = '';
+ if (data.address.road) {
+ location = data.address.road;
+ if (data.address.city) location += `, ${data.address.city}`;
+ } else if (data.address.suburb) {
+ location = data.address.suburb;
+ if (data.address.city) location += `, ${data.address.city}`;
+ }
+ if (location) document.getElementById('where').value = location;
+ }
+ });
+ }, function(error) {
+ document.getElementById('geolocateSpinner').style.display = 'none';
+ document.getElementById('geolocateBtn').disabled = false;
+ let msg = 'Unable to get your location. ';
+ switch(error.code) {
+ case error.PERMISSION_DENIED: msg += 'You denied the request for geolocation.'; break;
+ case error.POSITION_UNAVAILABLE: msg += 'Location information is unavailable.'; break;
+ case error.TIMEOUT: msg += 'The request to get your location timed out.'; break;
+ default: msg += 'An unknown error occurred.';
+ }
+ showResult(msg, 'error');
+ setGpsStatus('inactif', false);
+ }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 });
+ } else {
+ document.getElementById('geolocateSpinner').style.display = 'none';
+ document.getElementById('geolocateBtn').disabled = false;
+ showResult('Geolocation is not supported by your browser', 'error');
+ setGpsStatus('non supporté', false);
+ }
+});
+
+function validateForm() {
+ const requiredFields = document.querySelectorAll('#trafficForm [required]');
+ const submitButton = document.getElementById('report_issue_button');
+ let isValid = true;
+ requiredFields.forEach(field => { if (!field.value.trim()) isValid = false; });
+ if (!marker || !marker.getLngLat()) isValid = false;
+ submitButton.disabled = !isValid;
+ if (isValid) submitButton.classList.remove('disabled'); else submitButton.classList.add('disabled');
+ return isValid;
+}
+
+document.addEventListener('DOMContentLoaded', function() {
+ const formFields = document.querySelectorAll('#trafficForm input, #trafficForm select');
+ formFields.forEach(f => { f.addEventListener('input', validateForm); f.addEventListener('change', validateForm); });
+ validateForm();
+
+ // Charger token panoramax depuis localStorage
+ const stored = localStorage.getItem(PANORAMAX_TOKEN_STORAGE_KEY);
+ if (stored) {
+ const input = document.getElementById('panoramaxTokenInput');
+ if (input) {
+ input.value = stored;
+ input.style.display = 'none';
+ const label = document.querySelector("label[for='panoramaxTokenInput']");
+ if (label) label.style.display = 'none';
+ }
+ const saveBtn = document.getElementById('savePanoramaxTokenBtn');
+ const showBtn = document.getElementById('showPanoramaxTokenBtn');
+ if (saveBtn) saveBtn.style.display = 'none';
+ if (showBtn) showBtn.style.display = '';
+ }
+ const saveBtn = document.getElementById('savePanoramaxTokenBtn');
+ if (saveBtn) {
+ saveBtn.addEventListener('click', function() {
+ const val = document.getElementById('panoramaxTokenInput')?.value || '';
+ if (val) {
+ localStorage.setItem(PANORAMAX_TOKEN_STORAGE_KEY, val);
+ showResult('Token Panoramax enregistré localement', 'success');
+ // Masquer champ + bouton save, afficher bouton show
+ const input = document.getElementById('panoramaxTokenInput');
+ if (input) input.style.display = 'none';
+ const label = document.querySelector("label[for='panoramaxTokenInput']");
+ if (label) label.style.display = 'none';
+ saveBtn.style.display = 'none';
+ const showBtn = document.getElementById('showPanoramaxTokenBtn');
+ if (showBtn) showBtn.style.display = '';
+ } else {
+ localStorage.removeItem(PANORAMAX_TOKEN_STORAGE_KEY);
+ showResult('Token Panoramax supprimé du stockage local', 'success');
+ }
+ });
+ }
+ const showBtn = document.getElementById('showPanoramaxTokenBtn');
+ if (showBtn) {
+ showBtn.addEventListener('click', function() {
+ const input = document.getElementById('panoramaxTokenInput');
+ const label = document.querySelector("label[for='panoramaxTokenInput']");
+ if (input) input.style.display = '';
+ if (label) label.style.display = '';
+ const saveBtn = document.getElementById('savePanoramaxTokenBtn');
+ if (saveBtn) saveBtn.style.display = '';
+ showBtn.style.display = 'none';
+ });
+ }
+ // État GPS initial
+ setGpsStatus('inconnu');
+});
+
+// Aperçu photo
+const photoInput = document.getElementById('photo');
+if (photoInput) {
+ photoInput.addEventListener('change', function() {
+ const file = this.files && this.files[0];
+ const ctn = document.getElementById('photoPreviewContainer');
+ if (!file) { ctn.style.display = 'none'; return; }
+ const url = URL.createObjectURL(file);
+ const img = document.getElementById('photoPreview');
+ img.src = url;
+ ctn.style.display = 'block';
+ });
+}
+
+async function readExifGps(file) {
+ // Lecture minimale EXIF pour récupérer GPSLatitude/GPSLongitude si présents
+ try {
+ const buffer = await file.arrayBuffer();
+ const view = new DataView(buffer);
+ // Vérifier JPEG
+ if (view.getUint16(0, false) !== 0xFFD8) return null;
+ let offset = 2;
+ const length = view.byteLength;
+ while (offset < length) {
+ if (view.getUint16(offset, false) === 0xFFE1) { // APP1
+ const app1Len = view.getUint16(offset + 2, false);
+ // "Exif\0\0"
+ if (view.getUint32(offset + 4, false) === 0x45786966 && view.getUint16(offset + 8, false) === 0x0000) {
+ let tiffOffset = offset + 10;
+ const little = view.getUint16(tiffOffset, false) === 0x4949; // 'II'
+ const getU16 = (pos) => view.getUint16(pos, little);
+ const getU32 = (pos) => view.getUint32(pos, little);
+ const firstIFDOffset = getU32(tiffOffset + 4) + tiffOffset;
+ // Parcourir 0th IFD pour trouver GPS IFD pointer (tag 0x8825)
+ const entries = getU16(firstIFDOffset);
+ let gpsIFDPointer = 0;
+ for (let i = 0; i < entries; i++) {
+ const entryOffset = firstIFDOffset + 2 + i * 12;
+ const tag = getU16(entryOffset);
+ if (tag === 0x8825) { // GPSInfoIFDPointer
+ gpsIFDPointer = getU32(entryOffset + 8) + tiffOffset;
+ break;
+ }
+ }
+ if (!gpsIFDPointer) return null;
+ const gpsCount = getU16(gpsIFDPointer);
+ let latRef = 'N', lonRef = 'E';
+ let latVals = null, lonVals = null;
+ const readRational = (pos) => {
+ const num = getU32(pos);
+ const den = getU32(pos + 4);
+ return den ? (num / den) : 0;
+ };
+ for (let i = 0; i < gpsCount; i++) {
+ const eOff = gpsIFDPointer + 2 + i * 12;
+ const tag = getU16(eOff);
+ const type = getU16(eOff + 2);
+ const count = getU32(eOff + 4);
+ let valueOffset = eOff + 8;
+ let valuePtr = getU32(valueOffset) + tiffOffset;
+ if (tag === 0x0001) { // GPSLatitudeRef
+ const c = view.getUint8(valueOffset);
+ latRef = String.fromCharCode(c);
+ } else if (tag === 0x0002 && type === 5 && count === 3) { // GPSLatitude
+ latVals = [readRational(valuePtr), readRational(valuePtr + 8), readRational(valuePtr + 16)];
+ } else if (tag === 0x0003) { // GPSLongitudeRef
+ const c = view.getUint8(valueOffset);
+ lonRef = String.fromCharCode(c);
+ } else if (tag === 0x0004 && type === 5 && count === 3) { // GPSLongitude
+ lonVals = [readRational(valuePtr), readRational(valuePtr + 8), readRational(valuePtr + 16)];
+ }
+ }
+ if (!latVals || !lonVals) return null;
+ const toDecimal = (dms) => dms[0] + dms[1] / 60 + dms[2] / 3600;
+ let lat = toDecimal(latVals);
+ let lng = toDecimal(lonVals);
+ if (latRef === 'S') lat = -lat;
+ if (lonRef === 'W') lng = -lng;
+ return { lat, lng };
+ }
+ offset += 2 + app1Len;
+ } else if ((view.getUint16(offset, false) & 0xFFF0) === 0xFFE0) {
+ const segLen = view.getUint16(offset + 2, false);
+ offset += 2 + segLen;
+ } else {
+ break;
+ }
+ }
+ return null;
+ } catch (e) {
+ return null;
+ }
+}
+
+async function uploadPhotoIfConfigured(file, lng, lat, isoDatetime) {
+ try {
+ const uploadUrl = document.getElementById('panoramaxUploadUrl')?.value || '';
+ // Priorité au token utilisateur (input/localStorage), sinon fallback hidden server
+ const token = (document.getElementById('panoramaxTokenInput')?.value || localStorage.getItem(PANORAMAX_TOKEN_STORAGE_KEY) || document.getElementById('panoramaxToken')?.value || '');
+ if (!uploadUrl || !file) return null;
+ // Exiger EXIF GPS
+ const exifLoc = await readExifGps(file);
+ if (!exifLoc) {
+ showResult("La photo n'a pas de géolocalisation EXIF, envoi Panoramax interdit.", 'error');
+ return null;
+ }
+ const form = new FormData();
+ form.append('file', file, file.name || 'photo.jpg');
+ // Utiliser la géolocalisation EXIF uniquement
+ form.append('lon', String(exifLoc.lng));
+ form.append('lat', String(exifLoc.lat));
+ if (isoDatetime) form.append('datetime', isoDatetime);
+ const headers = {};
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+ const res = await fetch(uploadUrl, { method: 'POST', headers, body: form });
+ if (!res.ok) { throw new Error(await res.text() || `Upload failed (${res.status})`); }
+ const data = await res.json().catch(() => ({}));
+ return { id: data.id || data.uuid || data.photo_id || null, url: data.url || data.permalink || data.link || null, raw: data };
+ } catch (err) {
+ console.error('Panoramax upload error:', err);
+ showResult(`Erreur upload photo: ${err.message}`, 'error');
+ return null;
+ }
+}
+
+document.getElementById('trafficForm').addEventListener('submit', async function(e) {
+ e.preventDefault();
+ if (!validateForm()) { showResult('Please fill in all required fields and set a location on the map', 'error'); return; }
+ const label = document.getElementById('label').value;
+ const issueType = document.getElementById('issueType').value;
+ const severity = document.getElementById('severity').value;
+ const cause = document.getElementById('cause').value;
+ const start = document.getElementById('start').value;
+ const stop = document.getElementById('stop').value;
+ const where = document.getElementById('where').value;
+ const lngLat = marker.getLngLat();
+ const event = { type: 'Feature', geometry: { type: 'Point', coordinates: [lngLat.lng, lngLat.lat] }, properties: { label, type: 'unscheduled', what: issueType, 'issue:severity': severity, start, stop } };
+ if (cause) event.properties['issue:details'] = cause;
+ if (where) event.properties.where = where;
+ let osmUsernameValue = '';
+ const osmUsername = document.getElementById('osmUsername');
+ if (osmUsername && osmUsername.value) osmUsernameValue = osmUsername.value;
+ if (window.osmAuth && osmAuth.isUserAuthenticated()) osmUsernameValue = osmAuth.getUsername();
+ if (osmUsernameValue) event.properties['reporter:osm'] = osmUsernameValue;
+ let photoInfo = null;
+ const photoFile = (photoInput && photoInput.files && photoInput.files[0]) ? photoInput.files[0] : null;
+ if (photoFile) {
+ photoInfo = await uploadPhotoIfConfigured(photoFile, lngLat.lng, lngLat.lat, start);
+ if (photoInfo) {
+ event.properties['photo:service'] = 'panoramax';
+ if (photoInfo.id) {
+ event.properties['photo:id'] = String(photoInfo.id);
+ // Tag panoramax (uuid)
+ event.properties['panoramax'] = String(photoInfo.id);
+ }
+ if (photoInfo.url) event.properties['photo:url'] = photoInfo.url;
+ }
+ }
+ saveEventToLocalStorage(event);
+ fetch('https://api.openeventdatabase.org/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(event) })
+ .then(response => { if (response.ok) return response.json(); return response.text().then(text => { throw new Error(text || response.statusText); }); })
+ .then(data => {
+ if (data.id) updateEventInLocalStorage(event, data.id);
+ showResult(`Issue reported successfully with ID: ${data.id}`, 'success');
+ const resultElement = document.getElementById('result');
+ resultElement.innerHTML += `\n\nView Report on Server |\nView Saved Reports |\nBack to Map\n
`;
+ document.getElementById('trafficForm').reset();
+ setDefaultDates();
+ marker.remove();
+ fetchExistingTrafficEvents();
+ })
+ .catch(error => { showResult(`Error reporting issue: ${error.message}`, 'error'); });
+});
+
+function saveEventToLocalStorage(event) {
+ let savedEvents = JSON.parse(localStorage.getItem('oedb_events') || '[]');
+ event.timestamp = new Date().toISOString();
+ savedEvents.push(event);
+ localStorage.setItem('oedb_events', JSON.stringify(savedEvents));
+}
+
+function updateEventInLocalStorage(event, serverId) {
+ let savedEvents = JSON.parse(localStorage.getItem('oedb_events') || '[]');
+ const eventIndex = savedEvents.findIndex(e => e.timestamp === event.timestamp && e.geometry.coordinates[0] === event.geometry.coordinates[0] && e.geometry.coordinates[1] === event.geometry.coordinates[1]);
+ if (eventIndex !== -1) {
+ savedEvents[eventIndex].properties.id = serverId;
+ localStorage.setItem('oedb_events', JSON.stringify(savedEvents));
+ }
+}
+
+function showResult(message, type) {
+ const resultElement = document.getElementById('result');
+ resultElement.textContent = message;
+ resultElement.className = type;
+ resultElement.style.display = 'block';
+ resultElement.scrollIntoView({ behavior: 'smooth' });
+}
+
+function confirmEvent(eventId, isConfirmed) {
+ let username = localStorage.getItem('oedb_username');
+ if (!username) {
+ username = promptForUsername();
+ if (!username) return;
+ }
+ const now = new Date();
+ const dateTimeString = now.toISOString();
+ const realityCheckStatus = isConfirmed ? 'confirmed' : 'not confirmed';
+ const realityCheckValue = `${dateTimeString} | ${username} | ${realityCheckStatus}`;
+ fetch(`https://api.openeventdatabase.org/event/${eventId}`)
+ .then(r => { if (!r.ok) throw new Error(`Failed to fetch event ${eventId}`); return r.json(); })
+ .then(event => {
+ event.properties['reality_check'] = realityCheckValue;
+ return fetch(`https://api.openeventdatabase.org/event/${eventId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(event) });
+ })
+ .then(r => { if (!r.ok) throw new Error('Failed to update event'); saveContribution(eventId, isConfirmed); awardPoints(3); showResult(`Thank you for your contribution! You've earned 3 points.`, 'success'); updateUserInfoDisplay(); fetchExistingTrafficEvents(); })
+ .catch(err => { console.error('Error updating event:', err); showResult(`Error: ${err.message}`, 'error'); });
+}
+
+function promptForUsername() {
+ const username = prompt('Please enter your username:');
+ if (username) { localStorage.setItem('oedb_username', username); return username; }
+ return null;
+}
+
+function saveContribution(eventId, isConfirmed) {
+ let contributions = JSON.parse(localStorage.getItem('oedb_contributions') || '[]');
+ contributions.push({ eventId, timestamp: new Date().toISOString(), isConfirmed });
+ localStorage.setItem('oedb_contributions', JSON.stringify(contributions));
+}
+
+function awardPoints(points) {
+ let currentPoints = parseInt(localStorage.getItem('oedb_points') || '0');
+ currentPoints += points;
+ localStorage.setItem('oedb_points', currentPoints.toString());
+}
+
+function updateUserInfoDisplay() {
+ const username = localStorage.getItem('oedb_username') || 'Anonymous';
+ const points = localStorage.getItem('oedb_points') || '0';
+ let userInfoPanel = document.getElementById('user-info-panel');
+ if (!userInfoPanel) {
+ userInfoPanel = document.createElement('div');
+ userInfoPanel.id = 'user-info-panel';
+ userInfoPanel.className = 'user-info-panel';
+ const navLinks = document.querySelector('.nav-links');
+ navLinks.parentNode.insertBefore(userInfoPanel, navLinks.nextSibling);
+ }
+ userInfoPanel.innerHTML = `\nUser Information
\nUsername: ${username}
\nPoints: ${points}
`;
+}
+
+document.addEventListener('DOMContentLoaded', function() {
+ setDefaultDates();
+ initTabs();
+ initMap();
+ updateUserInfoDisplay();
+});
+
+// Contrôles Caméra
+const startCameraBtn = document.getElementById('startCameraBtn');
+const capturePhotoBtn = document.getElementById('capturePhotoBtn');
+const stopCameraBtn = document.getElementById('stopCameraBtn');
+const cameraVideo = document.getElementById('cameraVideo');
+const cameraCanvas = document.getElementById('cameraCanvas');
+
+async function startCamera() {
+ try {
+ mediaStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
+ cameraVideo.srcObject = mediaStream;
+ capturePhotoBtn.disabled = false;
+ stopCameraBtn.disabled = false;
+ startCameraBtn.disabled = true;
+ } catch (e) {
+ showResult(`Impossible d'accéder à la caméra: ${e.message}`, 'error');
+ }
+}
+
+function stopCamera() {
+ if (mediaStream) {
+ mediaStream.getTracks().forEach(t => t.stop());
+ mediaStream = null;
+ }
+ cameraVideo.srcObject = null;
+ capturePhotoBtn.disabled = true;
+ stopCameraBtn.disabled = true;
+ startCameraBtn.disabled = false;
+}
+
+function dataURLToFile(dataUrl, filename) {
+ const arr = dataUrl.split(',');
+ const mime = arr[0].match(/:(.*?);/)[1];
+ const bstr = atob(arr[1]);
+ let n = bstr.length;
+ const u8arr = new Uint8Array(n);
+ while (n--) { u8arr[n] = bstr.charCodeAt(n); }
+ return new File([u8arr], filename, { type: mime });
+}
+
+function capturePhoto() {
+ try {
+ const width = cameraVideo.videoWidth;
+ const height = cameraVideo.videoHeight;
+ if (!width || !height) { showResult('Vidéo non prête', 'error'); return; }
+ cameraCanvas.width = width;
+ cameraCanvas.height = height;
+ const ctx = cameraCanvas.getContext('2d');
+ ctx.drawImage(cameraVideo, 0, 0, width, height);
+ const dataUrl = cameraCanvas.toDataURL('image/jpeg', 0.92);
+ const file = dataURLToFile(dataUrl, 'camera_capture.jpg');
+ // Remplit le file input pour réutiliser le flux existant
+ const dt = new DataTransfer();
+ dt.items.add(file);
+ const input = document.getElementById('photo');
+ input.files = dt.files;
+ // Déclenche l’aperçu
+ const url = URL.createObjectURL(file);
+ const img = document.getElementById('photoPreview');
+ img.src = url;
+ document.getElementById('photoPreviewContainer').style.display = 'block';
+ showResult('Photo capturée depuis la caméra', 'success');
+ } catch (e) {
+ showResult(`Échec capture photo: ${e.message}`, 'error');
+ }
+}
+
+if (startCameraBtn && capturePhotoBtn && stopCameraBtn) {
+ startCameraBtn.addEventListener('click', startCamera);
+ capturePhotoBtn.addEventListener('click', capturePhoto);
+ stopCameraBtn.addEventListener('click', stopCamera);
+}
+
+// Expose functions used in inline HTML popups
+window.fillForm = fillForm;
+window.confirmEvent = confirmEvent;
+
diff --git a/oedb/resources/demo/templates/partials/demo_nav.html b/oedb/resources/demo/templates/partials/demo_nav.html
new file mode 100644
index 0000000..5d4a115
--- /dev/null
+++ b/oedb/resources/demo/templates/partials/demo_nav.html
@@ -0,0 +1,8 @@
+
+
diff --git a/oedb/resources/demo/templates/stats.html b/oedb/resources/demo/templates/stats.html
new file mode 100644
index 0000000..59cac20
--- /dev/null
+++ b/oedb/resources/demo/templates/stats.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+ Stats par type - OpenEventDatabase
+
+
+
+
+
+ {% include 'partials/demo_nav.html' %}
+
Statistiques par type d'évènement (what)
+
Total: {{ total_events }} évènements
+
+
+
+ Type (what) |
+ Nombre |
+ Actions |
+
+
+
+ {% for what, count in counts %}
+
+ {{ what }} |
+ {{ count }} |
+
+ Voir JSON
+ Voir sur la carte
+ |
+
+ {% endfor %}
+
+
+
+ {% if selected_what %}
+
Carte: {{ selected_what }}
+
+
+
+
+ {% endif %}
+
+
+
+
diff --git a/oedb/resources/demo/templates/traffic.html b/oedb/resources/demo/templates/traffic.html
index ce614dd..55a653f 100644
--- a/oedb/resources/demo/templates/traffic.html
+++ b/oedb/resources/demo/templates/traffic.html
@@ -8,16 +8,13 @@
+
+
-
+ {% include 'partials/demo_nav.html' %}
Report Road Issue
@@ -25,6 +22,9 @@
+
+
+
@@ -46,17 +46,15 @@
{% endif %}
@@ -67,16 +65,19 @@
- Road
+ Route
Rail
- Weather
+ Météo
- Emergency
+ Urgences
+
+
+ Cycles
@@ -95,7 +96,7 @@
- Vehicle on Side
+ Véhicule sur le bas côté de la route
@@ -107,15 +108,15 @@
- Flooded Road
+ Route inondée
- Roadwork
+ Travaux
- Black Traffic
+ journée noire bison futé
@@ -125,15 +126,15 @@
@@ -143,15 +144,15 @@
@@ -161,11 +162,25 @@
+
+
+
+
@@ -173,12 +188,45 @@
+ GPS: inconnu
- View All Saved Events on Map
+ Voir tous les événements enregistrés sur la carte
@@ -242,9 +290,9 @@
// Set start time to current time
document.getElementById('start').value = nowISO;
- // Set end time to current time + 1 hour
- const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
- document.getElementById('stop').value = oneHourLater.toISOString().slice(0, 16);
+ // Set end time to current time + 6 hours (durée par défaut des signalements)
+ const sixHoursLater = new Date(now.getTime() + 6 * 60 * 60 * 1000);
+ document.getElementById('stop').value = sixHoursLater.toISOString().slice(0, 16);
}
// Call function to set default dates
@@ -442,6 +490,18 @@
let markerColor = '#ff3860'; // Default red color
switch(issueType) {
+ case 'bike_obstacle':
+ labelInput.value = 'Obstacle vélo';
+ issueTypeInput.value = 'mobility.cycling.obstacle';
+ severitySelect.value = 'medium';
+ markerColor = '#388e3c'; // Green
+ break;
+ case 'illegal_dumping':
+ labelInput.value = 'Décharge sauvage';
+ issueTypeInput.value = 'environment.dumping.illegal';
+ severitySelect.value = 'medium';
+ markerColor = '#795548'; // Brown
+ break;
case 'pothole':
labelInput.value = 'Nid de poule';
issueTypeInput.value = 'traffic.hazard.pothole';
@@ -721,8 +781,64 @@
setTimeout(validateForm, 100);
});
+ // Photo preview
+ const photoInput = document.getElementById('photo');
+ if (photoInput) {
+ photoInput.addEventListener('change', function() {
+ const file = this.files && this.files[0];
+ if (!file) {
+ document.getElementById('photoPreviewContainer').style.display = 'none';
+ return;
+ }
+ const url = URL.createObjectURL(file);
+ const img = document.getElementById('photoPreview');
+ img.src = url;
+ document.getElementById('photoPreviewContainer').style.display = 'block';
+ });
+ }
+
+ async function uploadPhotoIfConfigured(file, lng, lat, isoDatetime) {
+ try {
+ const uploadUrl = document.getElementById('panoramaxUploadUrl')?.value || '';
+ const token = document.getElementById('panoramaxToken')?.value || '';
+ if (!uploadUrl || !file) {
+ return null; // pas configuré ou pas de fichier
+ }
+ const form = new FormData();
+ form.append('file', file, file.name || 'photo.jpg');
+ // Métadonnées géo/temps standard
+ if (typeof lng === 'number' && typeof lat === 'number') {
+ form.append('lon', String(lng));
+ form.append('lat', String(lat));
+ }
+ if (isoDatetime) {
+ form.append('datetime', isoDatetime);
+ }
+ const headers = {};
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+ const res = await fetch(uploadUrl, { method: 'POST', headers, body: form });
+ if (!res.ok) {
+ const t = await res.text();
+ throw new Error(t || `Upload failed (${res.status})`);
+ }
+ const data = await res.json().catch(() => ({}));
+ // On essaie de normaliser quelques champs courants
+ return {
+ id: data.id || data.uuid || data.photo_id || null,
+ url: data.url || data.permalink || data.link || null,
+ raw: data
+ };
+ } catch (err) {
+ console.error('Panoramax upload error:', err);
+ showResult(`Erreur upload photo: ${err.message}`, 'error');
+ return null;
+ }
+ }
+
// Handle form submission
- document.getElementById('trafficForm').addEventListener('submit', function(e) {
+ document.getElementById('trafficForm').addEventListener('submit', async function(e) {
e.preventDefault();
// Validate form before submission
@@ -795,6 +911,18 @@
console.log(`Including OSM username in report: ${osmUsernameValue}`);
}
+ // Upload photo to Panoramax si configuré
+ let photoInfo = null;
+ const photoFile = (photoInput && photoInput.files && photoInput.files[0]) ? photoInput.files[0] : null;
+ if (photoFile) {
+ photoInfo = await uploadPhotoIfConfigured(photoFile, lngLat.lng, lngLat.lat, start);
+ if (photoInfo) {
+ event.properties['photo:service'] = 'panoramax';
+ if (photoInfo.id) event.properties['photo:id'] = String(photoInfo.id);
+ if (photoInfo.url) event.properties['photo:url'] = photoInfo.url;
+ }
+ }
+
// Save event to localStorage
saveEventToLocalStorage(event);
diff --git a/oedb/resources/demo/templates/view_events.html b/oedb/resources/demo/templates/view_events.html
index e5b5c42..52a525f 100644
--- a/oedb/resources/demo/templates/view_events.html
+++ b/oedb/resources/demo/templates/view_events.html
@@ -28,11 +28,7 @@
Your Saved Events
-
-
+ {% include 'partials/demo_nav.html' %}