Files
smartphone-n8n-tracking/index.html
Joachim Hummel 182ebb8ba4 Fix marker z-index so newest locations appear on top
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>
2025-11-14 19:49:22 +00:00

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>