/**
* @file map.js
* @description Module de gestion de la carte interactive pour la création et l'édition de RoadTrips.
* Architecture : constantes → état → utilitaires → fonctionnalités → init
* @requires L (Leaflet)
* @requires Swal (SweetAlert2)
*/
// ============================================================
// 0. CONSTANTES & CONFIGURATION
// ============================================================
/**
* Configuration des régions (Europe et Amérique) pour centrer la carte.
* @constant {Object}
*/
const REGIONS_CONFIG = {
'europe': {
center: [46.5, 2.5],
zoom: 5,
codes: ["fr", "be", "ch", "lu", "de", "at", "li", "it", "sm", "va", "es", "pt", "ad", "gb", "ie", "nl", "dk", "no", "se", "fi", "is", "pl", "cz", "sk", "hu", "ee", "lv", "lt", "ro", "bg", "gr", "cy", "mt", "si", "hr", "ba", "rs", "me", "al", "mk", "xk", "ua", "md", "by", "ge", "am", "az"],
bbox: [-25, 34, 45, 72]
},
'america': {
center: [39.8283, -98.5795],
zoom: 4,
codes: ["us", "ca", "mx"],
bbox: [-169, 15, -52, 72]
}
};
/**
* Correspondance entre les modes de transport de l'UI et ceux de l'API OSRM.
* @constant {Object<string, string>}
*/
const TRANSPORT_STRATEGIES = {
'Voiture': 'driving',
'Velo': 'cycling',
'Marche': 'walking'
};
/**
* URLs de base pour l'API de routage OSRM selon le mode de transport.
* @constant {Object<string, string>}
*/
const ROUTING_SERVERS = {
'Voiture': 'https://routing.openstreetmap.de/routed-car',
'Velo': 'https://routing.openstreetmap.de/routed-bike',
'Marche': 'https://routing.openstreetmap.de/routed-foot'
};
const SEGMENT_COLORS = [
'#0B667D', '#2E8B57', '#FF7F50', '#BF092F', '#8e44ad', '#d35400', '#2980b9',
'#16A085', '#E67E22', '#8E44AD', '#2C3E50', '#C0392B', '#27AE60', '#F1C40F',
'#E74C3C', '#34495E', '#9B59B6', '#1ABC9C', '#7F8C8D', '#D35400'
];
const FAVORITE_ICON = L.divIcon({
html: '<div style="font-size: 24px; color: #f1c40f; text-shadow: 0 0 3px black;">⭐</div>',
className: 'fav-marker-icon',
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15]
});
// ============================================================
// 1. ÉTAT GLOBAL DU MODULE
// ============================================================
/**
* État global de l'application gérant les données de la carte et du trajet.
* @type {Object}
* @property {L.Map|null} map - Instance de la carte Leaflet.
* @property {string} currentStartCity - Ville de départ.
* @property {Array<number>|null} currentStartCoords - Coordonnées [lat, lon] du départ.
* @property {Array<Object>} segments - Tableau contenant les étapes du trajet.
* @property {Array<L.Polyline>} polylineLayers - Tableau des tracés affichés sur la carte.
* @property {Object<string, L.Marker>} markers - Dictionnaire des marqueurs (clé = nom de la ville).
* @property {number|null} currentSegmentIndex - Index du segment actuellement édité.
* @property {boolean} isCalculating - Indicateur d'état pendant les calculs d'itinéraires.
* @property {Array<Object>} userFavorites - Liste des lieux favoris de l'utilisateur.
* @property {L.FeatureGroup|null} favoritesLayer - Groupe Leaflet contenant les marqueurs de favoris.
*/
const state = {
map: null,
currentRegion: 'europe',
segments: [],
markers: {},
userFavorites: [],
subStepEditors: {},
currentSegmentIndex: null,
currentStartCity: '',
currentStartCoords: null,
};
// ============================================================
// 2. UTILITAIRES PURS (pas de dépendances vers state)
// ============================================================
function compresserImageJS(file, quality = 0.7, maxWidth = 1920) {
return new Promise((resolve, reject) => {
const fileName = file.name;
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = event => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
ctx.canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Erreur lors de la création du blob via Canvas"));
return;
}
resolve(new File([blob], fileName, { type: 'image/jpeg', lastModified: Date.now() }));
}, 'image/jpeg', quality);
};
img.onerror = error => reject(error);
};
reader.onerror = error => reject(error);
});
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? `${h}h${m.toString().padStart(2, '0')}` : `${m} min`;
}
function addTimeToString(startTime, secondsToAdd) {
const [h, m] = startTime.split(':').map(Number);
const date = new Date();
date.setHours(h, m, 0);
date.setSeconds(date.getSeconds() + secondsToAdd);
return date.getHours().toString().padStart(2, '0') + ':' +
date.getMinutes().toString().padStart(2, '0');
}
function durationStringToSeconds(timeStr) {
if (!timeStr) return 0;
const [h, m] = timeStr.split(':').map(Number);
return (h * 3600) + (m * 60);
}
function getNomSimple(nom) {
return nom ? nom.split(',')[0].trim() : '';
}
// ============================================================
// 3. UTILITAIRES UI
// ============================================================
function showToast(message) {
const existing = document.querySelector('.toast-notification');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'toast-notification';
toast.innerHTML = '⚠️ ' + message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 500);
}, 3500);
}
// ============================================================
// 4. CARTE : INITIALISATION
// ============================================================
function initRoadTripMap() {
const regionSelectEl = document.getElementById('regionSelect');
state.currentRegion = regionSelectEl ? regionSelectEl.value : 'europe';
state.map = L.map('map').setView(
REGIONS_CONFIG[state.currentRegion].center,
REGIONS_CONFIG[state.currentRegion].zoom
);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '@ OpenStreetMap'
}).addTo(state.map);
}
// ============================================================
// 5. GÉOCODAGE & MARQUEURS
// ============================================================
async function getCoordinate(ville) {
const codes = REGIONS_CONFIG[state.currentRegion].codes.join(',');
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(ville)}&limit=1&accept-language=fr&countrycodes=${codes}`;
try {
const resp = await fetch(url, { headers: { 'Accept-Language': 'fr' } });
if (!resp.ok) return null;
const data = await resp.json();
if (!data.length) return null;
return [parseFloat(data[0].lat), parseFloat(data[0].lon)];
} catch (e) {
console.error(e);
return null;
}
}
function addMarker(lieu, coords, type, popupContent) {
const marker = L.marker(coords).addTo(state.map).bindPopup(popupContent);
if (!state.markers[lieu]) state.markers[lieu] = [];
state.markers[lieu].push({ marker, type });
return marker;
}
function initAutocomplete(element) {
if (!element) return;
element.addEventListener('input', function () {
this.removeAttribute('data-lat');
this.removeAttribute('data-lon');
this.removeAttribute('data-full-name');
this.style.backgroundColor = '';
});
$(element).autocomplete({
source: async function (request, response) {
const codes = REGIONS_CONFIG[state.currentRegion].codes.join(',');
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(request.term)}&limit=8&accept-language=fr&countrycodes=${codes}&addressdetails=1`;
try {
const res = await fetch(url, { headers: { 'Accept-Language': 'fr' } });
const data = await res.json();
const seen = new Set();
const suggestions = [];
data.forEach(item => {
const addr = item.address || {};
const name = addr.city || addr.town || addr.village || addr.municipality || item.display_name.split(',')[0] || "";
const state = addr.state || "";
const country = addr.country || "";
const postcode = addr.postcode || "";
const uniqueKey = `${name}-${state}-${country}`.toLowerCase();
if (!seen.has(uniqueKey)) {
seen.add(uniqueKey);
const fullLabel = [name, state, postcode, country].filter(Boolean).join(", ");
suggestions.push({
label: `<div class="ui-menu-item-content">
<span class="ui-menu-item-name" style="font-weight: bold;">${name}</span>
<span class="ui-menu-item-details" style="font-size: 0.85em; color: #666; margin-left: 5px;">${fullLabel}</span>
</div>`,
value: fullLabel,
full_name: fullLabel,
lat: parseFloat(item.lat),
lon: parseFloat(item.lon)
});
}
});
response(suggestions);
} catch (error) {
console.error(error);
response([]);
}
},
minLength: 2,
select: function (event, ui) {
$(this).val(ui.item.full_name);
$(this).attr('data-full-name', ui.item.full_name);
$(this).attr('data-lat', ui.item.lat);
$(this).attr('data-lon', ui.item.lon);
$(this).css('backgroundColor', '#e8f8f5');
return false;
}
}).data("ui-autocomplete")._renderItem = function (ul, item) {
return $("<li>").append($("<div>").html(item.label)).appendTo(ul);
};
}
// ============================================================
// 6. FAVORIS
// ============================================================
async function fetchUserFavorites() {
try {
const url = typeof URL_GET_FAVORIS !== 'undefined' ? URL_GET_FAVORIS : '/roadtrips/get-lieux-favoris';
const resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (resp.ok) {
state.userFavorites = await resp.json();
}
} catch (e) {
console.log("Erreur chargement favoris", e);
}
}
async function loadMapFavorites() {
const favoris = state.userFavorites;
if (!favoris || favoris.length === 0) return;
favoris.forEach(fav => {
const lat = parseFloat(fav.latitude);
const lon = parseFloat(fav.longitude);
const marker = L.marker([lat, lon], { icon: FAVORITE_ICON }).addTo(state.map);
const container = document.createElement('div');
container.style.textAlign = 'center';
container.innerHTML = `
<strong style="color:#d35400">${fav.nom_lieu}</strong><br>
<small>${fav.categorie}</small><br>
<div style="margin-top:10px; display:flex; flex-direction:column; gap:5px;">
<button class="btn-fav-action btn-start" style="background:#27ae60; color:white; border:none; padding:5px; cursor:pointer; border-radius:3px;">🚩 Définir comme Départ</button>
<button class="btn-fav-action btn-end" style="background:#c0392b; color:white; border:none; padding:5px; cursor:pointer; border-radius:3px;">🏁 Définir comme Arrivée</button>
<button class="btn-fav-action btn-step" style="background:#2980b9; color:white; border:none; padding:5px; cursor:pointer; border-radius:3px;">📍 Ajouter comme Étape</button>
</div>`;
container.querySelector('.btn-start').addEventListener('click', () => {
const btnAdd = document.getElementById('btnAddSegment');
if (btnAdd && btnAdd.style.display !== 'none') btnAdd.click();
setTimeout(() => {
const inputStart = document.getElementById('inputStartBlock');
if (inputStart) {
inputStart.value = fav.nom_lieu;
state.currentStartCity = fav.nom_lieu;
state.currentStartCoords = [lat, lon];
inputStart.style.backgroundColor = '#d5f5e3';
}
}, 100);
marker.closePopup();
});
container.querySelector('.btn-end').addEventListener('click', () => {
const btnAdd = document.getElementById('btnAddSegment');
if (btnAdd && btnAdd.style.display !== 'none') btnAdd.click();
setTimeout(() => {
const inputEnd = document.getElementById('inputEndBlock');
if (inputEnd) {
inputEnd.value = fav.nom_lieu;
inputEnd.style.backgroundColor = '#d5f5e3';
}
}, 100);
marker.closePopup();
});
container.querySelector('.btn-step').addEventListener('click', () => {
const formContainer = document.getElementById('segmentFormContainer');
if (formContainer.style.display === 'block') {
document.getElementById('addSubEtape').click();
setTimeout(() => {
const allInputs = document.querySelectorAll('.subEtapeNom');
const lastInput = allInputs[allInputs.length - 1];
if (lastInput) {
lastInput.value = fav.nom_lieu;
lastInput.style.backgroundColor = '#d6eaf8';
}
}, 50);
marker.closePopup();
} else {
alert("Veuillez d'abord ouvrir le mode modification d'un trajet.");
}
});
marker.bindPopup(container);
});
}
function createFavSelectForInput(targetInputId) {
if (!state.userFavorites || state.userFavorites.length === 0) return null;
const select = document.createElement('select');
select.style.cssText = "width: 100%; margin-bottom: 10px; padding: 10px 14px; border: 1px solid #ddd; border-radius: 15px; background-color: #fff; color: #555; font-size: 1rem; cursor: pointer;";
let optionsHtml = '<option value="">⭐ Choisir un favori...</option>';
state.userFavorites.forEach(fav => {
const icon = fav.categorie === 'restaurant' ? '🍽️' : fav.categorie === 'hotel' ? '🏨' : '📍';
optionsHtml += `<option value="${fav.nom_lieu}">${icon} ${fav.nom_lieu}</option>`;
});
select.innerHTML = optionsHtml;
select.addEventListener('change', function () {
const input = document.getElementById(targetInputId);
if (input && this.value) {
input.value = this.value;
input.style.backgroundColor = '#e8f8f5';
setTimeout(() => { input.style.backgroundColor = ''; }, 500);
}
});
return select;
}
// ============================================================
// 7. GESTION DES SEGMENTS
// ============================================================
function removeSegmentFromMap(index) {
const seg = state.segments[index];
if (!seg) return;
if (seg.line) state.map.removeLayer(seg.line);
if (state.markers[seg.endName]) {
state.markers[seg.endName].forEach(m => state.map.removeLayer(m.marker));
delete state.markers[seg.endName];
}
if (seg.sousEtapes) {
seg.sousEtapes.forEach(se => {
if (state.markers[se.nom]) {
state.markers[se.nom].forEach(m => state.map.removeLayer(m.marker));
delete state.markers[se.nom];
}
});
}
if (index === 0) {
if (state.markers[seg.startName]) {
state.markers[seg.startName].forEach(m => state.map.removeLayer(m.marker));
delete state.markers[seg.startName];
}
state.currentStartCity = (typeof USER_DEFAULT_CITY !== 'undefined') ? USER_DEFAULT_CITY : "";
state.currentStartCoords = null;
} else {
state.currentStartCity = state.segments[index - 1].endName;
state.currentStartCoords = state.segments[index - 1].endCoord;
}
state.segments.splice(index, 1);
if (state.segments.length > 0) {
const group = new L.featureGroup(state.segments.map(s => s.line));
state.map.fitBounds(group.getBounds(), { padding: [50, 50] });
}
}
async function _ajouterSegmentEntre(startName, startCoords, endName, endCoords, index, strategy, existingData = null) {
const modeTransport = existingData ? existingData.mode : 'Voiture';
const currentProfile = TRANSPORT_STRATEGIES[modeTransport] || 'driving';
let coordsList = [startCoords];
if (existingData && existingData.sousEtapes) {
existingData.sousEtapes.forEach(se => { if (se.coords) coordsList.push(se.coords); });
}
coordsList.push(endCoords);
const coordString = coordsList.map(c => `${c[1]},${c[0]}`).join(';');
const url = `https://router.project-osrm.org/route/v1/${currentProfile}/${coordString}?overview=full&geometries=geojson`;
try {
const resp = await fetch(url);
const data = await resp.json();
const color = SEGMENT_COLORS[index % SEGMENT_COLORS.length];
let line, routeDistance = 0, routeDuration = 0, routeLegs = null;
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
const route = data.routes[0];
routeDistance = route.distance;
routeDuration = route.duration;
routeLegs = route.legs;
const lineStyle = {
color,
weight: 6,
opacity: 0.8,
dashArray: modeTransport !== 'Voiture' ? '10, 10' : null
};
line = L.geoJSON(route.geometry, lineStyle).addTo(state.map);
} else {
console.warn("⚠️ Impossible de tracer la route pour : " + startName + " -> " + endName, data);
line = L.polyline(coordsList, { color: 'red', weight: 4, dashArray: '5, 10' }).addTo(state.map);
}
state.map.fitBounds(line.getBounds(), { padding: [50, 50] });
const segData = {
line,
startName, startCoord: startCoords,
endName, endCoord: endCoords,
couleurSegment: color,
sousEtapes: existingData && existingData.sousEtapes ? existingData.sousEtapes : [],
startNameSimple: getNomSimple(startName),
endNameSimple: getNomSimple(endName),
modeTransport,
options: {},
date: existingData ? existingData.date_trajet : '',
distance: routeDistance,
duration: routeDuration,
legs: routeLegs
};
state.segments.push(segData);
const template = document.getElementById('template-legend-item');
const clone = template.content.cloneNode(true);
const li = clone.querySelector('li');
li.dataset.index = index;
clone.querySelector('.legend-color-indicator').style.background = color;
clone.querySelector('.toggleSousEtapes').innerHTML = `${getNomSimple(startName)} → ${getNomSimple(endName)}`;
const dateInput = clone.querySelector('.legend-date-input');
if (segData.date) dateInput.value = segData.date;
const timeInput = clone.querySelector('.legend-time-input');
if (existingData && existingData.heure_depart) {
timeInput.value = existingData.heure_depart;
segData.heure_depart = existingData.heure_depart;
} else {
segData.heure_depart = timeInput.value;
}
timeInput.addEventListener('change', (e) => {
state.segments[index].heure_depart = e.target.value;
updateLegendHtml(index);
});
const transportBtns = clone.querySelectorAll('.transport-btn');
transportBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === modeTransport);
btn.addEventListener('click', async () => {
const nouveauMode = btn.dataset.mode;
transportBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
await updateRouteSegment(index, nouveauMode, state.segments[index].options);
});
});
clone.querySelector('.modifierSousEtapes').dataset.index = index;
document.getElementById('legendList').appendChild(clone);
updateDateConstraints();
updateLegendHtml(index);
} catch (e) {
console.error("Erreur fatale lors de l'ajout du segment :", e);
}
}
/**
* Met à jour un segment de route spécifique sur la carte.
* @async
* @function updateRouteSegment
* @param {number} index - L'index du segment à mettre à jour.
* @param {string} mode - Le mode de transport (Voiture, Velo, Marche).
* @returns {Promise<void>}
*/
async function updateRouteSegment(index, mode, options = {}) {
const seg = state.segments[index];
if (!seg) return;
const baseUrl = ROUTING_SERVERS[mode] || ROUTING_SERVERS['Voiture'];
let points = [seg.startCoord];
if (seg.sousEtapes) {
seg.sousEtapes.forEach(se => { if (se.coords) points.push(se.coords); });
}
points.push(seg.endCoord);
const coordString = points.map(p => `${p[1]},${p[0]}`).join(';');
const url = `${baseUrl}/route/v1/driving/${coordString}?overview=full&geometries=geojson&continue_straight=true`;
try {
const resp = await fetch(url);
const data = await resp.json();
if (data.code === 'Ok') {
const route = data.routes[0];
if (seg.line) state.map.removeLayer(seg.line);
seg.distance = route.distance;
seg.duration = route.duration;
seg.modeTransport = mode;
const lineStyle = {
color: seg.couleurSegment,
weight: 6,
opacity: 0.8,
dashArray: mode !== 'Voiture' ? '10, 10' : null
};
seg.line = L.geoJSON(route.geometry, lineStyle).addTo(state.map);
state.map.fitBounds(seg.line.getBounds(), { padding: [50, 50] });
updateLegendHtml(index);
console.log(`Succès ! Mode: ${mode}, Route mise à jour.`);
}
} catch (e) {
console.error("Erreur de mise à jour de la route :", e);
}
}
// ============================================================
// 8. LÉGENDE & CONTRAINTES DE DATE
// ============================================================
function updateDateConstraints() {
const dateInputs = document.querySelectorAll('.legend-date-input');
for (let i = 0; i < dateInputs.length; i++) {
const currentInput = dateInputs[i];
if (i > 0) {
const prevInput = dateInputs[i - 1];
if (prevInput.value) {
currentInput.min = prevInput.value;
if (currentInput.value && currentInput.value < prevInput.value) {
currentInput.value = prevInput.value;
const idx = currentInput.closest('li').dataset.index;
if (state.segments[idx]) state.segments[idx].date = prevInput.value;
}
}
}
currentInput.onchange = (e) => {
const idx = e.target.closest('li').dataset.index;
if (state.segments[idx]) state.segments[idx].date = e.target.value;
updateDateConstraints();
};
}
}
function updateLegendHtml(index) {
const seg = state.segments[index];
const li = document.querySelector(`li[data-index="${index}"]`);
if (!li || !seg.heure_depart) return;
const ul = li.querySelector('.sousEtapesList');
let html = '';
let currentClock = seg.heure_depart;
html += `<div class="summary-container">`;
html += `<div class="summary-step start">📍 Départ à <strong>${currentClock}</strong></div>`;
if (seg.legs) {
seg.legs.forEach((leg, i) => {
currentClock = addTimeToString(currentClock, leg.duration);
if (i < seg.sousEtapes.length) {
const se = seg.sousEtapes[i];
html += `<div class="summary-step sub">
<div class="sub-arrival">Arrivée à ${getNomSimple(se.nom)} : <strong>${currentClock}</strong></div>
<span class="sub-pause">☕ Pause : ${se.heure}</span>
</div>`;
currentClock = addTimeToString(currentClock, durationStringToSeconds(se.heure));
}
});
}
html += `<div class="summary-step end">🏁 Arrivée estimée : <strong>${currentClock}</strong></div>`;
const distKm = (seg.distance / 1000).toFixed(1);
const totalTime = formatDuration(seg.duration);
const iconMode = seg.modeTransport === 'Voiture' ? '🚗' : (seg.modeTransport === 'Velo' ? '🚲' : '🚶');
html += `<div class="summary-stats">${iconMode} <strong>${distKm} km</strong> parcourus en <strong>${totalTime}</strong></div>`;
html += `</div>`;
ul.innerHTML = html;
}
// ============================================================
// 9. ÉDITEUR DE SOUS-ÉTAPES
// ============================================================
function openSegmentEditor(index) {
state.currentSegmentIndex = index;
const seg = state.segments[index];
document.getElementById('segmentFormContainer').style.display = 'block';
document.getElementById('segmentTitle').textContent = `${seg.startNameSimple} → ${seg.endNameSimple}`;
const container = document.getElementById('subEtapesContainer');
container.innerHTML = '';
state.subStepEditors = {};
if (seg.sousEtapes && seg.sousEtapes.length > 0) {
seg.sousEtapes.forEach(se => addSousEtapeForm(container, se));
} else {
addSousEtapeForm(container);
}
}
function addSousEtapeForm(targetContainer, data = {}) {
const template = document.getElementById('template-sub-etape');
if (!template) {
console.error("ERREUR FATALE : Le template 'template-sub-etape' est introuvable");
return;
}
const clone = template.content.cloneNode(true);
const div = clone.querySelector('.subEtape');
const inputNom = div.querySelector('.subEtapeNom');
inputNom.value = data.nom || '';
div.querySelector('.subEtapeHeure').value = data.heure || '';
const editorContainer = div.querySelector('.subEtapeEditorContainer');
const uniqueId = 'editor-' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
if (editorContainer) {
editorContainer.id = uniqueId;
} else {
console.error("Erreur: .subEtapeEditorContainer introuvable dans le template HTML");
}
targetContainer.appendChild(div);
initAutocomplete(inputNom);
setTimeout(() => {
if (!document.getElementById(uniqueId)) return;
state.subStepEditors[uniqueId] = new toastui.Editor({
el: document.querySelector('#' + uniqueId),
height: '350px',
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
initialValue: data.remarque || '',
placeholder: 'Décrivez cette étape et glissez-y vos photos !',
language: 'fr-FR',
usageStatistics: false,
hideModeSwitch: true,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task'],
['table', 'image', 'link']
],
hooks: {
addImageBlobHook: async (blob, callback) => {
try {
const compressedFile = await compresserImageJS(blob, 0.7, 1200);
const formData = new FormData();
formData.append('image', compressedFile);
const response = await fetch(UPLOAD_IMAGE_URL, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': CSRF_TOKEN
}
});
if (response.ok) {
const result = await response.json();
if (result.success && result.url) {
callback(result.url, "Photo de l'étape");
} else {
alert("Erreur serveur : " + result.message);
}
} else {
alert("Échec de la connexion lors de l'envoi de l'image.");
}
} catch (error) {
console.error("Erreur hook image:", error);
alert("Impossible de traiter cette image.");
}
}
}
});
}, 100);
div.querySelector('.removeSubEtapeBtn').addEventListener('click', () => {
if (state.subStepEditors[uniqueId]) {
state.subStepEditors[uniqueId].destroy();
delete state.subStepEditors[uniqueId];
}
div.remove();
});
}
// ============================================================
// 10. FORMULAIRE D'AJOUT DE SEGMENT
// ============================================================
function openNewSegmentForm() {
const btnAddSegment = document.getElementById('btnAddSegment');
const newBlockFormContainer = document.getElementById('newBlockForm');
btnAddSegment.style.display = 'none';
let html = '';
if (!state.currentStartCoords) {
html += `<div class="new-block-field"><label class="new-block-label">Départ :</label><input type="text" id="inputStartBlock" class="new-block-input"></div>`;
} else {
html += `<div class="new-block-static"><strong>Départ :</strong> ${state.currentStartCity}</div>`;
}
html += `<div class="new-block-field"><label class="new-block-label">Arrivée :</label><input type="text" id="inputEndBlock" class="new-block-input"></div>
<div class="new-block-actions">
<button id="btnCancelBlock" class="btn-block-action btn-block-cancel">Annuler</button>
<button id="btnValidateBlock" class="btn-block-action btn-block-validate">Valider</button>
</div>`;
newBlockFormContainer.innerHTML = html;
newBlockFormContainer.classList.remove('hidden');
newBlockFormContainer.style.display = 'block';
const inputStart = document.getElementById('inputStartBlock');
if (inputStart) {
const selectStart = createFavSelectForInput('inputStartBlock');
if (selectStart) inputStart.parentNode.insertBefore(selectStart, inputStart);
initAutocomplete(inputStart);
}
const inputEnd = document.getElementById('inputEndBlock');
if (inputEnd) {
const selectEnd = createFavSelectForInput('inputEndBlock');
if (selectEnd) inputEnd.parentNode.insertBefore(selectEnd, inputEnd);
initAutocomplete(inputEnd);
}
document.getElementById('btnCancelBlock').addEventListener('click', () => {
newBlockFormContainer.innerHTML = '';
newBlockFormContainer.style.display = 'none';
btnAddSegment.style.display = 'block';
});
document.getElementById('btnValidateBlock').addEventListener('click', () => validateNewSegment());
}
async function validateNewSegment() {
const btn = document.getElementById('btnValidateBlock');
btn.disabled = true;
btn.textContent = "Calcul...";
let startName, startCoords;
const inputStartEl = document.getElementById('inputStartBlock');
if (inputStartEl) {
const lat = inputStartEl.getAttribute('data-lat');
const lon = inputStartEl.getAttribute('data-lon');
startName = inputStartEl.value.trim();
startCoords = (lat && lon) ? [parseFloat(lat), parseFloat(lon)] : await getCoordinate(startName);
if (!startCoords) {
alert('Départ introuvable');
btn.disabled = false;
return;
}
addMarker(startName, startCoords, "ville", startName);
} else {
startName = state.currentStartCity;
startCoords = state.currentStartCoords;
}
const inputEndEl = document.getElementById('inputEndBlock');
const eLat = inputEndEl.getAttribute('data-lat');
const eLon = inputEndEl.getAttribute('data-lon');
const endName = inputEndEl.value.trim();
const endCoords = (eLat && eLon) ? [parseFloat(eLat), parseFloat(eLon)] : await getCoordinate(endName);
if (!endCoords) {
alert('Arrivée introuvable');
btn.disabled = false;
return;
}
await _ajouterSegmentEntre(startName, startCoords, endName, endCoords, state.segments.length, TRANSPORT_STRATEGIES['Voiture']);
addMarker(endName, endCoords, "ville", endName);
state.currentStartCity = endName;
state.currentStartCoords = endCoords;
document.getElementById('newBlockForm').innerHTML = '';
document.getElementById('newBlockForm').style.display = 'none';
document.getElementById('btnAddSegment').style.display = 'block';
}
// ============================================================
// 11. SAUVEGARDE DU ROADTRIP
// ============================================================
/**
* Enregistre le roadtrip actuel via une requête POST.
* @async
* @function handleSaveRoadtrip
* @returns {Promise<void>}
*/
async function handleSaveRoadtrip(e) {
if (state.segments.length === 0) {
alert('Votre RoadTrip est vide ! Ajoutez au moins un trajet.');
return;
}
const btn = document.getElementById('saveRoadtrip');
const oldTxt = btn.textContent;
btn.textContent = "Sauvegarde en cours...";
btn.disabled = true;
try {
const formData = new FormData();
if (e.target.dataset.id) formData.append('id', e.target.dataset.id);
formData.append('title', document.getElementById('roadtripTitle').value);
formData.append('description', document.getElementById('roadtripDescription').value);
formData.append('visibility', document.getElementById('roadtripVisibilite').value);
formData.append('status', document.getElementById('roadtripStatut').value);
formData.append('place', document.getElementById('regionSelect').value);
const fileInput = document.getElementById('roadtripPhoto');
if (fileInput.files.length > 0) {
const originalFile = fileInput.files[0];
if (originalFile.type.startsWith('image/')) {
try {
const compressed = await compresserImageJS(originalFile, 0.7, 1200);
formData.append('photo_cover', compressed);
} catch (err) {
console.warn("Erreur compression, envoi fichier original", err);
formData.append('photo_cover', originalFile);
}
} else {
formData.append('photo_cover', originalFile);
}
}
const cleanTrajets = state.segments.map((s, index) => {
const timeInput = document.querySelector(`li[data-index="${index}"] .legend-time-input`);
const cleanSousEtapes = s.sousEtapes.map(se => {
let desc = (se.remarque || "").replace(/!\[.*?\]\(data:image\/[^)]+\)/g, '[Image lourde retirée]');
return {
nom: se.nom,
heure: se.heure,
remarque: desc,
lat: se.lat || (se.coords ? se.coords[0] : null),
lon: se.lon || (se.coords ? se.coords[1] : null)
};
});
return {
depart: s.startName,
departLat: s.startCoord[0], departLon: s.startCoord[1],
arrivee: s.endName,
arriveeLat: s.endCoord[0], arriveeLon: s.endCoord[1],
mode: s.modeTransport,
date_trajet: s.date,
heure_depart: timeInput ? timeInput.value : '08:00',
sousEtapes: cleanSousEtapes
};
});
const blob = new Blob([JSON.stringify(cleanTrajets)], { type: 'application/json' });
formData.append('trajets_file', blob, 'trajets.json');
const saveUrl = typeof SAVE_URL !== 'undefined' ? SAVE_URL : '/roadtrips/add';
const csrfToken = typeof CSRF_TOKEN !== 'undefined' ? CSRF_TOKEN : '';
const resp = await fetch(saveUrl, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken,
'Accept': 'application/json'
}
});
if (resp.ok) {
const json = await resp.json();
if (json.success) {
alert("🎉 Sauvegardé avec succès !");
window.location.href = "/roadtrips/my-roadtrips";
} else {
alert("Erreur : " + (json.message || "Erreur inconnue"));
}
} else {
const errorJson = await resp.json().catch(() => ({}));
console.error("Erreur Serveur:", errorJson);
let errorMsg = "Une erreur est survenue lors de la sauvegarde.";
if (errorJson.details) {
errorMsg += "\n\nDétails :";
for (const [field, errors] of Object.entries(errorJson.details)) {
errorMsg += `\n- ${field} : ${Object.values(errors).join(', ')}`;
}
} else if (errorJson.message) {
errorMsg += "\n" + errorJson.message;
}
alert(errorMsg);
}
} catch (err) {
console.error("Erreur JS Critique :", err);
alert("Une erreur technique inattendue est survenue. Vérifiez la console.");
} finally {
btn.textContent = oldTxt;
btn.disabled = false;
}
}
// ============================================================
// 12. ASSISTANT IA
// ============================================================
/**
* Gère la génération de trajet via l'IA.
* @async
* @function handleGenerateAI
* @returns {Promise<void>}
*/
async function handleGenerateAI() {
const depart = document.getElementById('aiDepart').value.trim();
const destination = document.getElementById('aiDestination').value.trim();
const duree = document.getElementById('aiDuree').value.trim();
const theme = document.getElementById('aiTheme').value.trim();
if (!depart || !destination) {
alert("Veuillez indiquer au moins une ville de départ et une destination.");
return;
}
const btn = document.getElementById('btnGenerateAI');
const loader = document.getElementById('aiLoading');
const resultBox = document.getElementById('aiResultBox');
loader.style.display = 'block';
resultBox.style.display = 'none';
btn.disabled = true;
btn.textContent = "⏳ Génération en cours...";
const formData = new FormData();
formData.append('depart', depart);
formData.append('destination', destination);
formData.append('duree', duree);
formData.append('theme', theme);
const urlAI = typeof AI_GENERATE_URL !== 'undefined' ? AI_GENERATE_URL : '/roadtrips/genererRoadtripGratuit';
const token = typeof CSRF_TOKEN !== 'undefined' ? CSRF_TOKEN : '';
try {
const response = await fetch(urlAI, {
method: 'POST',
headers: { 'X-CSRF-Token': token, 'X-Requested-With': 'XMLHttpRequest' },
body: formData
});
const result = await response.json();
if (result.success && result.data) {
const data = result.data;
const titleInput = document.getElementById('roadtripTitle');
const descInput = document.getElementById('roadtripDescription');
let shouldFill = true;
if (titleInput.value !== '' || descInput.value !== '') {
shouldFill = confirm("L'IA a généré un titre et une description. Voulez-vous remplacer votre texte actuel ?");
}
if (shouldFill) {
if (data.titre) titleInput.value = data.titre;
if (data.description) descInput.value = data.description;
titleInput.style.backgroundColor = "#d5f5e3";
setTimeout(() => { titleInput.style.backgroundColor = ""; }, 1500);
}
let htmlEtapes = '<ul>';
if (data.etapes && Array.isArray(data.etapes)) {
data.etapes.forEach(etape => {
htmlEtapes += `<li>
<strong>${etape.ville}</strong>
<br><small style="color:var(--gris_fonce)">👀 À voir : ${etape.lieux || etape.activites || 'Centre ville'}</small>
</li>`;
});
} else {
htmlEtapes += '<li>Aucune étape spécifique suggérée, trajet direct.</li>';
}
htmlEtapes += '</ul>';
document.getElementById('aiResultContent').innerHTML = htmlEtapes;
resultBox.style.display = 'block';
} else {
alert("L'IA n'a pas pu générer de résultat : " + (result.message || 'Erreur inconnue'));
}
} catch (error) {
console.error("Erreur Fetch IA:", error);
alert("Une erreur de connexion est survenue avec l'assistant IA.");
} finally {
loader.style.display = 'none';
btn.disabled = false;
btn.textContent = "🚀 Générer des idées";
}
}
// ============================================================
// 13. CHARGEMENT DU MODE ÉDITION
// ============================================================
async function loadExistingRoadTrip() {
for (let i = 0; i < EXISTING_TRAJETS.length; i++) {
const t = EXISTING_TRAJETS[i];
const startCoords = (t.departLat && t.departLon)
? [parseFloat(t.departLat), parseFloat(t.departLon)]
: await getCoordinate(t.depart);
const endCoords = (t.arriveeLat && t.arriveeLon)
? [parseFloat(t.arriveeLat), parseFloat(t.arriveeLon)]
: await getCoordinate(t.arrivee);
let sousEtapesWithCoords = [];
if (t.sousEtapes && t.sousEtapes.length > 0) {
for (const se of t.sousEtapes) {
const c = (se.lat && se.lon)
? [parseFloat(se.lat), parseFloat(se.lon)]
: await getCoordinate(se.nom);
if (c) {
sousEtapesWithCoords.push({ ...se, coords: c });
addMarker(se.nom, c, "sous_etape", `<b>${se.nom}</b><br>${se.heure}`);
}
}
}
if (startCoords && endCoords) {
addMarker(t.depart, startCoords, "ville", t.depart);
addMarker(t.arrivee, endCoords, "ville", t.arrivee);
await _ajouterSegmentEntre(
t.depart, startCoords,
t.arrivee, endCoords,
state.segments.length,
TRANSPORT_STRATEGIES[t.mode],
{ mode: t.mode, date_trajet: t.date_trajet || t.date, heure_depart: t.heure_depart, sousEtapes: sousEtapesWithCoords }
);
state.currentStartCity = t.arrivee;
state.currentStartCoords = endCoords;
}
}
if (state.segments.length > 0) {
const group = new L.featureGroup(state.segments.map(s => s.line));
state.map.fitBounds(group.getBounds(), { padding: [50, 50] });
}
}
// ============================================================
// 14. BINDING DES ÉVÉNEMENTS
// ============================================================
function bindEvents() {
const regionSelect = document.getElementById('regionSelect');
if (regionSelect) {
regionSelect.addEventListener('change', (e) => {
state.currentRegion = e.target.value;
const config = REGIONS_CONFIG[state.currentRegion];
state.map.flyTo(config.center, config.zoom, { duration: 1.5 });
});
}
const statusSelect = document.getElementById('roadtripStatut');
const visibilitySelect = document.getElementById('roadtripVisibilite');
if (visibilitySelect) visibilitySelect.disabled = false;
if (statusSelect) {
statusSelect.addEventListener('change', () => {
if (visibilitySelect) visibilitySelect.disabled = false;
});
}
const btnAddSegment = document.getElementById('btnAddSegment');
if (btnAddSegment) {
btnAddSegment.addEventListener('click', openNewSegmentForm);
}
document.getElementById('legendList').addEventListener('click', (e) => {
if (e.target.classList.contains('modifierSousEtapes')) {
openSegmentEditor(e.target.closest('li').dataset.index);
}
if (e.target.classList.contains('toggleSousEtapes')) {
const ul = e.target.closest('li').querySelector('.sousEtapesList');
ul.style.display = (ul.style.display === 'none') ? 'block' : 'none';
}
const removeBtn = e.target.closest('.remove-segment-btn');
if (removeBtn) {
const segmentLi = removeBtn.closest('li');
const currentIndex = parseInt(segmentLi.dataset.index);
const allSegments = Array.from(document.querySelectorAll('#legendList > li:not(.fade-out-item)'));
const currentDomIndex = allSegments.indexOf(segmentLi);
if (currentDomIndex < allSegments.length - 1) {
showToast("Veuillez d'abord supprimer les trajets suivants pour ne pas briser l'itinéraire.");
return;
}
segmentLi.classList.add('fade-out-item');
removeSegmentFromMap(currentIndex);
setTimeout(() => segmentLi.remove(), 300);
}
});
document.getElementById('addSubEtape').onclick = () => addSousEtapeForm(document.getElementById('subEtapesContainer'));
document.getElementById('closeSegmentForm').onclick = () => {
document.getElementById('segmentFormContainer').style.display = 'none';
};
document.getElementById('saveSegment').onclick = async () => {
const seg = state.segments[state.currentSegmentIndex];
const newSubs = [];
for (const div of document.querySelectorAll('.subEtape')) {
const inputNom = div.querySelector('.subEtapeNom');
const nom = inputNom.value.trim();
const heure = div.querySelector('.subEtapeHeure').value;
if (!nom || !heure) continue;
const containerEl = div.querySelector('.subEtapeEditorContainer');
let remarque = "";
if (containerEl && state.subStepEditors[containerEl.id]) {
remarque = state.subStepEditors[containerEl.id].getMarkdown();
}
const latAttr = inputNom.getAttribute('data-lat');
const lonAttr = inputNom.getAttribute('data-lon');
const coords = (latAttr && lonAttr) ? [parseFloat(latAttr), parseFloat(lonAttr)] : await getCoordinate(nom);
if (coords) {
newSubs.push({ nom, heure, remarque, coords, lat: coords[0], lon: coords[1] });
addMarker(nom, coords, "sous_etape", `<b>${nom}</b><br>${heure}`);
} else {
alert(`Impossible de localiser : ${nom}. Veuillez sélectionner une suggestion.`);
}
}
seg.sousEtapes = newSubs;
await updateRouteSegment(state.currentSegmentIndex, seg.modeTransport || 'Voiture');
document.getElementById('segmentFormContainer').style.display = 'none';
};
const saveBtn = document.getElementById('saveRoadtrip');
if (saveBtn) saveBtn.onclick = handleSaveRoadtrip;
const btnGenerateAI = document.getElementById('btnGenerateAI');
if (btnGenerateAI) btnGenerateAI.addEventListener('click', handleGenerateAI);
}
// ============================================================
// 15. POINT D'ENTRÉE
// ============================================================
/**
* Point d'entrée principal initialisant la carte, les favoris et les événements.
* @async
* @function init
* @returns {Promise<void>}
*/
async function init() {
initRoadTripMap();
await fetchUserFavorites();
loadMapFavorites();
state.currentStartCity = (typeof USER_DEFAULT_CITY !== 'undefined') ? USER_DEFAULT_CITY : "";
if (typeof MODE_EDITION !== 'undefined' && MODE_EDITION === true && typeof EXISTING_TRAJETS !== 'undefined') {
await loadExistingRoadTrip();
} else if (state.currentStartCity) {
const coords = await getCoordinate(state.currentStartCity);
if (coords) {
state.currentStartCoords = coords;
addMarker(state.currentStartCity, coords, "ville", `Départ : ${state.currentStartCity}`);
state.map.setView(coords, 10);
}
}
bindEvents();
}
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('map')) {
init();
}
});