Changed rendering order to display oldest markers first and added zIndexOffset to ensure newest location markers are always visible on top when markers overlap. Also improved firstLocation logic to only use the latest marker for map centering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
448 lines
16 KiB
HTML
448 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>MQTT Location Tracker</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<style>
|
|
body { margin: 0; padding: 0; font-family: Arial, sans-serif; }
|
|
#map { height: 100vh; width: 100%; }
|
|
.info {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
background: white;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
z-index: 1000;
|
|
min-width: 250px;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
.filter-section {
|
|
margin-bottom: 15px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
.filter-section:last-of-type {
|
|
border-bottom: none;
|
|
}
|
|
.filter-label {
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
color: #666;
|
|
margin-bottom: 5px;
|
|
display: block;
|
|
}
|
|
select {
|
|
width: 100%;
|
|
padding: 8px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
background: white;
|
|
cursor: pointer;
|
|
transition: border-color 0.3s ease;
|
|
}
|
|
select:hover {
|
|
border-color: #4CAF50;
|
|
}
|
|
select:focus {
|
|
outline: none;
|
|
border-color: #4CAF50;
|
|
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
|
|
}
|
|
.toggle-btn {
|
|
margin-top: 15px;
|
|
padding: 10px 15px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
width: 100%;
|
|
transition: all 0.3s ease;
|
|
font-size: 14px;
|
|
}
|
|
.toggle-btn.active {
|
|
background: #4CAF50;
|
|
color: white;
|
|
}
|
|
.toggle-btn.inactive {
|
|
background: #f44336;
|
|
color: white;
|
|
}
|
|
.toggle-btn:hover {
|
|
opacity: 0.8;
|
|
}
|
|
.status-indicator {
|
|
display: inline-block;
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
margin-right: 6px;
|
|
}
|
|
.status-indicator.active {
|
|
background: #4CAF50;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
.status-indicator.inactive {
|
|
background: #999;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="map"></div>
|
|
<div class="info">
|
|
<h3>📍 MQTT Tracker</h3>
|
|
<div id="status">Lade...</div>
|
|
|
|
<div class="filter-section">
|
|
<label class="filter-label">🗺️ Kartenebene</label>
|
|
<select id="mapLayerSelect" onchange="changeMapLayer()">
|
|
<option value="standard">Standard (OpenStreetMap)</option>
|
|
<option value="satellite">Satellit (Esri)</option>
|
|
<option value="terrain">Gelände (OpenTopoMap)</option>
|
|
<option value="dark">Dunkel (CartoDB Dark)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-section">
|
|
<label class="filter-label">📱 Gerät</label>
|
|
<select id="deviceFilter" onchange="applyFilters()">
|
|
<option value="all">Alle Geräte</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-section">
|
|
<label class="filter-label">⏱️ Zeitraum</label>
|
|
<select id="timeFilter" onchange="applyFilters()">
|
|
<option value="1h" selected>Letzte Stunde</option>
|
|
<option value="3h">Letzte 3 Stunden</option>
|
|
<option value="6h">Letzte 6 Stunden</option>
|
|
<option value="12h">Letzte 12 Stunden</option>
|
|
<option value="24h">Letzte 24 Stunden</option>
|
|
</select>
|
|
</div>
|
|
|
|
<button id="toggleBtn" class="toggle-btn active" onclick="toggleAutoRefresh()">
|
|
<span class="status-indicator active"></span>
|
|
Auto-Refresh: AN
|
|
</button>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script>
|
|
// Device Name Mapping (username -> sprechender Name)
|
|
const DEVICE_NAMES = {
|
|
'10': 'Joachim Pixel',
|
|
'11': 'Huawei Smartphone'
|
|
};
|
|
|
|
// Device Colors (für unterschiedliche Marker/Polylines)
|
|
const DEVICE_COLORS = {
|
|
'10': '#e74c3c', // Rot
|
|
'11': '#3498db', // Blau
|
|
'default': '#95a5a6' // Grau für unbekannte Geräte
|
|
};
|
|
|
|
// Karte initialisieren (München)
|
|
const map = L.map('map').setView([48.1351, 11.5820], 12);
|
|
|
|
// Map layers
|
|
const mapLayers = {
|
|
standard: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap'
|
|
}),
|
|
satellite: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
|
attribution: '© Esri'
|
|
}),
|
|
terrain: L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenTopoMap'
|
|
}),
|
|
dark: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© CartoDB'
|
|
})
|
|
};
|
|
|
|
// Add default layer
|
|
let currentLayer = mapLayers.standard;
|
|
currentLayer.addTo(map);
|
|
|
|
// API URL
|
|
const API_URL = 'https://n8n.unixweb.home64.de/webhook/location';
|
|
|
|
// State
|
|
let autoRefreshEnabled = true;
|
|
let refreshInterval = null;
|
|
let allData = null;
|
|
let markerLayer = L.layerGroup().addTo(map);
|
|
let polylineLayer = L.layerGroup().addTo(map);
|
|
|
|
// Hilfsfunktion: Gerätename aus username holen
|
|
function getDeviceName(username) {
|
|
return DEVICE_NAMES[username] || `Unbekanntes Gerät (${username})`;
|
|
}
|
|
|
|
// Hilfsfunktion: Farbe für Gerät holen
|
|
function getDeviceColor(username) {
|
|
return DEVICE_COLORS[username] || DEVICE_COLORS.default;
|
|
}
|
|
|
|
// Change map layer
|
|
function changeMapLayer() {
|
|
const selectedLayer = document.getElementById('mapLayerSelect').value;
|
|
map.removeLayer(currentLayer);
|
|
currentLayer = mapLayers[selectedLayer];
|
|
currentLayer.addTo(map);
|
|
}
|
|
|
|
// Filter data by device
|
|
function filterByDevice(locations) {
|
|
const deviceFilter = document.getElementById('deviceFilter').value;
|
|
if (deviceFilter === 'all') return locations;
|
|
|
|
return locations.filter(loc => loc.username === deviceFilter);
|
|
}
|
|
|
|
// Filter data by time range
|
|
function filterByTime(locations) {
|
|
const timeFilter = document.getElementById('timeFilter').value;
|
|
|
|
const now = new Date();
|
|
const ranges = {
|
|
'1h': 60 * 60 * 1000,
|
|
'3h': 3 * 60 * 60 * 1000,
|
|
'6h': 6 * 60 * 60 * 1000,
|
|
'12h': 12 * 60 * 60 * 1000,
|
|
'24h': 24 * 60 * 60 * 1000
|
|
};
|
|
|
|
const cutoffTime = now - ranges[timeFilter];
|
|
|
|
return locations.filter(loc => {
|
|
const locTime = new Date(loc.timestamp);
|
|
return locTime >= cutoffTime;
|
|
});
|
|
}
|
|
|
|
// Update device filter dropdown with available devices
|
|
function updateDeviceFilterOptions(locations) {
|
|
const deviceFilter = document.getElementById('deviceFilter');
|
|
const currentValue = deviceFilter.value;
|
|
|
|
// Get unique devices (username field)
|
|
const devices = new Set();
|
|
locations.forEach(loc => {
|
|
if (loc.username) {
|
|
devices.add(loc.username);
|
|
}
|
|
});
|
|
|
|
// Rebuild options
|
|
deviceFilter.innerHTML = '<option value="all">Alle Geräte</option>';
|
|
Array.from(devices).sort().forEach(username => {
|
|
const option = document.createElement('option');
|
|
option.value = username;
|
|
option.textContent = getDeviceName(username);
|
|
deviceFilter.appendChild(option);
|
|
});
|
|
|
|
// Restore previous selection if still available
|
|
if (currentValue !== 'all' && devices.has(currentValue)) {
|
|
deviceFilter.value = currentValue;
|
|
}
|
|
}
|
|
|
|
// Apply all filters and update map
|
|
function applyFilters() {
|
|
if (!allData || !allData.history) return;
|
|
|
|
// Nur MQTT-Daten (user_id = 0 oder "0")
|
|
let filteredData = allData.history.filter(loc => loc.user_id == 0);
|
|
|
|
// Apply filters in sequence
|
|
filteredData = filterByDevice(filteredData);
|
|
filteredData = filterByTime(filteredData);
|
|
|
|
// Update map
|
|
displayLocations(filteredData);
|
|
|
|
// Update status
|
|
document.getElementById('status').innerHTML =
|
|
`📊 Punkte: ${filteredData.length}<br>` +
|
|
`📱 Geräte: ${new Set(filteredData.map(l => l.username)).size}<br>` +
|
|
`${allData.success ? '✅ Verbunden' : '❌ Fehler'}`;
|
|
}
|
|
|
|
// Display filtered locations on map
|
|
function displayLocations(locations) {
|
|
// Clear existing markers and polylines
|
|
markerLayer.clearLayers();
|
|
polylineLayer.clearLayers();
|
|
|
|
if (locations.length === 0) return;
|
|
|
|
// Gruppiere Locations nach Gerät
|
|
const deviceGroups = {};
|
|
locations.forEach(loc => {
|
|
const device = loc.username || 'unknown';
|
|
if (!deviceGroups[device]) {
|
|
deviceGroups[device] = [];
|
|
}
|
|
deviceGroups[device].push(loc);
|
|
});
|
|
|
|
// Für jedes Gerät: Marker + Polyline
|
|
let firstLocation = null;
|
|
|
|
Object.keys(deviceGroups).forEach(device => {
|
|
const deviceLocs = deviceGroups[device];
|
|
const color = getDeviceColor(device);
|
|
|
|
// Sortiere nach Timestamp (neueste zuerst)
|
|
deviceLocs.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
|
|
// Add markers in reverse order (oldest first) so newest are on top
|
|
// But reverse the array for rendering while keeping track of which is latest
|
|
const reversedLocs = [...deviceLocs].reverse();
|
|
|
|
reversedLocs.forEach((loc, reverseIndex) => {
|
|
const index = deviceLocs.length - 1 - reverseIndex; // Original index
|
|
const isLatest = index === 0;
|
|
const lat = parseFloat(loc.latitude);
|
|
const lon = parseFloat(loc.longitude);
|
|
|
|
if (isNaN(lat) || isNaN(lon)) return;
|
|
|
|
// Popup-Inhalt mit zusätzlichen Infos
|
|
let popupContent = `<b>${getDeviceName(device)}</b><br>${loc.display_time || ''}`;
|
|
|
|
// Batterie
|
|
if (loc.battery !== undefined && loc.battery !== null) {
|
|
popupContent += `<br>🔋 Batterie: ${loc.battery}%`;
|
|
}
|
|
|
|
// Geschwindigkeit
|
|
if (loc.speed !== undefined && loc.speed !== null) {
|
|
const speedKmh = (loc.speed * 3.6).toFixed(1);
|
|
popupContent += `<br>🚗 Speed: ${speedKmh} km/h`;
|
|
}
|
|
|
|
// Marker Icon (neuester = größer)
|
|
const iconSize = isLatest ? [32, 32] : [16, 16];
|
|
const iconAnchor = isLatest ? [16, 16] : [8, 8];
|
|
|
|
const markerIcon = L.divIcon({
|
|
html: `<svg width="${iconSize[0]}" height="${iconSize[1]}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="12" cy="12" r="10" fill="${color}" stroke="white" stroke-width="2"/>
|
|
<path d="M12 8 L12 12 L15 15" stroke="white" stroke-width="2" stroke-linecap="round" fill="none"/>
|
|
</svg>`,
|
|
iconSize: iconSize,
|
|
iconAnchor: iconAnchor,
|
|
className: ''
|
|
});
|
|
|
|
// Höherer zIndexOffset für neuere Marker (neueste = höchster z-index)
|
|
L.marker([lat, lon], {
|
|
icon: markerIcon,
|
|
zIndexOffset: reverseIndex // Oldest = 0, newest = highest
|
|
})
|
|
.addTo(markerLayer)
|
|
.bindPopup(popupContent);
|
|
|
|
if (!firstLocation && isLatest) {
|
|
firstLocation = [lat, lon];
|
|
}
|
|
});
|
|
|
|
// Add polyline if multiple points
|
|
if (deviceLocs.length > 1) {
|
|
const coords = deviceLocs
|
|
.map(h => {
|
|
const lat = parseFloat(h.latitude);
|
|
const lon = parseFloat(h.longitude);
|
|
return !isNaN(lat) && !isNaN(lon) ? [lat, lon] : null;
|
|
})
|
|
.filter(c => c !== null);
|
|
|
|
if (coords.length > 1) {
|
|
L.polyline(coords, { color: color, weight: 3, opacity: 0.7 }).addTo(polylineLayer);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Zentriere auf erste Location
|
|
if (firstLocation) {
|
|
map.setView(firstLocation, 13);
|
|
}
|
|
}
|
|
|
|
async function loadLocations() {
|
|
try {
|
|
// Cache-Busting: füge Timestamp hinzu
|
|
const cacheBustedUrl = `${API_URL}?_t=${Date.now()}`;
|
|
const response = await fetch(cacheBustedUrl);
|
|
const data = await response.json();
|
|
|
|
allData = data;
|
|
|
|
// Nur MQTT-Daten für Device-Filter
|
|
const mqttData = data.history ? data.history.filter(loc => loc.user_id == 0) : [];
|
|
|
|
// Update device filter dropdown
|
|
if (mqttData.length > 0) {
|
|
updateDeviceFilterOptions(mqttData);
|
|
}
|
|
|
|
// Apply filters and display
|
|
applyFilters();
|
|
|
|
} catch (error) {
|
|
document.getElementById('status').innerHTML = '❌ Verbindungsfehler';
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
function toggleAutoRefresh() {
|
|
autoRefreshEnabled = !autoRefreshEnabled;
|
|
const btn = document.getElementById('toggleBtn');
|
|
|
|
if (autoRefreshEnabled) {
|
|
btn.className = 'toggle-btn active';
|
|
btn.innerHTML = '<span class="status-indicator active"></span>Auto-Refresh: AN';
|
|
startAutoRefresh();
|
|
} else {
|
|
btn.className = 'toggle-btn inactive';
|
|
btn.innerHTML = '<span class="status-indicator inactive"></span>Auto-Refresh: AUS';
|
|
stopAutoRefresh();
|
|
}
|
|
}
|
|
|
|
function startAutoRefresh() {
|
|
if (refreshInterval) clearInterval(refreshInterval);
|
|
refreshInterval = setInterval(loadLocations, 5000);
|
|
}
|
|
|
|
function stopAutoRefresh() {
|
|
if (refreshInterval) {
|
|
clearInterval(refreshInterval);
|
|
refreshInterval = null;
|
|
}
|
|
}
|
|
|
|
// Initial laden
|
|
loadLocations();
|
|
|
|
// Auto-refresh starten
|
|
startAutoRefresh();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|