feat(voyage): base de données péages 2026 (24 680 paires, 5 opérateurs) #3
@@ -1398,3 +1398,10 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
#tripMap {
|
#tripMap {
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Toll cost estimator panel --- */
|
||||||
|
.hol-cost-stat { background: var(--bg-page, #f8fafc); border: 1px solid var(--border-light, #e2e8f0); border-radius: 10px; padding: .75rem 1rem; text-align: center; }
|
||||||
|
.hol-cost-stat-val { font-size: 1.2rem; font-weight: 700; color: var(--text-main, #0f172a); }
|
||||||
|
.hol-cost-stat-label { font-size: .72rem; color: var(--text-muted, #64748b); margin-top: 2px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.hol-cost-total { border-color: var(--primary, #4361ee); }
|
||||||
|
.hol-cost-total .hol-cost-stat-val { color: var(--primary, #4361ee); }
|
||||||
|
|||||||
@@ -261,6 +261,22 @@ $pctSaved = $cost > 0 ? min(100 - $pctPaid, ($saved / $cost) * 100) : 0;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (count($mapPoints) >= 2): ?>
|
||||||
|
<div class="hol-summary-card" id="hol-cost-panel" style="margin-top:24px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:16px;">
|
||||||
|
<h3 style="margin:0; display:flex; align-items:center; gap:8px;">🚗 Coût du trajet</h3>
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
<label style="font-size:.82rem; color:var(--text-muted);">Conso (L/100km)</label>
|
||||||
|
<input type="number" id="fuel-l100" value="7" min="3" max="30" step="0.5" style="width:65px; padding:.3rem .5rem; border:1px solid var(--border-light); border-radius:8px; font-size:.85rem;">
|
||||||
|
<label style="font-size:.82rem; color:var(--text-muted);">Prix carburant (€/L)</label>
|
||||||
|
<input type="number" id="fuel-price" value="1.85" min="1" max="4" step="0.01" style="width:65px; padding:.3rem .5rem; border:1px solid var(--border-light); border-radius:8px; font-size:.85rem;">
|
||||||
|
<button onclick="estimateTripCost()" class="pf-btn pf-btn-small">Calculer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="hol-cost-result" style="color:var(--text-muted); font-size:.9rem;">Cliquez sur Calculer pour estimer le coût du trajet.</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!empty($holiday['notes'])): ?>
|
<?php if (!empty($holiday['notes'])): ?>
|
||||||
<div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;">
|
<div class="hol-summary-card" style="margin-top: 24px; padding: 25px; border-left: 5px solid #f59e0b;">
|
||||||
<h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;">
|
<h3 style="margin: 0 0 15px 0; font-size: 1.2rem; color: #0f172a; display: flex; align-items: center; gap: 8px;">
|
||||||
@@ -415,6 +431,63 @@ window.closePlanningModal = window.closePlanningModal || function() {
|
|||||||
if(modal) modal.style.display = 'none';
|
if(modal) modal.style.display = 'none';
|
||||||
document.body.classList.remove('no-scroll');
|
document.body.classList.remove('no-scroll');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function estimateTripCost() {
|
||||||
|
const l100 = parseFloat(document.getElementById('fuel-l100').value) || 7;
|
||||||
|
const price = parseFloat(document.getElementById('fuel-price').value) || 1.85;
|
||||||
|
const result = document.getElementById('hol-cost-result');
|
||||||
|
result.innerHTML = '⏳ Calcul en cours…';
|
||||||
|
|
||||||
|
const stops = (window.MAP_POINTS || []).map(p => ({lat: p.lat, lng: p.lng, name: p.location_name}));
|
||||||
|
if (stops.length < 2) { result.innerHTML = 'Pas assez d\'étapes pour calculer.'; return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/modules/voyage/api.php?action=estimate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({stops, fuel_l100: l100, fuel_price: price})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.ok) { result.innerHTML = 'Erreur : ' + (data.error || 'inconnue'); return; }
|
||||||
|
|
||||||
|
let html = '<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:16px;">';
|
||||||
|
html += `<div class="hol-cost-stat"><div class="hol-cost-stat-val">${Math.round(data.total_km)} km</div><div class="hol-cost-stat-label">Distance totale</div></div>`;
|
||||||
|
html += `<div class="hol-cost-stat"><div class="hol-cost-stat-val">${data.total_toll.toFixed(2)} €</div><div class="hol-cost-stat-label">Péages estimés</div></div>`;
|
||||||
|
html += `<div class="hol-cost-stat"><div class="hol-cost-stat-val">${data.fuel_cost.toFixed(2)} €</div><div class="hol-cost-stat-label">Carburant</div></div>`;
|
||||||
|
html += `<div class="hol-cost-stat hol-cost-total"><div class="hol-cost-stat-val">${data.grand_total.toFixed(2)} €</div><div class="hol-cost-stat-label">Total aller</div></div>`;
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (data.segments && data.segments.length) {
|
||||||
|
html += '<div style="border-top:1px solid var(--border-light); padding-top:12px; margin-top:4px;">';
|
||||||
|
html += '<div style="font-size:.75rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--text-muted); margin-bottom:10px;">Détail du trajet</div>';
|
||||||
|
data.segments.forEach(s => {
|
||||||
|
html += `<div style="padding:8px 0; border-bottom:1px solid var(--border-light);">`;
|
||||||
|
html += `<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:4px;">
|
||||||
|
<span style="font-weight:600; font-size:.88rem;">📍 ${esc(s.from)} → ${esc(s.to)}</span>
|
||||||
|
<span style="font-size:.88rem; color:var(--primary);">péage : <strong>${s.toll.toFixed(2)} €</strong></span>
|
||||||
|
</div>`;
|
||||||
|
html += `<div style="font-size:.78rem; color:var(--text-muted);">🛣️ ${Math.round(s.distance_km)} km`;
|
||||||
|
if (s.entry_plaza && s.exit_plaza) {
|
||||||
|
html += ` · ${esc(s.entry_plaza)} → ${esc(s.exit_plaza)}`;
|
||||||
|
if (s.op) html += ` <span style="background:var(--bg-page); border:1px solid var(--border-light); border-radius:4px; padding:0 4px; font-size:.7rem;">${esc(s.op)}</span>`;
|
||||||
|
} else if (s.note) {
|
||||||
|
html += ` · <em>${esc(s.note)}</em>`;
|
||||||
|
}
|
||||||
|
html += '</div></div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
result.innerHTML = html;
|
||||||
|
} catch(e) {
|
||||||
|
result.innerHTML = 'Erreur de connexion.';
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/modules/holidays/holidays.js"></script>
|
<script src="/modules/holidays/holidays.js"></script>
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<?php
|
||||||
|
// modules/voyage/api.php
|
||||||
|
// Toll cost estimator API for HouseHub
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
if ($action !== 'estimate') {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Action inconnue']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Read POST body ---
|
||||||
|
$body = file_get_contents('php://input');
|
||||||
|
$params = json_decode($body, true);
|
||||||
|
if (!$params || empty($params['stops']) || count($params['stops']) < 2) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides (stops requis, min 2)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stops = $params['stops'];
|
||||||
|
$fuelL100 = (float)($params['fuel_l100'] ?? 7);
|
||||||
|
$fuelPrice= (float)($params['fuel_price'] ?? 1.85);
|
||||||
|
|
||||||
|
// --- Load databases ---
|
||||||
|
$tollsPath = __DIR__ . '/data/tolls.json';
|
||||||
|
$gpsPath = __DIR__ . '/data/toll_gps.json';
|
||||||
|
|
||||||
|
if (!file_exists($tollsPath)) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Base de péages introuvable']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tollsRaw = json_decode(file_get_contents($tollsPath), true);
|
||||||
|
$tollData = $tollsRaw['data'] ?? [];
|
||||||
|
|
||||||
|
$gpsData = [];
|
||||||
|
if (file_exists($gpsPath)) {
|
||||||
|
$gpsData = json_decode(file_get_contents($gpsPath), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Build lookup index: [op][entry][exit] => c1_price ---
|
||||||
|
$tollIndex = [];
|
||||||
|
foreach ($tollData as $row) {
|
||||||
|
$op = $row['op'];
|
||||||
|
$e = $row['e'];
|
||||||
|
$x = $row['x'];
|
||||||
|
$c1 = (float)$row['c1'];
|
||||||
|
$tollIndex[$op][$e][$x] = $c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: great-circle distance (km) ---
|
||||||
|
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
|
||||||
|
$R = 6371.0;
|
||||||
|
$dLat = deg2rad($lat2 - $lat1);
|
||||||
|
$dLng = deg2rad($lng2 - $lng1);
|
||||||
|
$a = sin($dLat/2)**2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
|
||||||
|
return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Find plazas near a coordinate (within $maxKm) ---
|
||||||
|
// Returns array of [plaza_name => distance_km]
|
||||||
|
function plazasNear(array $gpsData, float $lat, float $lng, float $maxKm): array {
|
||||||
|
$result = [];
|
||||||
|
foreach ($gpsData as $name => $info) {
|
||||||
|
$d = haversine($lat, $lng, $info['lat'], $info['lng']);
|
||||||
|
if ($d <= $maxKm) {
|
||||||
|
$result[$name] = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
asort($result);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Look up toll price for (op, entry, exit) with fallback ---
|
||||||
|
function lookupToll(array $tollIndex, string $op, string $entry, string $exit): ?float {
|
||||||
|
// Direct lookup
|
||||||
|
if (isset($tollIndex[$op][$entry][$exit])) {
|
||||||
|
return $tollIndex[$op][$entry][$exit];
|
||||||
|
}
|
||||||
|
// Reversed
|
||||||
|
if (isset($tollIndex[$op][$exit][$entry])) {
|
||||||
|
return $tollIndex[$op][$exit][$entry];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OSRM route distance ---
|
||||||
|
function getRouteDistanceKm(float $lat1, float $lng1, float $lat2, float $lng2): ?float {
|
||||||
|
$url = sprintf(
|
||||||
|
'https://router.project-osrm.org/route/v1/driving/%f,%f;%f,%f?overview=false',
|
||||||
|
$lng1, $lat1, $lng2, $lat2
|
||||||
|
);
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => 10,
|
||||||
|
'header' => "User-Agent: HouseHub/1.0\r\n"
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
$resp = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($resp === false) return null;
|
||||||
|
$data = json_decode($resp, true);
|
||||||
|
if (!empty($data['routes'][0]['distance'])) {
|
||||||
|
return (float)$data['routes'][0]['distance'] / 1000.0;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main: process each consecutive stop pair ---
|
||||||
|
$segments = [];
|
||||||
|
$totalKm = 0.0;
|
||||||
|
$totalToll = 0.0;
|
||||||
|
|
||||||
|
for ($i = 0; $i < count($stops) - 1; $i++) {
|
||||||
|
$orig = $stops[$i];
|
||||||
|
$dest = $stops[$i + 1];
|
||||||
|
|
||||||
|
$oLat = (float)$orig['lat'];
|
||||||
|
$oLng = (float)$orig['lng'];
|
||||||
|
$dLat = (float)$dest['lat'];
|
||||||
|
$dLng = (float)$dest['lng'];
|
||||||
|
$fromName = $orig['name'] ?? "Étape " . ($i + 1);
|
||||||
|
$toName = $dest['name'] ?? "Étape " . ($i + 2);
|
||||||
|
|
||||||
|
// Distance via OSRM
|
||||||
|
$distKm = getRouteDistanceKm($oLat, $oLng, $dLat, $dLng);
|
||||||
|
if ($distKm === null) {
|
||||||
|
// Fallback: straight-line distance * 1.25
|
||||||
|
$distKm = haversine($oLat, $oLng, $dLat, $dLng) * 1.25;
|
||||||
|
}
|
||||||
|
$totalKm += $distKm;
|
||||||
|
|
||||||
|
// --- Find toll ---
|
||||||
|
$segToll = 0.0;
|
||||||
|
$entryPlaza = null;
|
||||||
|
$exitPlaza = null;
|
||||||
|
$tollNote = null;
|
||||||
|
|
||||||
|
if (!empty($gpsData)) {
|
||||||
|
// Find plazas near origin (80km) and destination (80km)
|
||||||
|
$nearOrig = plazasNear($gpsData, $oLat, $oLng, 80.0);
|
||||||
|
$nearDest = plazasNear($gpsData, $dLat, $dLng, 80.0);
|
||||||
|
|
||||||
|
$bestToll = null;
|
||||||
|
$bestDistSum = PHP_FLOAT_MAX;
|
||||||
|
$bestEntry = null;
|
||||||
|
$bestExit = null;
|
||||||
|
$bestOp = null;
|
||||||
|
|
||||||
|
// Try each operator
|
||||||
|
foreach ($tollIndex as $op => $opEntries) {
|
||||||
|
// Find candidate entry plazas (near origin, present in this operator)
|
||||||
|
$candidateEntries = [];
|
||||||
|
foreach ($nearOrig as $pName => $dist) {
|
||||||
|
if (isset($opEntries[$pName]) || isset(array_flip(array_keys($opEntries))[$pName])) {
|
||||||
|
// Plaza exists as entry in this operator
|
||||||
|
if (isset($opEntries[$pName])) {
|
||||||
|
$candidateEntries[$pName] = $dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check if it's an exit plaza (reversed lookup later)
|
||||||
|
// Include any plaza from this operator that is within range
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build set of all plazas for this operator
|
||||||
|
$opPlazas = [];
|
||||||
|
foreach ($opEntries as $entry => $exits) {
|
||||||
|
$opPlazas[$entry] = true;
|
||||||
|
foreach ($exits as $exit => $price) {
|
||||||
|
$opPlazas[$exit] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter nearOrig and nearDest to only plazas in this operator
|
||||||
|
$opNearOrig = array_intersect_key($nearOrig, $opPlazas);
|
||||||
|
$opNearDest = array_intersect_key($nearDest, $opPlazas);
|
||||||
|
|
||||||
|
if (empty($opNearOrig) || empty($opNearDest)) continue;
|
||||||
|
|
||||||
|
// Try best 5 entry and best 5 exit candidates
|
||||||
|
$topEntries = array_slice($opNearOrig, 0, 5, true);
|
||||||
|
$topExits = array_slice($opNearDest, 0, 5, true);
|
||||||
|
|
||||||
|
foreach ($topEntries as $ePlaza => $eDist) {
|
||||||
|
foreach ($topExits as $xPlaza => $xDist) {
|
||||||
|
if ($ePlaza === $xPlaza) continue;
|
||||||
|
$price = lookupToll($tollIndex, $op, $ePlaza, $xPlaza);
|
||||||
|
if ($price !== null) {
|
||||||
|
$distSum = $eDist + $xDist;
|
||||||
|
if ($distSum < $bestDistSum) {
|
||||||
|
$bestDistSum = $distSum;
|
||||||
|
$bestToll = $price;
|
||||||
|
$bestEntry = $ePlaza;
|
||||||
|
$bestExit = $xPlaza;
|
||||||
|
$bestOp = $op;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bestToll !== null) {
|
||||||
|
$segToll = $bestToll;
|
||||||
|
$entryPlaza = $bestEntry;
|
||||||
|
$exitPlaza = $bestExit;
|
||||||
|
} else {
|
||||||
|
$tollNote = 'péages non estimés pour ce trajet';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$tollNote = 'base GPS des péages non disponible';
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalToll += $segToll;
|
||||||
|
|
||||||
|
$seg = [
|
||||||
|
'from' => $fromName,
|
||||||
|
'to' => $toName,
|
||||||
|
'distance_km' => round($distKm, 1),
|
||||||
|
'toll' => round($segToll, 2),
|
||||||
|
];
|
||||||
|
if ($entryPlaza) $seg['entry_plaza'] = $entryPlaza;
|
||||||
|
if ($exitPlaza) $seg['exit_plaza'] = $exitPlaza;
|
||||||
|
if ($bestOp) $seg['op'] = $bestOp;
|
||||||
|
if ($tollNote) $seg['note'] = $tollNote;
|
||||||
|
|
||||||
|
$segments[] = $seg;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fuelCost = round(($totalKm / 100.0) * $fuelL100 * $fuelPrice, 2);
|
||||||
|
$grandTotal = round($totalToll + $fuelCost, 2);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'segments' => $segments,
|
||||||
|
'total_km' => round($totalKm, 1),
|
||||||
|
'total_toll' => round($totalToll, 2),
|
||||||
|
'fuel_cost' => $fuelCost,
|
||||||
|
'grand_total' => $grandTotal,
|
||||||
|
], JSON_UNESCAPED_UNICODE);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user