map.php
26.7 KB
<?php
require_once __DIR__ . '/../../bootstrap.php';
requirePermission('trutrak.view');
$gmapsKey = trim(getSystemInfo('trutrak_gmaps_key', ''));
$pageTitle = 'TruTrak Map';
include __DIR__ . '/../../partials/header.php';
?>
<div class="content-header">
<div>
<h1 class="content-title">πΊοΈ TruTrak Map</h1>
<p class="content-subtitle">Live locations on the map</p>
</div>
<div class="d-flex gap-2">
<?php if (hasPermission('trutrak.manage')): ?>
<a class="btn btn-secondary" href="<?= e(app_url('admin/trutrak')) ?>">βοΈ Connector</a>
<?php endif; ?>
</div>
</div>
<style>
.tt-map-shell{ position:relative; }
/* Overlay panel (filters + status) */
.tt-map-panel{
position:absolute; top:12px; left:12px; z-index:1006;
width:min(440px, calc(100% - 24px));
background: rgba(12, 14, 18, .86);
border:1px solid var(--border);
border-radius:14px;
padding:10px;
backdrop-filter: blur(10px);
box-shadow: 0 10px 30px rgba(0,0,0,.35);
}
.tt-map-panel .tt-row{ display:flex; gap:10px; align-items:center; }
.tt-map-search{ flex:1; position:relative; }
.tt-map-search input{ padding-left:34px; }
.tt-map-search .icon{ position:absolute; left:10px; top:50%; transform:translateY(-50%); opacity:.8; }
.tt-map-panel .tt-adv{ display:none; margin-top:10px; }
.tt-map-panel.open .tt-adv{ display:block; }
.tt-map-panel .tt-status{ display:flex; justify-content:space-between; gap:12px; margin-top:10px; font-size:.85rem; color:var(--text-muted); }
.tt-map-panel .tt-status strong{ color:var(--text-main); font-weight:900; }
/* Map overlay buttons */
.tt-map-controls{ position:absolute; top:12px; right:12px; z-index:1006; display:flex; gap:8px; }
.tt-map-controls .btn{ padding:10px 12px; line-height:1; font-weight:800; background: rgba(12,14,18,.86); border:1px solid var(--border); }
.tt-map-controls .btn:hover{ filter:brightness(1.08); }
/* Leaflet controls to match app */
.leaflet-control-zoom a{
background: rgba(12,14,18,.86) !important;
color: var(--text-main) !important;
border:1px solid var(--border) !important;
width:34px !important; height:34px !important;
line-height:34px !important;
border-radius:10px !important;
}
.leaflet-control-zoom{ box-shadow:none !important; }
/* Fullscreen */
#ttMapWrap.tt-fullscreen{ height:100vh; padding:12px; background: var(--bg, #0b0f14); }
#ttMapWrap.tt-fullscreen #ttMapCard{ height:100%; }
#ttMapWrap.tt-fullscreen #ttMapCard .card-body{ height:100%; }
#ttMapWrap.tt-fullscreen #ttMap{ height:100% !important; min-height:0 !important; }
#ttMapWrap.tt-fullscreen #ttMapErr{ margin-top:0; }
@media (max-width: 900px){
/* On mobile/tablet keep panel readable */
.tt-map-panel{ position:relative; width:100%; top:auto; left:auto; margin-bottom:12px; }
.tt-map-controls{ top:10px; right:10px; }
}
</style>
<div id="ttMapWrap">
<?php if ($gmapsKey !== ''): ?>
<script src="https://maps.googleapis.com/maps/api/js?key=<?= e($gmapsKey) ?>"></script>
<?php endif; ?>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<?php if ($gmapsKey !== ''): ?>
<script src="https://unpkg.com/leaflet.gridlayer.googlemutant@0.13.0/Leaflet.GoogleMutant.js"></script>
<?php endif; ?>
<div class="card" id="ttMapCard">
<div class="card-body tt-map-shell" style="padding:0;">
<div class="tt-map-panel" id="ttMapPanel">
<div class="tt-row">
<div class="tt-map-search">
<span class="icon">π</span>
<input id="ttMapSearch" class="input" type="text" placeholder="Search: plate / vehicle / device idβ¦" />
</div>
<button class="btn btn-secondary" id="ttMapPanelToggle" title="Show/Hide filters" type="button">βοΈ</button>
</div>
<div class="tt-adv" id="ttMapPanelAdv">
<div class="d-flex gap-2" style="flex-wrap:wrap;align-items:center;">
<label class="d-flex align-center gap-2" style="cursor:pointer;">
<input type="checkbox" id="ttMapMovingOnly" style="transform:translateY(1px);" />
<span class="text-muted">Moving only</span>
</label>
<label class="d-flex align-center gap-2" style="cursor:pointer;">
<input type="checkbox" id="ttMapShowGeofences" style="transform:translateY(1px);" checked />
<span class="text-muted">Geofences</span>
</label>
<div style="min-width:220px;flex:1;">
<label class="field-label" for="ttMapSeenWithin" style="margin:0 0 6px 0;">Last seen</label>
<select id="ttMapSeenWithin" class="input">
<option value="all">Any time</option>
<option value="300">Last 5 minutes</option>
<option value="1800">Last 30 minutes</option>
<option value="7200">Last 2 hours</option>
<option value="86400">Last 24 hours</option>
</select>
</div>
</div>
</div>
<div class="tt-status">
<div>Last update <strong id="ttMapLast">β</strong></div>
<div>Showing <strong id="ttMapCount">0</strong></div>
</div>
</div>
<div class="tt-map-controls">
<button class="btn btn-secondary" id="ttMapRefresh" title="Refresh" type="button">π</button>
<button class="btn btn-secondary" id="ttMapFit" title="Fit to vehicles" type="button">β€’</button>
<button class="btn btn-secondary" id="ttMapFullscreen" title="Fullscreen" type="button">βΆ</button>
</div>
<div id="ttMap" style="height:78vh; min-height:520px;"></div>
</div>
</div>
<div id="ttMapErr" class="alert alert-error" style="display:none;margin-top:12px;"></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
const err = document.getElementById('ttMapErr');
const btn = document.getElementById('ttMapRefresh');
const fitBtn = document.getElementById('ttMapFit');
const fsBtn = document.getElementById('ttMapFullscreen');
const wrap = document.getElementById('ttMapWrap');
const panel = document.getElementById('ttMapPanel');
const panelToggle = document.getElementById('ttMapPanelToggle');
const qSearch = document.getElementById('ttMapSearch');
const movingOnly = document.getElementById('ttMapMovingOnly');
const showGeofences = document.getElementById('ttMapShowGeofences');
const seenWithin = document.getElementById('ttMapSeenWithin');
const elLast = document.getElementById('ttMapLast');
const elCount = document.getElementById('ttMapCount');
// Filters panel toggle
if (panelToggle && panel) {
panelToggle.addEventListener('click', ()=>{
panel.classList.toggle('open');
});
}
// No saved view / no saved filters (requested)
// Map init
const map = L.map('ttMap');
const GMAPS_KEY = <?= json_encode($gmapsKey) ?>;
let baseLayer = null;
try {
if (GMAPS_KEY && typeof google !== 'undefined' && L.gridLayer && typeof L.gridLayer.googleMutant === 'function') {
baseLayer = L.gridLayer.googleMutant({ type: 'roadmap' });
baseLayer.addTo(map);
}
} catch(e) {}
if (!baseLayer) {
// Clean, Google-like basemap (no API key needed)
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
subdomains: 'abcd'
}).addTo(map);
}
let layer = L.layerGroup().addTo(map);
// Fullscreen
function fsElement(){
return document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement || null;
}
function isFullscreen(){
return !!wrap && fsElement() === wrap;
}
async function enterFullscreen(){
if(!wrap) return;
try{
if (wrap.requestFullscreen) return await wrap.requestFullscreen();
if (wrap.webkitRequestFullscreen) return wrap.webkitRequestFullscreen();
if (wrap.mozRequestFullScreen) return wrap.mozRequestFullScreen();
if (wrap.msRequestFullscreen) return wrap.msRequestFullscreen();
}catch(e){}
}
async function exitFullscreen(){
try{
if (document.exitFullscreen) return await document.exitFullscreen();
if (document.webkitExitFullscreen) return document.webkitExitFullscreen();
if (document.mozCancelFullScreen) return document.mozCancelFullScreen();
if (document.msExitFullscreen) return document.msExitFullscreen();
}catch(e){}
}
async function toggleFullscreen(){
if(isFullscreen()) return exitFullscreen();
return enterFullscreen();
}
function syncFullscreenUI(){
const on = isFullscreen();
if(wrap) wrap.classList.toggle('tt-fullscreen', on);
if(fsBtn){
fsBtn.title = on ? 'Exit fullscreen' : 'Fullscreen';
fsBtn.textContent = on ? 'π' : 'βΆ';
}
setTimeout(()=>{ try{ map.invalidateSize(); }catch(e){} }, 150);
}
document.addEventListener('fullscreenchange', syncFullscreenUI);
document.addEventListener('webkitfullscreenchange', syncFullscreenUI);
document.addEventListener('mozfullscreenchange', syncFullscreenUI);
document.addEventListener('MSFullscreenChange', syncFullscreenUI);
if(fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
// Geofences overlay (polygons + centroid markers)
let waypointLayer = L.layerGroup().addTo(map);
let waypointRows = [];
let waypointsLoaded = false;
let waypointFocusDone = false;
function pick(row, keys){
if(!row || typeof row!=="object") return "";
for (const k of keys){
if (row[k] != null && String(row[k]).trim() !== "") return row[k];
}
return "";
}
function waypointColorFromRow(row){
const raw = String(pick(row, ["COLOUR","colour","color","COLOUR_NAME","colour_name"]) || "").toLowerCase();
if(raw.includes("amber") || raw.includes("orange")) return "#ff9f0a";
if(raw.includes("green")) return "#34c759";
if(raw.includes("red")) return "#ff3b30";
if(raw.includes("blue")) return "#0a84ff";
if(raw.includes("grey") || raw.includes("gray")) return "#a1a1aa";
return "#60a5fa";
}
function rgbaFromHex(hex, alpha){
try{
const h = String(hex||'').trim();
const m = h.match(/^#?([0-9a-f]{6})$/i);
if(!m) return `rgba(255,140,26,${alpha})`;
const n = parseInt(m[1], 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return `rgba(${r},${g},${b},${alpha})`;
}catch(e){
return `rgba(255,140,26,${alpha})`;
}
}
function makeVanIcon(color){
const bg = rgbaFromHex(color, 0.22);
const glow = rgbaFromHex(color, 0.35);
return L.divIcon({
className: '',
html: `<div class="tt-pin tt-van" style="--c:${esc(color)};--glow:${esc(glow)};background:${esc(bg)};"><span>π</span></div>`,
iconSize: [36,36],
iconAnchor: [18,18]
});
}
function geofenceIconEmoji(row){
const icon = pick(row, ['_icon','icon','ICON','meta_icon','META_ICON']);
if(icon) return String(icon).trim();
const cat = String(pick(row, ['_category','category','CATEGORY','meta_category','META_CATEGORY'])||'').toLowerCase();
if(cat === 'office') return 'π’';
if(cat === 'yard') return 'π';
return 'π';
}
function makeGeofenceIcon(color, emoji){
const bg = rgbaFromHex(color, 0.18);
const glow = rgbaFromHex(color, 0.28);
return L.divIcon({
className: '',
html: `<div class="tt-pin tt-gf" style="--c:${esc(color)};--glow:${esc(glow)};background:${esc(bg)};"><span>${esc(emoji)}</span></div>`,
iconSize: [32,32],
iconAnchor: [16,16]
});
}
function parseWktPolygon(wkt){
if(!wkt) return null;
const s = String(wkt).trim();
if(!s) return null;
// Expected: POLYGON ((lon lat, lon lat, ...))
const m = s.match(/POLYGON\s*\(\((.+?)\)\)/i);
if(!m) return null;
const body = m[1];
const pts = [];
body.split(",").forEach(pair=>{
const parts = pair.trim().split(/\s+/);
if(parts.length>=2){
const lon = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
if(isFinite(lat) && isFinite(lon)) pts.push([lat, lon]);
}
});
return pts.length ? pts : null;
}
function polygonCenter(latlngs){
if(!latlngs || !latlngs.length) return null;
let minLat=latlngs[0][0], maxLat=latlngs[0][0], minLng=latlngs[0][1], maxLng=latlngs[0][1];
latlngs.forEach(p=>{
minLat=Math.min(minLat,p[0]); maxLat=Math.max(maxLat,p[0]);
minLng=Math.min(minLng,p[1]); maxLng=Math.max(maxLng,p[1]);
});
return [(minLat+maxLat)/2, (minLng+maxLng)/2];
}
function wpDisplayName(row){
const addr1 = pick(row, ["ADDRESS1","address1","Address1"]);
const addr2 = pick(row, ["ADDRESS2","address2","Address2"]);
const town = pick(row, ["TOWN","town","Town"]);
const pc = pick(row, ["POSTCODE","postcode","Postcode"]);
const parts = [addr1, addr2, town, pc].map(x=>String(x||"").trim()).filter(Boolean);
return parts.join(", ") || "Geofence";
}
function openGeofenceCard(row){
if (typeof Modal === "undefined") return;
const wkt = pick(row, ["GEO_COORDINATES","geo_coordinates","Geo_Coordinates","GEO_COORDINATE","geo"]);
const poly = parseWktPolygon(wkt);
const center = poly ? polygonCenter(poly) : null;
const name = wpDisplayName(row);
const created = pick(row, ["DTCREATED","dtcreated","created","DT_CREATED"]);
const centroidLat = pick(row, ["CENTROID_LATITUDE","centroid_latitude","Centroid_Latitude"]);
const centroidLon = pick(row, ["CENTROID_LONGITUDE","centroid_longitude","Centroid_Longitude"]);
const cLat = centroidLat || (center ? center[0].toFixed(6) : "");
const cLon = centroidLon || (center ? center[1].toFixed(6) : "");
const content = `
<div>
<div style="font-weight:900;font-size:1.1rem;">${esc(name)}</div>
${created ? `<div class="text-muted" style="margin-top:4px;">Created: ${esc(created)}</div>` : ""}
<div style="margin-top:10px;">
<div class="text-muted" style="font-size:.9rem;">Centroid</div>
<div style="font-weight:800;">${esc(cLat || "β")}, ${esc(cLon || "β")}</div>
</div>
<div style="margin-top:10px;">
<div class="text-muted" style="font-size:.9rem;">Boundary (WKT)</div>
<textarea class="input" style="width:100%;height:160px;font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;" readonly>${wkt ? wkt : ""}</textarea>
</div>
</div>`;
const modal = new Modal({
title: "Geofence",
content: content,
size: "large",
buttons: [
{ text: "Copy Boundary", class: "btn-secondary", onClick: async ()=>{ try{ await navigator.clipboard.writeText(String(wkt||"")); }catch(e){} } },
{ text: "Zoom To", class: "btn-secondary", onClick: (e,m)=>{ if(center){ map.setView(center, 15, {animate:true}); } m.close(); } },
{ text: "Close", class: "btn-primary", onClick: (e,m)=>m.close() },
]
});
modal.open();
}
function renderGeofences(){
waypointLayer.clearLayers();
if(!showGeofences.checked) return;
(waypointRows||[]).forEach((row)=>{
const wkt = pick(row, ["GEO_COORDINATES","geo_coordinates","Geo_Coordinates","GEO_COORDINATE","geo"]);
const poly = parseWktPolygon(wkt);
if(!poly) return;
const color = waypointColorFromRow(row);
const pg = L.polygon(poly, { color: color, weight: 2, fillColor: color, fillOpacity: 0.15 });
pg.on("click", ()=> openGeofenceCard(row));
pg.addTo(waypointLayer);
const c = polygonCenter(poly);
if(c){
const emoji = geofenceIconEmoji(row);
const mk = L.marker(c, { icon: makeGeofenceIcon(color, emoji) });
mk.on("click", ()=> openGeofenceCard(row));
mk.addTo(waypointLayer);
}
});
}
async function loadGeofences(){
try{
const r = await fetch("/api/trutrak?action=geofences", {credentials: "same-origin"});
const j = await r.json();
if(!j.ok) throw new Error(j.error || "Geofences failed");
waypointRows = j.rows || [];
waypointsLoaded = true;
}catch(e){
waypointRows = [];
waypointsLoaded = false;
}
}
async function toggleGeofences(){
if(!showGeofences.checked){
waypointLayer.clearLayers();
return;
}
if(!waypointsLoaded){
await loadGeofences();
}
renderGeofences();
const params = new URLSearchParams(window.location.search);
const q = params.get("geofence") || params.get("waypoint");
if(q && !waypointFocusDone && waypointRows && waypointRows.length){
const qq = String(q).trim().toLowerCase();
const row = waypointRows.find(r=>{
const name = wpDisplayName(r).toLowerCase();
const id = String(pick(r,["ID","id","WAYPOINT_ID","waypoint_id","GEOFENCE_ID","geofence_id"])||"").toLowerCase();
return (id && id===qq) || name.includes(qq);
});
if(row){
const wkt = pick(row,["GEO_COORDINATES","geo_coordinates","Geo_Coordinates","GEO_COORDINATE","geo"]);
const poly = parseWktPolygon(wkt);
const c = poly ? polygonCenter(poly) : null;
if(c) map.setView(c, 15, {animate:true});
openGeofenceCard(row);
waypointFocusDone = true;
}
}
}
let allRows = [];
let markersById = new Map();
let didInitialFit = false;
let focusDone = false;
let viewLocked = false;
// Default view (no saved view)
map.setView([51.5074, -0.1278], 6);
function esc(s){ return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
function parseTime(v){
if(!v) return null;
try{
if(typeof v==='number') return v>1e12? v : v*1000;
const s=String(v).trim();
if(!s) return null;
if(/^[0-9]+$/.test(s)){
const n=parseInt(s,10);
return n>1e12? n : n*1000;
}
const d=new Date(s);
if(!isNaN(d.getTime())) return d.getTime();
}catch(e){}
return null;
}
function fmtTime(v){
const t=parseTime(v);
if(!t) return 'β';
return new Date(t).toLocaleString();
}
function computeStatus(d){
const sp = parseFloat(d.speed ?? d.spd ?? '0');
const last = d.deviceTime || d.fixTime || d.time || d.timestamp || d.serverTime || '';
const t = parseTime(last);
const age = t ? (Date.now() - t) : null;
if (age != null && age > 60*60*1000) {
return { label: 'Offline', badge: 'badge-danger' };
}
if (isFinite(sp) && sp > 0) {
return { label: 'Moving', badge: 'badge-success' };
}
return { label: 'Stationary', badge: 'badge-muted' };
}
function statusColor(d){
const sp=parseFloat(d.speed ?? d.spd ?? '0');
const st=String(d.status||'').toLowerCase();
const last = d.deviceTime || d.fixTime || d.time || d.timestamp || d.serverTime || '';
const t = parseTime(last);
const age = t ? (Date.now()-t) : null;
if(age!=null && age>60*60*1000) return '#ff3b30'; // offline-ish
if(st.includes('power') || st.includes('loss') || st.includes('alarm')) return '#ff9f0a';
if(isFinite(sp) && sp>0) return '#34c759';
return '#a1a1aa';
}
function markerColor(d){
return (d.tagColor || d.tag_color || '').toString().trim() || statusColor(d);
}
function rowMatches(d){
const q=(qSearch.value||'').trim().toLowerCase();
if(q){
const hay = [d.plateShort,d.plateFull,d.reg,d.name,d.uniqueId,d.deviceId,d.make,d.model].map(x=>String(x||'').toLowerCase()).join(' ');
if(!hay.includes(q)) return false;
}
if(movingOnly.checked){
const sp=parseFloat(d.speed ?? d.spd ?? '0');
if(!(isFinite(sp) && sp>0)) return false;
}
const within=parseInt(seenWithin.value||'all',10);
if(isFinite(within)){
const last = d.deviceTime || d.fixTime || d.time || d.timestamp || d.serverTime || '';
const t=parseTime(last);
if(!t) return false;
if((Date.now()-t) > within*1000) return false;
}
return true;
}
function openVehicleCard(d){
if (typeof Modal === 'undefined') {
alert('Modal system missing');
return;
}
const dev = String(d.deviceId ?? '');
const vehicleId = d.vehicleId ? String(d.vehicleId) : '';
const plate = (d.plateFull || d.plateShort || d.reg || d.name || (dev ? ('Asset ' + dev) : 'Vehicle'));
const mm = String(((d.make||'') + ' ' + (d.model||'')).trim());
const st = computeStatus(d);
const last = fmtTime(d.deviceTime || d.fixTime || d.time || d.timestamp || d.serverTime || '');
const addr = String(d.address || '').trim();
const speed = String(d.speed ?? d.spd ?? '');
const statusText = String(d.status || '').trim();
const lat = (d.latitude ?? d.lat);
const lon = (d.longitude ?? d.lon);
const historyBase = <?= json_encode(app_url('trutrak/map')) ?>;
const historyUrl = historyBase;
const calendarBase = <?= json_encode(app_url('calendar')) ?>;
const calendarUrl = vehicleId ? `${calendarBase}?van_id=${encodeURIComponent(vehicleId)}` : calendarBase;
const vehicleBase = <?= json_encode(app_url('management/vehicles')) ?>;
const vehicleUrl = vehicleId ? `${vehicleBase}?van_id=${encodeURIComponent(vehicleId)}` : vehicleBase;
const content = `
<div class="grid" style="grid-template-columns:1fr 1fr; gap:12px;">
<div>
<div class="text-muted" style="font-size:.9rem;">Vehicle</div>
<div style="font-weight:900;font-size:1.15rem;">${esc(plate)}</div>
${mm ? `<div class="text-muted" style="margin-top:2px;">${esc(mm)}</div>` : ''}
<div style="margin-top:10px;">
<span class="badge ${st.badge}">${esc(st.label)}</span>
${speed ? `<span class="badge badge-muted" style="margin-left:6px;">${esc(speed)} mph</span>` : ''}
</div>
</div>
<div>
<div class="text-muted" style="font-size:.9rem;">Last update</div>
<div style="font-weight:800;">${esc(last)}</div>
${statusText ? `<div class="text-muted" style="margin-top:6px;">${esc(statusText)}</div>` : ''}
${dev ? `<div class="text-muted" style="margin-top:6px;">Asset ${esc(dev)}</div>` : ''}
</div>
</div>
<div style="margin-top:12px;">
<div class="text-muted" style="font-size:.9rem;">Address</div>
<div style="font-weight:700;">${addr ? esc(addr) : 'β'}</div>
${(lat && lon) ? `<div class="text-muted" style="margin-top:6px;font-size:.9rem;">${esc(lat)}, ${esc(lon)}</div>` : ''}
</div>
`;
const modal = new Modal({
title: 'Vehicle Details',
content: content,
size: 'large',
buttons: [
{ text: 'History', class: 'btn-secondary', onClick: () => { window.location.href = historyUrl; } },
{ text: 'Open Calendar', class: 'btn-secondary', onClick: () => { window.location.href = calendarUrl; } },
{ text: 'Open Vehicle', class: 'btn-secondary', onClick: () => { window.location.href = vehicleUrl; } },
{ text: 'Close', class: 'btn-primary', onClick: (e, m) => m.close() }
]
});
modal.open();
}
function plot(){
layer.clearLayers();
markersById.clear();
const pts=[];
const shown=[];
allRows.forEach(d=>{ if(rowMatches(d)) shown.push(d); });
shown.forEach(d=>{
const lat=parseFloat(d.latitude ?? d.lat);
const lon=parseFloat(d.longitude ?? d.lon);
if(!isFinite(lat) || !isFinite(lon)) return;
pts.push([lat,lon]);
const color=markerColor(d);
const dev=String(d.deviceId||'');
const m=L.marker([lat,lon], { icon: makeVanIcon(color) });
m.on('click', ()=> openVehicleCard(d));
m.addTo(layer);
if(dev) markersById.set(dev, m);
});
elCount.textContent = String(shown.length);
const params=new URLSearchParams(window.location.search);
const focusDev=params.get('device_id');
if(focusDev && markersById.has(focusDev) && !focusDone){
const m=markersById.get(focusDev);
map.setView(m.getLatLng(), 15, {animate:true});
// Find the data row for the modal
const row = shown.find(x => String(x.deviceId||'') === String(focusDev));
if (row) openVehicleCard(row);
focusDone = true;
viewLocked = true;
return;
}
// Only auto-fit ONCE (first load) unless the view is locked (e.g. deep-linked focus).
if(!viewLocked && !didInitialFit && pts.length){
map.fitBounds(pts, {padding:[30,30]});
didInitialFit = true;
}
}
async function loadCache(){
try{
const r=await fetch(<?= json_encode(app_api_url('trutrak?action=cache')) ?>,{credentials:'same-origin'});
const j=await r.json();
if(j.ok){
const rows=j.devices||j.rows||j.items||[];
if(rows.length){
allRows = rows;
if(j.updated_at) elLast.textContent = new Date(j.updated_at*1000).toLocaleString();
plot();
}
}
}catch(e){}
}
async function load(){
err.style.display='none'; err.textContent='';
try{
const r=await fetch(<?= json_encode(app_api_url('trutrak?action=live')) ?>,{credentials:'same-origin'});
const j=await r.json();
if(!j.ok) throw new Error(j.error || 'Request failed');
allRows = j.devices||j.rows||[];
elLast.textContent = j.updated_at ? new Date(j.updated_at*1000).toLocaleString() : new Date().toLocaleString();
// Info banner if nothing shows due to filtering (only mapped + active vehicles are shown)
if(Array.isArray(allRows) && allRows.length===0 && j.stats && (j.stats.hidden_unlinked>0 || j.stats.hidden_inactive>0)) {
err.style.display='block';
err.className='alert alert-warning';
err.textContent=`Nothing is showing because only TruTrak devices linked to an ACTIVE vehicle are displayed. Go to Administration β TruTrak Admin to manage the device mapping. (Hidden: unlinked ${j.stats.hidden_unlinked||0}, inactive ${j.stats.hidden_inactive||0})`;
} else {
err.className='alert alert-error';
}
plot();
}catch(e){
err.style.display='block';
err.textContent=String(e.message||e);
}
}
function fit(){
const pts=[];
allRows.forEach(d=>{
if(!rowMatches(d)) return;
const lat=parseFloat(d.latitude ?? d.lat);
const lon=parseFloat(d.longitude ?? d.lon);
if(!isFinite(lat) || !isFinite(lon)) return;
pts.push([lat,lon]);
});
if(pts.length) map.fitBounds(pts, {padding:[30,30]});
}
btn.addEventListener('click', load);
fitBtn.addEventListener('click', fit);
qSearch.addEventListener('input', ()=>{ plot(); });
movingOnly.addEventListener('change', ()=>{ plot(); });
showGeofences.addEventListener('change', ()=>{ toggleGeofences(); });
seenWithin.addEventListener('change', ()=>{ plot(); });
loadCache().then(load);
toggleGeofences();
setInterval(()=>{ if(document.visibilityState==='visible') load(); }, 15000);
});
</script>
<?php include __DIR__ . "/../../partials/footer.php"; ?>