352 lines
16 KiB
Plaintext
352 lines
16 KiB
Plaintext
<div class="space-y-6">
|
|
<div class="space-y-6" wire:key="live-tracking-component">
|
|
{{-- Page Header --}}
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<flux:heading size="lg">Live Tracking</flux:heading>
|
|
<flux:subheading>Real-time GPS tracking and monitoring</flux:subheading>
|
|
</div>
|
|
<div class="flex space-x-3">
|
|
<flux:button wire:click="refreshData" variant="ghost" size="sm" icon="arrow-path">
|
|
Refresh
|
|
</flux:button>
|
|
<flux:button wire:click="toggleAutoRefresh" variant="{{ $autoRefresh ? 'primary' : 'ghost' }}" size="sm" icon="play">
|
|
{{ $autoRefresh ? 'Auto Refresh ON' : 'Auto Refresh OFF' }}
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{{-- Device List Panel --}}
|
|
<div class="lg:col-span-1">
|
|
<div class="bg-white shadow rounded-lg border border-zinc-200">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<flux:heading size="base">Devices</flux:heading>
|
|
<flux:button wire:click="toggleAllDevices" variant="ghost" size="xs">
|
|
{{ $allDevicesSelected ? 'Deselect All' : 'Select All' }}
|
|
</flux:button>
|
|
</div>
|
|
|
|
<div class="space-y-2 max-h-96 overflow-y-auto">
|
|
@foreach($devices as $device)
|
|
<div class="flex items-center justify-between p-3 rounded-lg border {{ in_array($device->id, $selectedDevices) ? 'border-blue-500 bg-blue-50' : 'border-gray-200' }}">
|
|
<div class="flex items-center space-x-3">
|
|
<input type="checkbox"
|
|
wire:click="toggleDevice({{ $device->id }})"
|
|
{{ in_array($device->id, $selectedDevices) ? 'checked' : '' }}
|
|
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
|
|
|
<div class="flex-1">
|
|
<div class="flex items-center space-x-2">
|
|
<span class="font-medium text-sm">{{ $device->name }}</span>
|
|
@php
|
|
$deviceData = collect($deviceDetails)->firstWhere('device_id', $device->id);
|
|
$status = $deviceData['status'] ?? 'offline';
|
|
@endphp
|
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium
|
|
{{ $status === 'online' ? 'bg-green-100 text-green-800' :
|
|
($status === 'idle' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800') }}">
|
|
{{ ucfirst($status) }}
|
|
</span>
|
|
</div>
|
|
@if($deviceData)
|
|
<div class="text-xs text-gray-500 mt-1">
|
|
{{ $deviceData['speed'] }} km/h • {{ \Carbon\Carbon::parse($deviceData['last_update'])->diffForHumans() }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex space-x-1">
|
|
@if($deviceData)
|
|
<flux:button wire:click="selectDevice({{ $device->id }})" variant="ghost" size="xs" icon="eye">
|
|
</flux:button>
|
|
<flux:button wire:click="followDevice({{ $device->id }})"
|
|
variant="{{ $followDevice === $device->id ? 'primary' : 'ghost' }}"
|
|
size="xs" icon="map-pin">
|
|
</flux:button>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Map Controls --}}
|
|
<div class="bg-white shadow rounded-lg border border-zinc-200 mt-4">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<flux:heading size="base" class="mb-4">Map Controls</flux:heading>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<flux:label>Map Style</flux:label>
|
|
<div class="grid grid-cols-2 gap-2 mt-2">
|
|
<flux:button wire:click="changeMapStyle('streets')"
|
|
variant="{{ $mapStyle === 'streets' ? 'primary' : 'ghost' }}"
|
|
size="sm">Streets</flux:button>
|
|
<flux:button wire:click="changeMapStyle('satellite')"
|
|
variant="{{ $mapStyle === 'satellite' ? 'primary' : 'ghost' }}"
|
|
size="sm">Satellite</flux:button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="flex items-center justify-between">
|
|
<flux:label>Show Trails</flux:label>
|
|
<flux:button wire:click="toggleTrails"
|
|
variant="{{ $showTrails ? 'primary' : 'ghost' }}"
|
|
size="xs">{{ $showTrails ? 'ON' : 'OFF' }}</flux:button>
|
|
</div>
|
|
@if($showTrails)
|
|
<div class="mt-2">
|
|
<flux:label>Trail Duration (hours)</flux:label>
|
|
<flux:select wire:model.live="trailDuration" size="sm">
|
|
<option value="1">1 Hour</option>
|
|
<option value="6">6 Hours</option>
|
|
<option value="12">12 Hours</option>
|
|
<option value="24">24 Hours</option>
|
|
<option value="48">48 Hours</option>
|
|
</flux:select>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<div>
|
|
<flux:label>Auto Refresh Interval</flux:label>
|
|
<flux:select wire:model.live="refreshInterval" size="sm">
|
|
<option value="10">10 seconds</option>
|
|
<option value="30">30 seconds</option>
|
|
<option value="60">1 minute</option>
|
|
<option value="300">5 minutes</option>
|
|
</flux:select>
|
|
</div>
|
|
|
|
<flux:button wire:click="autoCenter" variant="outline" size="sm" class="w-full" icon="map">
|
|
Center Map
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Map Area --}}
|
|
<div class="lg:col-span-3">
|
|
<div class="bg-white shadow rounded-lg border border-zinc-200 h-[600px] relative">
|
|
<div id="liveTrackingMap" class="w-full h-full rounded-lg"></div>
|
|
|
|
{{-- Map Overlay Info --}}
|
|
@if($selectedDevice)
|
|
@php
|
|
$deviceData = collect($deviceDetails)->firstWhere('device_id', $selectedDevice);
|
|
@endphp
|
|
@if($deviceData)
|
|
<div class="absolute top-4 right-4 bg-white shadow-lg rounded-lg p-4 border border-zinc-200 max-w-sm">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<flux:heading size="sm">{{ $deviceData['device_name'] }}</flux:heading>
|
|
<flux:button wire:click="$set('selectedDevice', null)" variant="ghost" size="xs" icon="x-mark">
|
|
</flux:button>
|
|
</div>
|
|
<div class="space-y-2 text-sm">
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-600">Status:</span>
|
|
<span class="font-medium capitalize">{{ $deviceData['status'] }}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-600">Speed:</span>
|
|
<span class="font-medium">{{ $deviceData['speed'] }} km/h</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-600">Direction:</span>
|
|
<span class="font-medium">{{ $deviceData['course'] }}°</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-600">Last Update:</span>
|
|
<span class="font-medium">{{ \Carbon\Carbon::parse($deviceData['last_update'])->format('H:i:s') }}</span>
|
|
</div>
|
|
@if($deviceData['address'])
|
|
<div class="pt-2 border-t border-gray-200">
|
|
<span class="text-gray-600">Address:</span>
|
|
<p class="text-sm font-medium">{{ $deviceData['address'] }}</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Load Leaflet Assets --}}
|
|
@assets
|
|
<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>
|
|
@endassets
|
|
|
|
@script
|
|
<script>
|
|
let liveTrackingMap = null;
|
|
let liveTrackingMarkers = {};
|
|
let liveTrackingTrails = {};
|
|
let autoRefreshInterval = null;
|
|
|
|
// Initialize the map
|
|
function initLiveTrackingMap() {
|
|
if (liveTrackingMap) {
|
|
liveTrackingMap.remove();
|
|
}
|
|
|
|
const mapCenter = @json($mapCenter);
|
|
const zoomLevel = @json($zoomLevel);
|
|
const mapStyle = @json($mapStyle);
|
|
|
|
liveTrackingMap = L.map('liveTrackingMap').setView([mapCenter.lat, mapCenter.lng], zoomLevel);
|
|
|
|
updateMapStyle(mapStyle);
|
|
updateMarkers();
|
|
setupAutoRefresh();
|
|
}
|
|
|
|
function updateMapStyle(style) {
|
|
if (liveTrackingMap.tileLayer) {
|
|
liveTrackingMap.removeLayer(liveTrackingMap.tileLayer);
|
|
}
|
|
|
|
let tileUrl, attribution;
|
|
|
|
if (style === 'satellite') {
|
|
tileUrl = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
|
attribution = '© Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community';
|
|
} else {
|
|
tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
}
|
|
|
|
liveTrackingMap.tileLayer = L.tileLayer(tileUrl, {
|
|
attribution: attribution,
|
|
maxZoom: 19
|
|
}).addTo(liveTrackingMap);
|
|
}
|
|
|
|
function updateMarkers() {
|
|
if (!liveTrackingMap) return;
|
|
|
|
const devices = @json($deviceDetails);
|
|
|
|
// Clear existing markers
|
|
Object.values(liveTrackingMarkers).forEach(marker => {
|
|
liveTrackingMap.removeLayer(marker);
|
|
});
|
|
liveTrackingMarkers = {};
|
|
|
|
// Add new markers
|
|
devices.forEach(device => {
|
|
if (device.latitude && device.longitude) {
|
|
const icon = getDeviceIcon(device.status);
|
|
const marker = L.marker([device.latitude, device.longitude], { icon })
|
|
.addTo(liveTrackingMap);
|
|
|
|
const popupContent = `
|
|
<div class="p-2">
|
|
<h3 class="font-semibold">${device.device_name}</h3>
|
|
<p><strong>Speed:</strong> ${device.speed} km/h</p>
|
|
<p><strong>Status:</strong> ${device.status}</p>
|
|
<p><strong>Last Update:</strong> ${new Date(device.last_update).toLocaleString()}</p>
|
|
${device.address ? `<p><strong>Address:</strong> ${device.address}</p>` : ''}
|
|
</div>
|
|
`;
|
|
|
|
marker.bindPopup(popupContent);
|
|
liveTrackingMarkers[device.device_id] = marker;
|
|
}
|
|
});
|
|
}
|
|
|
|
function getDeviceIcon(status) {
|
|
const colors = {
|
|
online: '#10b981',
|
|
idle: '#f59e0b',
|
|
offline: '#ef4444'
|
|
};
|
|
|
|
const color = colors[status] || colors.offline;
|
|
|
|
return L.divIcon({
|
|
className: 'device-marker',
|
|
html: `<div style="
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background-color: ${color};
|
|
border: 2px solid white;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
"></div>`,
|
|
iconSize: [16, 16],
|
|
iconAnchor: [8, 8]
|
|
});
|
|
}
|
|
|
|
function setupAutoRefresh() {
|
|
if (autoRefreshInterval) {
|
|
clearInterval(autoRefreshInterval);
|
|
}
|
|
|
|
const autoRefresh = @json($autoRefresh);
|
|
const refreshInterval = @json($refreshInterval);
|
|
|
|
if (autoRefresh) {
|
|
autoRefreshInterval = setInterval(() => {
|
|
$wire.refreshData();
|
|
}, refreshInterval * 1000);
|
|
}
|
|
}
|
|
|
|
// Initialize map when component loads
|
|
initLiveTrackingMap();
|
|
|
|
// Listen for Livewire events
|
|
$wire.on('refreshMap', () => {
|
|
updateMarkers();
|
|
});
|
|
|
|
$wire.on('changeMapStyle', (style) => {
|
|
updateMapStyle(style);
|
|
});
|
|
|
|
$wire.on('centerMap', (center, zoom) => {
|
|
if (liveTrackingMap) {
|
|
liveTrackingMap.setView([center.lat, center.lng], zoom || 13);
|
|
}
|
|
});
|
|
|
|
// Cleanup when component is destroyed
|
|
document.addEventListener('livewire:navigate', function() {
|
|
if (autoRefreshInterval) {
|
|
clearInterval(autoRefreshInterval);
|
|
}
|
|
if (liveTrackingMap) {
|
|
liveTrackingMap.remove();
|
|
liveTrackingMap = null;
|
|
}
|
|
});
|
|
</script>
|
|
@endscript
|
|
|
|
<style>
|
|
.device-marker {
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
|
|
#liveTrackingMap {
|
|
z-index: 1;
|
|
}
|
|
</style>
|
|
</div>
|