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'}

\n

Type: ${event.properties.what || 'Unknown'}

\n

Start: ${event.properties.start || 'Unknown'}

\n

End: ${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 = `\n

User Information

\n

Username: ${username}

\n

Points: ${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

+ + + + + + + + + + {% for what, count in counts %} + + + + + + {% endfor %} + +
Type (what)NombreActions
{{ what }}{{ count }} + Voir JSON + Voir sur la carte +
+ + {% 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 @@
- Unattended Luggage + Bagage abandonné
- Delay + Retard
- Major Delay + Retard important
@@ -143,15 +144,15 @@
- Flood Alert + Vigilance rouge inondation
- Thunderstorm + Vigilance orange orages
- Fog Warning + Vigilance jaune brouillard
@@ -161,11 +162,25 @@
- Emergency Alert + Alerte d'urgence (SAIP)
- Daylight Saving + Période d'heure d'été +
+
+ + + +
+
+
+ + Obstacle vélo +
+
+ + Décharge sauvage
@@ -173,12 +188,45 @@ + GPS: inconnu
- + + +
Prenez une photo géolocalisée de la situation (mobile recommandé)
+ +
+
+ + +
Stocké en local sur cet appareil. Utilisé pour envoyer la photo.
+
+
+ + +
+
+
+ +
+ + + +
+
+ + +
+
La photo capturée sera ajoutée au champ ci-dessus.
+
+
+
+
@@ -186,50 +234,50 @@
- +
- +
- +
- +
- +
-
Click on the map to set the issue location or use the "Get My Current Location" button
+
Cliquez sur la carte pour définir la localisation du problème ou utilisez le bouton "Obtenir ma position actuelle"
- +
- 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' %}