add panoramax token

This commit is contained in:
Tykayn 2025-09-22 11:44:25 +02:00 committed by tykayn
parent f66e5e3f7b
commit 1a3df2ed75
12 changed files with 1132 additions and 61 deletions

View file

@ -12,7 +12,7 @@ import falcon
# Import utility modules # Import utility modules
from oedb.utils.logging import logger 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 # Import middleware
from oedb.middleware.headers import HeaderMiddleware 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.stats import StatsResource
from oedb.resources.search import EventSearch from oedb.resources.search import EventSearch
from oedb.resources.root import root 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 from oedb.resources.event_form import event_form
def create_app(): def create_app():
@ -34,6 +34,9 @@ def create_app():
Returns: Returns:
falcon.App: The configured Falcon application. falcon.App: The configured Falcon application.
""" """
# Load environment variables from .env (if present)
load_env_from_file()
# Create the Falcon application with middleware # Create the Falcon application with middleware
logger.info("Initializing Falcon application") logger.info("Initializing Falcon application")
app = falcon.App(middleware=[ 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')) static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'oedb', 'resources', 'demo', 'static'))
app.add_static_route('/static/', static_dir) 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 # Check database connection before continuing
if not check_db_connection(): if not check_db_connection():
logger.error("Cannot start server - PostgreSQL database is not responding") 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/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/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/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") logger.success("Application initialized successfully")
return app return app

View file

@ -1536,8 +1536,8 @@ class DemoResource:
// Update event info // Update event info
document.getElementById('event-info').innerHTML = '<p>Loading events...</p>'; document.getElementById('event-info').innerHTML = '<p>Loading events...</p>';
// Fetch events from the API - using limit=1000 to get more events // Fetch events from the public API - using limit=1000 to get more events
fetch('/event?limit=1000') fetch('https://api.openeventdatabase.org/event?limit=1000')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.features && data.features.length > 0) { if (data.features && data.features.length > 0) {

View file

@ -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_main import demo_main
from oedb.resources.demo.demo_traffic import demo_traffic 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 from oedb.resources.demo.demo_view_events import demo_view_events
# Import DemoResource class from the original demo.py file # Import DemoResource class from the original demo.py file
@ -25,4 +26,4 @@ spec.loader.exec_module(demo_original)
demo = demo_original.demo demo = demo_original.demo
# Export the demo resources and the demo object # Export the demo resources and the demo object
__all__ = ['demo_main', 'demo_traffic', 'demo_view_events', 'demo'] __all__ = ['demo_main', 'demo_traffic', 'demo_view_events', 'demo_stats', 'demo']

View file

@ -492,14 +492,27 @@ class DemoMainResource:
// Function to check if an event needs a reality check (created more than 1 hour ago) // Function to check if an event needs a reality check (created more than 1 hour ago)
function checkIfNeedsRealityCheck(event) { function checkIfNeedsRealityCheck(event) {
// Skip if event already has a reality check
if (event.properties['reality_check']) {
// Check if the event is a traffic event return false;
}
// Only for traffic events
if (!event.properties.what || !event.properties.what.startsWith('traffic')) { 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; 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 // Function to fit map to events bounds
function fitMapToBounds(geojson) { function fitMapToBounds(geojson) {

View file

@ -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()

View file

@ -48,7 +48,7 @@ class DemoTrafficResource:
# Get OAuth2 configuration parameters # Get OAuth2 configuration parameters
client_id = os.getenv("CLIENT_ID", "") client_id = os.getenv("CLIENT_ID", "")
client_secret = os.getenv("CLIENT_SECRET", "") 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", "") client_redirect = os.getenv("CLIENT_REDIRECT", "")
# Check if we have an authorization code in the query parameters # Check if we have an authorization code in the query parameters
@ -114,7 +114,9 @@ class DemoTrafficResource:
client_authorizations=client_authorizations, client_authorizations=client_authorizations,
is_authenticated=is_authenticated, is_authenticated=is_authenticated,
osm_username=osm_username, 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 # Set the response body and status

View file

@ -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;
}

View file

@ -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<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>`;
} else if (event.properties['reality_check']) {
popupContent += `\n<div class="reality-check-info">\n<p>Reality check: ${event.properties['reality_check']}</p>\n</div>`;
}
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<p>\n<a href="https://api.openeventdatabase.org/event/${data.id}" >View Report on Server</a> |\n<a href="/demo/view-events" >View Saved Reports</a> |\n<a href="/demo">Back to Map</a>\n</p>`;
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<h3>User Information</h3>\n<p>Username: <strong>${username}</strong></p>\n<p>Points: <span class="user-points">${points}</span></p>`;
}
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 laperç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;

View file

@ -0,0 +1,8 @@
<div class="nav-links">
<a href="/demo">← Retour à la démo</a>
<a href="/demo/traffic">Signaler trafic</a>
<a href="/demo/view-events">Voir événements</a>
<a href="/demo/map-by-what">Carte par type</a>
<a href="/demo/stats">Stats</a>
</div>

View file

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stats par type - OpenEventDatabase</title>
<link rel="stylesheet" href="/static/demo_styles.css">
<style>
.container { max-width: 1100px; margin: 0 auto; background: #fff; padding: 16px; border-radius: 6px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px 10px; border-bottom: 1px solid #eee; text-align: left; }
th { background: #f9fafb; }
.actions a { margin-right: 8px; }
.map-embed { width: 100%; height: 360px; border: 1px solid #ddd; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
{% include 'partials/demo_nav.html' %}
<h1>Statistiques par type d'évènement (what)</h1>
<p>Total: <strong>{{ total_events }}</strong> évènements</p>
<table>
<thead>
<tr>
<th>Type (what)</th>
<th>Nombre</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for what, count in counts %}
<tr>
<td>{{ what }}</td>
<td>{{ count }}</td>
<td class="actions">
<a href="/event?what={{ what }}" target="_blank">Voir JSON</a>
<a href="/demo/stats?what={{ what }}#map">Voir sur la carte</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if selected_what %}
<h2 id="map">Carte: {{ selected_what }}</h2>
<link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.js"></script>
<div id="mapDiv" class="map-embed"></div>
<script>
const map = new maplibregl.Map({
container: 'mapDiv',
style: 'https://demotiles.maplibre.org/style.json',
center: [2.3522, 48.8566],
zoom: 4
});
map.addControl(new maplibregl.NavigationControl());
fetch('/event?what={{ selected_what }}&limit=500')
.then(r => r.json())
.then(data => {
if (!data.features || !data.features.length) return;
const bounds = new maplibregl.LngLatBounds();
data.features.forEach(f => {
if (f.geometry && f.geometry.type === 'Point') {
const c = f.geometry.coordinates;
new maplibregl.Marker().setLngLat(c).addTo(map);
bounds.extend(c);
}
});
if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
});
</script>
{% endif %}
</div>
</body>
</html>

View file

@ -8,16 +8,13 @@
<link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" /> <link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" />
<link rel="stylesheet" href="/static/demo_styles.css"> <link rel="stylesheet" href="/static/demo_styles.css">
<script defer src="https://use.fontawesome.com/releases/v5.15.4/js/all.js"></script> <script defer src="https://use.fontawesome.com/releases/v5.15.4/js/all.js"></script>
<link rel="stylesheet" href="/static/traffic.css">
<script src="/static/demo_auth.js"></script> <script src="/static/demo_auth.js"></script>
<script src="/static/traffic.js" defer></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="nav-links"> {% include 'partials/demo_nav.html' %}
<a href="/demo">&larr; Back to Map</a>
<a href="/">API Information</a>
<a href="/event">View Events</a>
<a href="/demo/view-events">View Saved Events</a>
</div>
<h1>Report Road Issue</h1> <h1>Report Road Issue</h1>
@ -25,6 +22,9 @@
<input type="hidden" id="osmClientId" value="{{ client_id }}"> <input type="hidden" id="osmClientId" value="{{ client_id }}">
<input type="hidden" id="osmClientSecret" value="{{ client_secret }}"> <input type="hidden" id="osmClientSecret" value="{{ client_secret }}">
<input type="hidden" id="osmRedirectUri" value="{{ client_redirect }}"> <input type="hidden" id="osmRedirectUri" value="{{ client_redirect }}">
<!-- Hidden Panoramax configuration (upload endpoint and token) -->
<input type="hidden" id="panoramaxUploadUrl" value="{{ panoramax_upload_url }}">
<input type="hidden" id="panoramaxToken" value="{{ panoramax_token }}">
<!-- Authentication section will be rendered by JavaScript or server-side --> <!-- Authentication section will be rendered by JavaScript or server-side -->
<div id="auth-section"> <div id="auth-section">
@ -46,18 +46,16 @@
{% endif %} {% endif %}
<script> <script>
// Replace server-side auth section with JavaScript-rendered version if available // Préserve l'affichage serveur si présent, sinon laisse traffic.js/dema_auth gérer
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (window.osmAuth) { const hasServerAuth = document.getElementById('osmUsername') && document.getElementById('osmUsername').value;
if (hasServerAuth) return;
if (window.osmAuth && osmAuth.renderAuthSection) {
const clientId = document.getElementById('osmClientId').value; const clientId = document.getElementById('osmClientId').value;
const redirectUri = document.getElementById('osmRedirectUri').value; const redirectUri = document.getElementById('osmRedirectUri').value;
const authSection = document.getElementById('auth-section'); 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); authSection.innerHTML = osmAuth.renderAuthSection(clientId, redirectUri);
} }
}
}); });
</script> </script>
</div> </div>
@ -67,16 +65,19 @@
<!-- Tab Navigation --> <!-- Tab Navigation -->
<div class="tabs"> <div class="tabs">
<div class="tab-item active" data-tab="road"> <div class="tab-item active" data-tab="road">
<i class="fas fa-road"></i> Road <i class="fas fa-road"></i> Route
</div> </div>
<div class="tab-item" data-tab="rail"> <div class="tab-item" data-tab="rail">
<i class="fas fa-train"></i> Rail <i class="fas fa-train"></i> Rail
</div> </div>
<div class="tab-item" data-tab="weather"> <div class="tab-item" data-tab="weather">
<i class="fas fa-cloud-sun-rain"></i> Weather <i class="fas fa-cloud-sun-rain"></i> Météo
</div> </div>
<div class="tab-item" data-tab="emergency"> <div class="tab-item" data-tab="emergency">
<i class="fas fa-exclamation-circle"></i> Emergency <i class="fas fa-exclamation-circle"></i> Urgences
</div>
<div class="tab-item" data-tab="civic">
<i class="fas fa-bicycle"></i> Cycles
</div> </div>
</div> </div>
@ -95,7 +96,7 @@
</div> </div>
<div class="issue-button road vehicle" onclick="fillForm('vehicle')"> <div class="issue-button road vehicle" onclick="fillForm('vehicle')">
<i class="fas fa-car"></i> <i class="fas fa-car"></i>
Vehicle on Side Véhicule sur le bas côté de la route
</div> </div>
<div class="issue-button road danger" onclick="fillForm('danger')"> <div class="issue-button road danger" onclick="fillForm('danger')">
<i class="fas fa-skull-crossbones"></i> <i class="fas fa-skull-crossbones"></i>
@ -107,15 +108,15 @@
</div> </div>
<div class="issue-button road flooded-road" onclick="fillForm('flooded_road')"> <div class="issue-button road flooded-road" onclick="fillForm('flooded_road')">
<i class="fas fa-water"></i> <i class="fas fa-water"></i>
Flooded Road Route inondée
</div> </div>
<div class="issue-button road roadwork" onclick="fillForm('roadwork')"> <div class="issue-button road roadwork" onclick="fillForm('roadwork')">
<i class="fas fa-hard-hat"></i> <i class="fas fa-hard-hat"></i>
Roadwork Travaux
</div> </div>
<div class="issue-button road black-traffic" onclick="fillForm('black_traffic')"> <div class="issue-button road black-traffic" onclick="fillForm('black_traffic')">
<i class="fas fa-traffic-light"></i> <i class="fas fa-traffic-light"></i>
Black Traffic journée noire bison futé
</div> </div>
</div> </div>
</div> </div>
@ -125,15 +126,15 @@
<div class="issue-buttons"> <div class="issue-buttons">
<div class="issue-button rail unattended-luggage" onclick="fillForm('unattended_luggage')"> <div class="issue-button rail unattended-luggage" onclick="fillForm('unattended_luggage')">
<i class="fas fa-suitcase"></i> <i class="fas fa-suitcase"></i>
Unattended Luggage Bagage abandonné
</div> </div>
<div class="issue-button rail transport-delay" onclick="fillForm('transport_delay')"> <div class="issue-button rail transport-delay" onclick="fillForm('transport_delay')">
<i class="fas fa-hourglass-half"></i> <i class="fas fa-hourglass-half"></i>
Delay Retard
</div> </div>
<div class="issue-button rail major-delay" onclick="fillForm('major_transport_delay')"> <div class="issue-button rail major-delay" onclick="fillForm('major_transport_delay')">
<i class="fas fa-hourglass-end"></i> <i class="fas fa-hourglass-end"></i>
Major Delay Retard important
</div> </div>
</div> </div>
</div> </div>
@ -143,15 +144,15 @@
<div class="issue-buttons"> <div class="issue-buttons">
<div class="issue-button weather flood-danger" onclick="fillForm('flood_danger')"> <div class="issue-button weather flood-danger" onclick="fillForm('flood_danger')">
<i class="fas fa-water"></i> <i class="fas fa-water"></i>
Flood Alert Vigilance rouge inondation
</div> </div>
<div class="issue-button weather thunderstorm" onclick="fillForm('thunderstorm_alert')"> <div class="issue-button weather thunderstorm" onclick="fillForm('thunderstorm_alert')">
<i class="fas fa-bolt"></i> <i class="fas fa-bolt"></i>
Thunderstorm Vigilance orange orages
</div> </div>
<div class="issue-button weather fog" onclick="fillForm('fog_warning')"> <div class="issue-button weather fog" onclick="fillForm('fog_warning')">
<i class="fas fa-smog"></i> <i class="fas fa-smog"></i>
Fog Warning Vigilance jaune brouillard
</div> </div>
</div> </div>
</div> </div>
@ -161,11 +162,25 @@
<div class="issue-buttons"> <div class="issue-buttons">
<div class="issue-button emergency emergency-alert" onclick="fillForm('emergency_alert')"> <div class="issue-button emergency emergency-alert" onclick="fillForm('emergency_alert')">
<i class="fas fa-exclamation-circle"></i> <i class="fas fa-exclamation-circle"></i>
Emergency Alert Alerte d'urgence (SAIP)
</div> </div>
<div class="issue-button emergency daylight-saving" onclick="fillForm('daylight_saving')"> <div class="issue-button emergency daylight-saving" onclick="fillForm('daylight_saving')">
<i class="fas fa-clock"></i> <i class="fas fa-clock"></i>
Daylight Saving Période d'heure d'été
</div>
</div>
</div>
<!-- Civic Tab -->
<div class="tab-pane" id="civic-tab">
<div class="issue-buttons">
<div class="issue-button civic bike-obstacle" onclick="fillForm('bike_obstacle')">
<i class="fas fa-bicycle"></i>
Obstacle vélo
</div>
<div class="issue-button civic illegal-dumping" onclick="fillForm('illegal_dumping')">
<i class="fas fa-trash"></i>
Décharge sauvage
</div> </div>
</div> </div>
</div> </div>
@ -173,12 +188,45 @@
<button id="geolocateBtn" class="geolocation-btn"> <button id="geolocateBtn" class="geolocation-btn">
<span id="geolocateSpinner" class="loading" style="display: none;"></span> <span id="geolocateSpinner" class="loading" style="display: none;"></span>
Get My Current Location Obtenir ma position actuelle
</button> </button>
<span id="gpsStatus" class="gps-status" title="État GPS">GPS: inconnu</span>
<form id="trafficForm"> <form id="trafficForm">
<div class="form-group"> <div class="form-group">
<label for="label" class="required">Issue Description</label> <label for="photo">Photo (optionnelle)</label>
<input type="file" id="photo" name="photo" accept="image/*" capture="environment">
<div class="note">Prenez une photo géolocalisée de la situation (mobile recommandé)</div>
<div id="photoPreviewContainer" style="margin-top:8px; display:none;">
<img id="photoPreview" alt="Aperçu photo" style="max-width:100%; border-radius:4px;"/>
</div>
<div class="form-row" style="margin-top:8px;">
<div class="form-group">
<label for="panoramaxTokenInput">Token Panoramax</label>
<input type="password" id="panoramaxTokenInput" placeholder="Jeton d'API Panoramax">
<div class="note">Stocké en local sur cet appareil. Utilisé pour envoyer la photo.</div>
</div>
<div class="form-group" style="align-self:flex-end;">
<button type="button" id="savePanoramaxTokenBtn">Enregistrer le token</button>
<button type="button" id="showPanoramaxTokenBtn" style="display:none;">Modifier le token</button>
</div>
</div>
<div class="camera-block">
<label>Prendre une photo avec la caméra</label>
<div class="camera-controls">
<button type="button" id="startCameraBtn">Démarrer la caméra</button>
<button type="button" id="capturePhotoBtn" disabled>Prendre la photo</button>
<button type="button" id="stopCameraBtn" disabled>Arrêter</button>
</div>
<div class="camera-preview">
<video id="cameraVideo" autoplay playsinline muted></video>
<canvas id="cameraCanvas" style="display:none;"></canvas>
</div>
<div class="note">La photo capturée sera ajoutée au champ ci-dessus.</div>
</div>
</div>
<div class="form-group">
<label for="label" class="required">Description du problème</label>
<input type="text" id="label" name="label" placeholder="e.g., Large pothole on Highway A1" required> <input type="text" id="label" name="label" placeholder="e.g., Large pothole on Highway A1" required>
</div> </div>
@ -186,50 +234,50 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="severity" class="required">Severity</label> <label for="severity" class="required">Gravité</label>
<select id="severity" name="severity" required> <select id="severity" name="severity" required>
<option value="low">Low (Minor issue)</option> <option value="low">Faible (Problème mineur)</option>
<option value="medium" selected>Medium (Moderate issue)</option> <option value="medium" selected>Moyen (Problème modéré)</option>
<option value="high">High (Severe issue)</option> <option value="high">Élevé (Problème grave)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="cause">Additional Details</label> <label for="cause">Détails supplémentaires</label>
<input type="text" id="cause" name="cause" placeholder="e.g., Size, specific location details"> <input type="text" id="cause" name="cause" placeholder="e.g., Size, specific location details">
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label for="start" class="required">Report Time</label> <label for="start" class="required">Heure de début</label>
<input type="datetime-local" id="start" name="start" required value=""> <input type="datetime-local" id="start" name="start" required value="">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="stop" class="required">Estimated Clear Time</label> <label for="stop" class="required">Heure estimée de fin</label>
<input type="datetime-local" id="stop" name="stop" required value=""> <input type="datetime-local" id="stop" name="stop" required value="">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="where">Road/Location Name</label> <label for="where">Route/Nom du lieu</label>
<input type="text" id="where" name="where" placeholder="e.g., Highway A1, Main Street"> <input type="text" id="where" name="where" placeholder="e.g., Highway A1, Main Street">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="required">Location</label> <label class="required">Location</label>
<div id="map"></div> <div id="map"></div>
<div class="note">Click on the map to set the issue location or use the "Get My Current Location" button</div> <div class="note">Cliquez sur la carte pour définir la localisation du problème ou utilisez le bouton "Obtenir ma position actuelle"</div>
</div> </div>
<button id="report_issue_button" type="submit" disabled>Report Issue</button> <button id="report_issue_button" type="submit" disabled>Signaler le problème</button>
</form> </form>
<div id="result"></div> <div id="result"></div>
<a href="/demo/view-events" class="view-saved-events"> <a href="/demo/view-events" class="view-saved-events">
<i class="fas fa-map-marked-alt"></i> View All Saved Events on Map <i class="fas fa-map-marked-alt"></i> Voir tous les événements enregistrés sur la carte
</a> </a>
</div> </div>
@ -242,9 +290,9 @@
// Set start time to current time // Set start time to current time
document.getElementById('start').value = nowISO; document.getElementById('start').value = nowISO;
// Set end time to current time + 1 hour // Set end time to current time + 6 hours (durée par défaut des signalements)
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000); const sixHoursLater = new Date(now.getTime() + 6 * 60 * 60 * 1000);
document.getElementById('stop').value = oneHourLater.toISOString().slice(0, 16); document.getElementById('stop').value = sixHoursLater.toISOString().slice(0, 16);
} }
// Call function to set default dates // Call function to set default dates
@ -442,6 +490,18 @@
let markerColor = '#ff3860'; // Default red color let markerColor = '#ff3860'; // Default red color
switch(issueType) { 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': case 'pothole':
labelInput.value = 'Nid de poule'; labelInput.value = 'Nid de poule';
issueTypeInput.value = 'traffic.hazard.pothole'; issueTypeInput.value = 'traffic.hazard.pothole';
@ -721,8 +781,64 @@
setTimeout(validateForm, 100); 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 // Handle form submission
document.getElementById('trafficForm').addEventListener('submit', function(e) { document.getElementById('trafficForm').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
// Validate form before submission // Validate form before submission
@ -795,6 +911,18 @@
console.log(`Including OSM username in report: ${osmUsernameValue}`); 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 // Save event to localStorage
saveEventToLocalStorage(event); saveEventToLocalStorage(event);

View file

@ -28,11 +28,7 @@
<div class="map-overlay"> <div class="map-overlay">
<h2>Your Saved Events</h2> <h2>Your Saved Events</h2>
{% include 'partials/demo_nav.html' %}
<div class="nav-links">
<a href="/demo">&larr; Back to Map</a>
<a href="/demo/traffic">Report New Issue</a>
</div>
<!-- Authentication section --> <!-- Authentication section -->
<div id="auth-section" class="auth-section"> <div id="auth-section" class="auth-section">