add panoramax token
This commit is contained in:
parent
f66e5e3f7b
commit
1a3df2ed75
12 changed files with 1132 additions and 61 deletions
23
backend.py
23
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
|
||||
|
|
|
@ -1536,8 +1536,8 @@ class DemoResource:
|
|||
// Update event info
|
||||
document.getElementById('event-info').innerHTML = '<p>Loading events...</p>';
|
||||
|
||||
// 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) {
|
||||
|
|
|
@ -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']
|
||||
__all__ = ['demo_main', 'demo_traffic', 'demo_view_events', 'demo_stats', 'demo']
|
|
@ -492,14 +492,27 @@ 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;
|
||||
}
|
||||
// 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
|
||||
function fitMapToBounds(geojson) {
|
||||
|
|
53
oedb/resources/demo/demo_stats.py
Normal file
53
oedb/resources/demo/demo_stats.py
Normal 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()
|
||||
|
||||
|
|
@ -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
|
||||
|
|
99
oedb/resources/demo/static/traffic.css
Normal file
99
oedb/resources/demo/static/traffic.css
Normal 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;
|
||||
}
|
||||
|
676
oedb/resources/demo/static/traffic.js
Normal file
676
oedb/resources/demo/static/traffic.js
Normal 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 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;
|
||||
|
8
oedb/resources/demo/templates/partials/demo_nav.html
Normal file
8
oedb/resources/demo/templates/partials/demo_nav.html
Normal 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>
|
||||
|
76
oedb/resources/demo/templates/stats.html
Normal file
76
oedb/resources/demo/templates/stats.html
Normal 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>
|
||||
|
|
@ -8,16 +8,13 @@
|
|||
<link href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/static/demo_styles.css">
|
||||
<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/traffic.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav-links">
|
||||
<a href="/demo">← 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>
|
||||
{% include 'partials/demo_nav.html' %}
|
||||
|
||||
<h1>Report Road Issue</h1>
|
||||
|
||||
|
@ -25,6 +22,9 @@
|
|||
<input type="hidden" id="osmClientId" value="{{ client_id }}">
|
||||
<input type="hidden" id="osmClientSecret" value="{{ client_secret }}">
|
||||
<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 -->
|
||||
<div id="auth-section">
|
||||
|
@ -46,18 +46,16 @@
|
|||
{% endif %}
|
||||
|
||||
<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() {
|
||||
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 redirectUri = document.getElementById('osmRedirectUri').value;
|
||||
const authSection = document.getElementById('auth-section');
|
||||
|
||||
// Only replace if osmAuth is loaded and has renderAuthSection method
|
||||
if (osmAuth.renderAuthSection) {
|
||||
authSection.innerHTML = osmAuth.renderAuthSection(clientId, redirectUri);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
@ -67,16 +65,19 @@
|
|||
<!-- Tab Navigation -->
|
||||
<div class="tabs">
|
||||
<div class="tab-item active" data-tab="road">
|
||||
<i class="fas fa-road"></i> Road
|
||||
<i class="fas fa-road"></i> Route
|
||||
</div>
|
||||
<div class="tab-item" data-tab="rail">
|
||||
<i class="fas fa-train"></i> Rail
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
|
@ -95,7 +96,7 @@
|
|||
</div>
|
||||
<div class="issue-button road vehicle" onclick="fillForm('vehicle')">
|
||||
<i class="fas fa-car"></i>
|
||||
Vehicle on Side
|
||||
Véhicule sur le bas côté de la route
|
||||
</div>
|
||||
<div class="issue-button road danger" onclick="fillForm('danger')">
|
||||
<i class="fas fa-skull-crossbones"></i>
|
||||
|
@ -107,15 +108,15 @@
|
|||
</div>
|
||||
<div class="issue-button road flooded-road" onclick="fillForm('flooded_road')">
|
||||
<i class="fas fa-water"></i>
|
||||
Flooded Road
|
||||
Route inondée
|
||||
</div>
|
||||
<div class="issue-button road roadwork" onclick="fillForm('roadwork')">
|
||||
<i class="fas fa-hard-hat"></i>
|
||||
Roadwork
|
||||
Travaux
|
||||
</div>
|
||||
<div class="issue-button road black-traffic" onclick="fillForm('black_traffic')">
|
||||
<i class="fas fa-traffic-light"></i>
|
||||
Black Traffic
|
||||
journée noire bison futé
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -125,15 +126,15 @@
|
|||
<div class="issue-buttons">
|
||||
<div class="issue-button rail unattended-luggage" onclick="fillForm('unattended_luggage')">
|
||||
<i class="fas fa-suitcase"></i>
|
||||
Unattended Luggage
|
||||
Bagage abandonné
|
||||
</div>
|
||||
<div class="issue-button rail transport-delay" onclick="fillForm('transport_delay')">
|
||||
<i class="fas fa-hourglass-half"></i>
|
||||
Delay
|
||||
Retard
|
||||
</div>
|
||||
<div class="issue-button rail major-delay" onclick="fillForm('major_transport_delay')">
|
||||
<i class="fas fa-hourglass-end"></i>
|
||||
Major Delay
|
||||
Retard important
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -143,15 +144,15 @@
|
|||
<div class="issue-buttons">
|
||||
<div class="issue-button weather flood-danger" onclick="fillForm('flood_danger')">
|
||||
<i class="fas fa-water"></i>
|
||||
Flood Alert
|
||||
Vigilance rouge inondation
|
||||
</div>
|
||||
<div class="issue-button weather thunderstorm" onclick="fillForm('thunderstorm_alert')">
|
||||
<i class="fas fa-bolt"></i>
|
||||
Thunderstorm
|
||||
Vigilance orange orages
|
||||
</div>
|
||||
<div class="issue-button weather fog" onclick="fillForm('fog_warning')">
|
||||
<i class="fas fa-smog"></i>
|
||||
Fog Warning
|
||||
Vigilance jaune brouillard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -161,11 +162,25 @@
|
|||
<div class="issue-buttons">
|
||||
<div class="issue-button emergency emergency-alert" onclick="fillForm('emergency_alert')">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
Emergency Alert
|
||||
Alerte d'urgence (SAIP)
|
||||
</div>
|
||||
<div class="issue-button emergency daylight-saving" onclick="fillForm('daylight_saving')">
|
||||
<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>
|
||||
|
@ -173,12 +188,45 @@
|
|||
|
||||
<button id="geolocateBtn" class="geolocation-btn">
|
||||
<span id="geolocateSpinner" class="loading" style="display: none;"></span>
|
||||
Get My Current Location
|
||||
Obtenir ma position actuelle
|
||||
</button>
|
||||
<span id="gpsStatus" class="gps-status" title="État GPS">GPS: inconnu</span>
|
||||
|
||||
<form id="trafficForm">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
@ -186,50 +234,50 @@
|
|||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="severity" class="required">Severity</label>
|
||||
<label for="severity" class="required">Gravité</label>
|
||||
<select id="severity" name="severity" required>
|
||||
<option value="low">Low (Minor issue)</option>
|
||||
<option value="medium" selected>Medium (Moderate issue)</option>
|
||||
<option value="high">High (Severe issue)</option>
|
||||
<option value="low">Faible (Problème mineur)</option>
|
||||
<option value="medium" selected>Moyen (Problème modéré)</option>
|
||||
<option value="high">Élevé (Problème grave)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<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="">
|
||||
</div>
|
||||
|
||||
<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="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="required">Location</label>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div id="result"></div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
|
|
@ -28,11 +28,7 @@
|
|||
|
||||
<div class="map-overlay">
|
||||
<h2>Your Saved Events</h2>
|
||||
|
||||
<div class="nav-links">
|
||||
<a href="/demo">← Back to Map</a>
|
||||
<a href="/demo/traffic">Report New Issue</a>
|
||||
</div>
|
||||
{% include 'partials/demo_nav.html' %}
|
||||
|
||||
<!-- Authentication section -->
|
||||
<div id="auth-section" class="auth-section">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue