BROOKO icon
BROOKO UK NETWORK
Where code meets creativity & adventure
File viewer

map.php

Type
php
Size
26.7 KB
Modified
15 May
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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"; ?>