PageSourceSearch

https://truckslog.com/_SITES/ETS2_LIVE_MAP/app.js?v=1787067799

js truckslog.com collected 2026-10-10 02:29:48 UTC 62,485 bytes, 2,052 lines download raw bytes

1(function () {
2  const FEED_REFRESH_MS = 300;
3  const FEED_REFRESH_HIDDEN_MS = 15000;
4  const FEED_REFRESH_MAX_MS = 30000;
5  const MARKER_LABEL_ZOOM = 6;
6  const FOLLOW_MIN_ZOOM = 6;
7  const MARKER_OVERLAP_SPREAD_MIN_ZOOM = 6;
8  const MARKER_OVERLAP_DISTANCE = 30;
9  const MAP_CHANGE_GRACE_MS = 450;
10  const INACTIVE_AFTER_MS = 30000;
11  const STALE_AFTER_MS = 120000;
12  const ETA_MIN_SPEED = 15;
13  const TELEMETRY_SAMPLE_SIZE = 5;
14  const UI_STORAGE_KEY = 'ets2-live-map-ui-v4';
15  const DEFAULT_OVERLAY_TYPES = [];
16
17  const root = document.getElementById('ets2-live-map-app');
18  const statusEl = document.getElementById('ets2-map-status');
19
20  if (!root) {
21    return;
22  }
23
24  if (!window.L) {
25    if (statusEl) {
26      statusEl.textContent = 'Leaflet konnte nicht geladen werden';
27    }
28    return;
29  }
30
31  const els = {
32    app: root,
33    pageContainer: root.closest('.uk-container.uk-container-expand'),
34    list: document.getElementById('ets2-map-list'),
35    count: document.getElementById('ets2-map-count'),
36    updated: document.getElementById('ets2-map-updated'),
37    status: document.getElementById('ets2-map-status'),
38    details: document.getElementById('ets2-map-details'),
39    fit: document.getElementById('ets2-map-fit'),
40    canvas: document.getElementById('ets2-live-map-canvas'),
41    search: document.getElementById('ets2-map-search'),
42    sort: document.getElementById('ets2-map-sort'),
43    overlayList: document.getElementById('ets2-map-overlay-list'),
44    followCard: document.getElementById('ets2-map-follow-card'),
45    followName: document.getElementById('ets2-map-follow-name'),
46    followState: document.getElementById('ets2-map-follow-state'),
47    followResume: document.getElementById('ets2-map-follow-resume'),
48    followStop: document.getElementById('ets2-map-follow-stop'),
49    statusFilters: Array.from(document.querySelectorAll('[data-status-filter]')),
50    scopes: Array.from(document.querySelectorAll('.ets2-live-map-scope')),
51    panelButtons: Array.from(document.querySelectorAll('[data-panel-target]')),
52    panels: Array.from(document.querySelectorAll('.ets2-live-map-panel')),
53    layerMarkers: document.getElementById('ets2-layer-markers'),
54    layerDriving: document.getElementById('ets2-layer-driving'),
55    layerIdle: document.getElementById('ets2-layer-idle'),
56    offcanvas: document.getElementById('ets2LiveMapSidebar'),
57    offcanvasDismissers: Array.from(document.querySelectorAll('[data-bs-dismiss="offcanvas"]')),
58    offcanvasTriggers: Array.from(document.querySelectorAll('[data-bs-target="#ets2LiveMapSidebar"]')),
59  };
60
61  const state = {
62    tilesInfoUrl: root.dataset.tilesInfoUrl,
63    overlaysUrl: root.dataset.overlaysUrl,
64    tilesUrlTemplate: root.dataset.tilesUrlTemplate,
65    feedUrl: root.dataset.feedUrl,
66    scope: root.dataset.defaultScope || 'all',
67    map: null,
68    config: null,
69    bounds: null,
70    crs: null,
71    markersByGuid: new Map(),
72    vehiclesByGuid: new Map(),
73    vehicleMetaByGuid: new Map(),
74    overlapCountByGuid: new Map(),
75    overlayLayerGroupsByType: new Map(),
76    overlayCountsByType: new Map(),
77    selectedGuid: null,
78    followGuid: null,
79    followPaused: false,
80    followPauseReason: '',
81    timerId: null,
82    hasFitOnce: false,
83    isLoadingFeed: false,
84    lastVehicles: [],
85    lastFeedSignature: '',
86    lastListSignature: '',
87    lastDetailsSignature: '',
88    activePanel: 'live-map',
89    searchQuery: '',
90    statusFilter: 'all',
91    sortMode: 'activity',
92    showMarkers: true,
93    showDriving: true,
94    showIdle: false,
95    visibleOverlayTypes: new Set(DEFAULT_OVERLAY_TYPES),
96    offcanvasApi: null,
97    hasBootstrapOffcanvas: false,
98    hiddenFooterNodes: [],
99    consecutiveFeedErrors: 0,
100    programmaticMapChangeUntil: 0,
101  };
102
103  function normalizePanelName(panelName) {
104    switch (panelName) {
105      case 'vehicles':
106        return 'drivers';
107      case 'layers':
108      case 'details':
109        return 'info';
110      case 'drivers':
111      case 'info':
112      case 'live-map':
113        return panelName;
114      default:
115        return 'live-map';
116    }
117  }
118
119  function setStatus(text) {
120    if (els.status) {
121      els.status.textContent = text;
122    }
123  }
124
125  function readStoredUi() {
126    if (!window.localStorage) {
127      return null;
128    }
129
130    try {
131      const raw = localStorage.getItem(UI_STORAGE_KEY);
132      return raw ? JSON.parse(raw) : null;
133    } catch (error) {
134      return null;
135    }
136  }
137
138  function persistUiState() {
139    if (!window.localStorage) {
140      return;
141    }
142
143    try {
144      localStorage.setItem(UI_STORAGE_KEY, JSON.stringify({
145        scope: state.scope,
146        activePanel: state.activePanel,
147        searchQuery: state.searchQuery,
148        statusFilter: state.statusFilter,
149        sortMode: state.sortMode,
150        showMarkers: state.showMarkers,
151        showDriving: state.showDriving,
152        showIdle: state.showIdle,
153        visibleOverlayTypes: Array.from(state.visibleOverlayTypes),
154        selectedGuid: state.selectedGuid,
155      }));
156    } catch (error) {
157      // Ignore storage errors.
158    }
159  }
160
161  function applyStoredUi() {
162    const stored = readStoredUi();
163
164    if (!stored || typeof stored !== 'object') {
165      return;
166    }
167
168    if (typeof stored.scope === 'string') {
169      state.scope = stored.scope;
170    }
171    if (typeof stored.activePanel === 'string') {
172      state.activePanel = normalizePanelName(stored.activePanel);
173    }
174    if (typeof stored.searchQuery === 'string') {
175      state.searchQuery = stored.searchQuery.toLowerCase();
176      if (els.search) {
177        els.search.value = stored.searchQuery;
178      }
179    }
180    if (typeof stored.statusFilter === 'string') {
181      state.statusFilter = stored.statusFilter;
182    }
183    if (typeof stored.sortMode === 'string') {
184      state.sortMode = stored.sortMode;
185      if (els.sort) {
186        els.sort.value = stored.sortMode;
187      }
188    }
189    if (typeof stored.showMarkers === 'boolean') {
190      state.showMarkers = stored.showMarkers;
191      if (els.layerMarkers) {
192        els.layerMarkers.checked = stored.showMarkers;
193      }
194    }
195    if (typeof stored.showDriving === 'boolean') {
196      state.showDriving = stored.showDriving;
197      if (els.layerDriving) {
198        els.layerDriving.checked = stored.showDriving;
199      }
200    }
201    if (typeof stored.showIdle === 'boolean') {
202      state.showIdle = stored.showIdle;
203      if (els.layerIdle) {
204        els.layerIdle.checked = stored.showIdle;
205      }
206    }
207    if (Array.isArray(stored.visibleOverlayTypes)) {
208      state.visibleOverlayTypes = new Set(stored.visibleOverlayTypes.filter(function (value) {
209        return typeof value === 'string' && value;
210      }));
211    }
212    if (typeof stored.selectedGuid === 'string') {
213      state.selectedGuid = stored.selectedGuid;
214    }
215  }
216
217  function normalizeTileConfig(config) {
218    if (!config || typeof config !== 'object') {
219      throw new Error('TileMapInfo ist leer oder ungueltig');
220    }
221
222    const candidateMap = config.map && typeof config.map === 'object' ? { ...config.map } : { ...config };
223    const requiredKeys = ['minZoom', 'maxZoom', 'x1', 'x2', 'y1', 'y2'];
224    const hasRequiredKeys = requiredKeys.every(function (key) {
225      return candidateMap[key] !== undefined && candidateMap[key] !== null;
226    });
227
228    if (!hasRequiredKeys) {
229      throw new Error('TileMapInfo hat kein unterstuetztes Format');
230    }
231
232    if (candidateMap.tileSize === undefined || candidateMap.tileSize === null) {
233      candidateMap.tileSize = 512;
234    }
235
236    if (candidateMap.maxX === undefined || candidateMap.maxX === null) {
237      candidateMap.maxX = candidateMap.tileSize * Math.pow(2, Number(candidateMap.maxZoom) || 0);
238    }
239
240    if (candidateMap.maxY === undefined || candidateMap.maxY === null) {
241      candidateMap.maxY = candidateMap.tileSize * Math.pow(2, Number(candidateMap.maxZoom) || 0);
242    }
243
244    return { map: candidateMap };
245  }
246
247  function describeTileConfigPayload(config) {
248    if (config === null) {
249      return 'null';
250    }
251
252    if (Array.isArray(config)) {
253      return 'array';
254    }
255
256    if (typeof config !== 'object') {
257      return typeof config;
258    }
259
260    const keys = Object.keys(config);
261    return keys.length ? 'keys=' + keys.join(',') : 'empty-object';
262  }
263
264  function syncViewportLayout() {
265    const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
266    els.app.style.setProperty('--ets2-nav-offset', '0px');
267    els.app.style.setProperty('--ets2-viewport-height', Math.ceil(viewportHeight) + 'px');
268
269    if (els.pageContainer instanceof HTMLElement) {
270      els.pageContainer.style.paddingTop = '0';
271      els.pageContainer.style.paddingBottom = '0';
272      els.pageContainer.style.maxWidth = '100%';
273    }
274
275    if (state.map) {
276      window.requestAnimationFrame(function () {
277        state.map.invalidateSize();
278      });
279    }
280  }
281
282  function hidePageFooter() {
283    const styleId = 'ets2-live-map-hide-footer-style';
284
285    if (!document.getElementById(styleId)) {
286      const style = document.createElement('style');
287      style.id = styleId;
288      style.textContent = 'footer.uk-background-secondary.uk-light.uk-padding-small{display:none!important;}';
289      document.head.appendChild(style);
290    }
291
292    Array.from(document.querySelectorAll(
293      'footer.uk-background-secondary.uk-light.uk-padding-small, footer, .footer, .site-footer, .main-footer'
294    )).forEach(function (node) {
295      if (!(node instanceof HTMLElement)) {
296        return;
297      }
298
299      state.hiddenFooterNodes.push({
300        node: node,
301        display: node.style.display,
302      });
303
304      node.style.display = 'none';
305    });
306  }
307
308  function setUpdated(dateIso) {
309    if (!els.updated) {
310      return;
311    }
312
313    if (!dateIso) {
314      els.updated.textContent = 'unbekannt';
315      return;
316    }
317
318    els.updated.textContent = new Date(dateIso).toLocaleTimeString('de-DE');
319  }
320
321  function setActiveScope() {
322    els.scopes.forEach(function (button) {
323      button.classList.toggle('is-active', button.dataset.scope === state.scope);
324    });
325  }
326
327  function setActivePanel(panelName) {
328    state.activePanel = normalizePanelName(panelName);
329
330    els.panelButtons.forEach(function (button) {
331      button.classList.toggle('is-active', button.dataset.panelTarget === state.activePanel);
332    });
333
334    els.panels.forEach(function (panel) {
335      panel.classList.toggle('is-active', panel.dataset.panel === state.activePanel);
336    });
337  }
338
339  function setActiveStatusFilter() {
340    els.statusFilters.forEach(function (button) {
341      button.classList.toggle('is-active', button.dataset.statusFilter === state.statusFilter);
342    });
343  }
344
345  function syncLayerVisibilityInputs() {
346    if (els.layerDriving) {
347      els.layerDriving.checked = state.showDriving;
348    }
349
350    if (els.layerIdle) {
351      els.layerIdle.checked = state.showIdle;
352    }
353  }
354
355  function setActiveSortMode() {
356    if (els.sort) {
357      els.sort.value = state.sortMode;
358    }
359  }
360
361  function setupOffcanvas() {
362    if (!els.offcanvas) {
363      return;
364    }
365
366    if (window.bootstrap && typeof window.bootstrap.Offcanvas === 'function') {
367      state.hasBootstrapOffcanvas = true;
368      state.offcanvasApi = window.bootstrap.Offcanvas.getOrCreateInstance(els.offcanvas);
369
370      els.offcanvas.addEventListener('shown.bs.offcanvas', function () {
371        if (state.map) {
372          window.setTimeout(function () {
373            state.map.invalidateSize();
374          }, 50);
375        }
376      });
377
378      els.offcanvas.addEventListener('hidden.bs.offcanvas', function () {
379        if (state.map) {
380          window.setTimeout(function () {
381            state.map.invalidateSize();
382          }, 50);
383        }
384      });
385
386      return;
387    }
388
389    els.app.classList.add('ets2-live-map-app-no-bootstrap');
390
391    els.offcanvasTriggers.forEach(function (trigger) {
392      trigger.addEventListener('click', function () {
393        els.offcanvas.classList.add('is-open');
394
395        if (state.map) {
396          window.setTimeout(function () {
397            state.map.invalidateSize();
398          }, 50);
399        }
400      });
401    });
402
403    els.offcanvasDismissers.forEach(function (button) {
404      button.addEventListener('click', function () {
405        els.offcanvas.classList.remove('is-open');
406
407        if (state.map) {
408          window.setTimeout(function () {
409            state.map.invalidateSize();
410          }, 50);
411        }
412      });
413    });
414  }
415
416  function openSidebar() {
417    if (!els.offcanvas) {
418      return;
419    }
420
421    if (state.hasBootstrapOffcanvas && state.offcanvasApi) {
422      state.offcanvasApi.show();
423      return;
424    }
425
426    if (els.app.classList.contains('ets2-live-map-app-no-bootstrap')) {
427      els.offcanvas.classList.add('is-open');
428    }
429  }
430
431  function closeSidebar() {
432    if (!els.offcanvas) {
433      return;
434    }
435
436    if (state.hasBootstrapOffcanvas && state.offcanvasApi) {
437      state.offcanvasApi.hide();
438      return;
439    }
440
441    if (els.app.classList.contains('ets2-live-map-app-no-bootstrap')) {
442      els.offcanvas.classList.remove('is-open');
443    }
444  }
445
446  function bindMapDismiss() {
447    if (!els.canvas) {
448      return;
449    }
450
451    els.canvas.addEventListener('pointerdown', function () {
452      closeSidebar();
453    });
454  }
455
456  function markProgrammaticMapChange() {
457    state.programmaticMapChangeUntil = Date.now() + MAP_CHANGE_GRACE_MS;
458  }
459
460  function isProgrammaticMapChange() {
461    return Date.now() <= state.programmaticMapChangeUntil;
462  }
463
464  function pauseFollow(reason) {
465    if (!state.followGuid || state.followPaused) {
466      return;
467    }
468
469    state.followPaused = true;
470    state.followPauseReason = reason || 'Pausiert';
471    updateFollowCard();
472    renderListSelection();
473    persistUiState();
474  }
475
476  function resumeFollow() {
477    if (!state.followGuid) {
478      return;
479    }
480
481    state.followPaused = false;
482    state.followPauseReason = '';
483    updateFollowCard();
484    renderListSelection();
485    persistUiState();
486    applyFollowSelection();
487  }
488
489  function stopFollow() {
490    state.followGuid = null;
491    state.followPaused = false;
492    state.followPauseReason = '';
493    updateFollowCard();
494    renderListSelection();
495    persistUiState();
496  }
497
498  function setFollowGuid(guid, paused) {
499    state.followGuid = guid || null;
500    state.followPaused = Boolean(paused);
501    state.followPauseReason = paused ? 'Pausiert' : '';
502    updateFollowCard();
503  }
504
505  function followVehicle() {
506    return state.followGuid ? state.vehiclesByGuid.get(state.followGuid) || null : null;
507  }
508
509  function updateFollowCard() {
510    if (!els.followCard || !els.followName || !els.followState || !els.followResume || !els.followStop) {
511      return;
512    }
513
514    const vehicle = followVehicle();
515
516    if (!vehicle) {
517      els.followCard.classList.add('is-hidden');
518      els.followName.textContent = 'Kein Fahrer';
519      els.followState.textContent = 'Nicht aktiv';
520      els.followResume.disabled = true;
521      els.followStop.disabled = true;
522      return;
523    }
524
525    els.followCard.classList.remove('is-hidden');
526    els.followName.textContent = vehicle.nickname || 'Unbekannt';
527    els.followState.textContent = state.followPaused
528      ? (state.followPauseReason || 'Pausiert')
529      : 'Aktiv und zentriert';
530    els.followResume.disabled = !state.followPaused;
531    els.followStop.disabled = false;
532  }
533
534  function isVehicleDriving(vehicle) {
535    return Number(vehicle.speed) > 0;
536  }
537
538  function visibleVehicles(vehicles) {
539    return vehicles.filter(function (vehicle) {
540      const isDriving = isVehicleDriving(vehicle);
541
542      if (!state.showDriving && isDriving) {
543        return false;
544      }
545
546      if (!state.showIdle && !isDriving) {
547        return false;
548      }
549
550      if (state.statusFilter === 'driving' && !isDriving) {
551        return false;
552      }
553
554      if (state.statusFilter === 'idle' && isDriving) {
555        return false;
556      }
557
558      if (!state.searchQuery) {
559        return true;
560      }
561
562      const haystack = [
563        vehicle.nickname,
564        vehicle.truck_hersteller,
565        vehicle.truck_modell,
566        vehicle.truck_plate,
567        vehicle.trailer_plate,
568        vehicle.startort,
569        vehicle.zielort,
570        vehicle.spedition,
571      ].join(' ').toLowerCase();
572
573      return haystack.includes(state.searchQuery);
574    });
575  }
576
577  function visibleVehiclesFromState() {
578    return visibleVehicles(state.lastVehicles);
579  }
580
581  function updateVisibleVehicleCount() {
582    if (!els.count) {
583      return;
584    }
585
586    els.count.textContent = String(visibleVehiclesFromState().length);
587  }
588
589  function projectGameToLatLng(x, y) {
590    if (!state.config) {
591      return L.latLng(0, 0);
592    }
593
594    return L.latLng(Number(y) || 0, Number(x) || 0);
595  }
596
597  function vehicleLatLng(vehicle) {
598    return projectGameToLatLng(vehicle.pos_x, vehicle.pos_z);
599  }
600
601  function gameCoordsLatLng(x, y) {
602    return projectGameToLatLng(x, y);
603  }
604
605  function vehicleCargoLabel(vehicle) {
606    return String(vehicle.ladung || '').trim();
607  }
608
609  function hasTruckslogCargo(vehicle) {
610    return vehicleCargoLabel(vehicle).toLowerCase().includes('truckslog');
611  }
612
613  function vehicleTooltipHtml(vehicle) {
614    const cargo = vehicleCargoLabel(vehicle);
615
616    if (!cargo) {
617      return escapeHtml(vehicle.nickname || 'Unbekannt');
618    }
619
620    return '<strong>' + escapeHtml(vehicle.nickname || 'Unbekannt') + '</strong><br><span>' + escapeHtml(cargo) + '</span>';
621  }
622
623  function buildDivIcon(vehicle, isSelected) {
624    const driving = isVehicleDriving(vehicle);
625    const colorClass = driving ? 'is-driving' : 'is-idle';
626    const cargoClass = hasTruckslogCargo(vehicle) ? ' is-truckslog' : '';
627    const selectedClass = isSelected ? ' is-selected' : '';
628    const inactiveClass = isVehicleInactive(vehicle) ? ' is-inactive' : '';
629    const overlapCount = state.overlapCountByGuid.get(vehicle.guid) || 1;
630    const overlapBadge = overlapCount > 1 ? '<span class="ets2-map-marker-badge">' + overlapCount + '</span>' : '';
631
632    return L.divIcon({
633      className: 'ets2-map-marker-wrap',
634      html: '<span class="ets2-map-marker ' + colorClass + cargoClass + selectedClass + inactiveClass + '">' + overlapBadge + '</span>',
635      iconSize: isSelected ? [30, 30] : [22, 22],
636      iconAnchor: isSelected ? [15, 15] : [11, 11],
637    });
638  }
639
640  function vehicleMeta(vehicle) {
641    return state.vehicleMetaByGuid.get(vehicle.guid) || {
642      lastSeenAt: Date.now(),
643      lastMovedAt: Date.now(),
644      speedSamples: [],
645    };
646  }
647
648  function updateVehicleMeta(vehicles) {
649    const now = Date.now();
650    const seen = new Set();
651
652    vehicles.forEach(function (vehicle) {
653      const currentPointKey = String(Math.round(Number(vehicle.pos_x) || 0)) + ':' + String(Math.round(Number(vehicle.pos_z) || 0));
654      const existing = state.vehicleMetaByGuid.get(vehicle.guid);
655      const nextSamples = existing && Array.isArray(existing.speedSamples) ? existing.speedSamples.slice(-TELEMETRY_SAMPLE_SIZE + 1) : [];
656      nextSamples.push(Number(vehicle.speed) || 0);
657
658      const moved = !existing || existing.pointKey !== currentPointKey || Math.abs((Number(vehicle.speed) || 0) - (existing.lastSpeed || 0)) > 2;
659
660      state.vehicleMetaByGuid.set(vehicle.guid, {
661        pointKey: currentPointKey,
662        lastSpeed: Number(vehicle.speed) || 0,
663        lastSeenAt: now,
664        lastMovedAt: moved ? now : (existing ? existing.lastMovedAt : now),
665        speedSamples: nextSamples,
666      });
667
668      seen.add(vehicle.guid);
669    });
670
671    state.vehicleMetaByGuid.forEach(function (_meta, guid) {
672      if (!seen.has(guid)) {
673        state.vehicleMetaByGuid.delete(guid);
674      }
675    });
676  }
677
678  function smoothedSpeed(vehicle) {
679    const samples = vehicleMeta(vehicle).speedSamples || [];
680
681    if (!samples.length) {
682      return Number(vehicle.speed) || 0;
683    }
684
685    const average = samples.reduce(function (sum, speed) { return sum + speed; }, 0) / samples.length;
686    return Math.max(0, average);
687  }
688
689  function isVehicleInactive(vehicle) {
690    const meta = vehicleMeta(vehicle);
691    const now = Date.now();
692
693    if (now - meta.lastSeenAt > STALE_AFTER_MS) {
694      return true;
695    }
696
697    return !isVehicleDriving(vehicle) && now - meta.lastMovedAt > INACTIVE_AFTER_MS;
698  }
699
700  function vehicleStatusLabel(vehicle) {
701    if (Date.now() - vehicleMeta(vehicle).lastSeenAt > STALE_AFTER_MS) {
702      return 'Inaktiv';
703    }
704
705    if (isVehicleInactive(vehicle)) {
706      return 'Wartet';
707    }
708
709    return isVehicleDriving(vehicle) ? 'Unterwegs' : 'Stand';
710  }
711
712  function etaData(vehicle) {
713    const speed = smoothedSpeed(vehicle);
714    const restKm = Number(vehicle.rest_km) || 0;
715
716    if (restKm <= 0) {
717      return {
718        arrivalText: 'jetzt',
719        durationText: 'Ankunft',
720        sortMinutes: 0,
721      };
722    }
723
724    if (speed < ETA_MIN_SPEED) {
725      return {
726        arrivalText: '-',
727        durationText: 'ETA pausiert',
728        sortMinutes: Number.MAX_SAFE_INTEGER,
729      };
730    }
731
732    const durationMinutes = Math.max(1, Math.round((restKm / speed) * 60));
733    const hours = Math.floor(durationMinutes / 60);
734    const minutes = durationMinutes % 60;
735    const arrival = new Date(Date.now() + durationMinutes * 60 * 1000);
736
737    return {
738      arrivalText: arrival.toLocaleTimeString('de-DE', {
739        hour: '2-digit',
740        minute: '2-digit',
741      }),
742      durationText: hours > 0 ? hours + 'h ' + String(minutes).padStart(2, '0') + 'm' : minutes + ' min',
743      sortMinutes: durationMinutes,
744    };
745  }
746
747  function shouldShowMarkerLabel(guid) {
748    if (!state.map) {
749      return false;
750    }
751
752    return guid === state.selectedGuid || state.map.getZoom() >= MARKER_LABEL_ZOOM;
753  }
754
755  function syncMarkerVisualState() {
756    state.markersByGuid.forEach(function (marker, guid) {
757      const vehicle = state.vehiclesByGuid.get(guid);
758
759      if (!vehicle) {
760        return;
761      }
762
763      marker.setIcon(buildDivIcon(vehicle, guid === state.selectedGuid));
764
765      if (shouldShowMarkerLabel(guid) && state.map.hasLayer(marker)) {
766        marker.openTooltip();
767      } else {
768        marker.closeTooltip();
769      }
770
771      if (marker._icon) {
772        marker._icon.style.zIndex = guid === state.selectedGuid ? '900' : '';
773      }
774    });
775  }
776
777  function sortVehicles(vehicles) {
778    return vehicles.slice().sort(function (left, right) {
779      if (state.sortMode === 'speed_desc') {
780        return (Number(right.speed) || 0) - (Number(left.speed) || 0) || String(left.nickname).localeCompare(String(right.nickname), 'de');
781      }
782
783      if (state.sortMode === 'rest_asc') {
784        return (Number(left.rest_km) || 0) - (Number(right.rest_km) || 0) || String(left.nickname).localeCompare(String(right.nickname), 'de');
785      }
786
787      if (state.sortMode === 'eta_asc') {
788        return etaData(left).sortMinutes - etaData(right).sortMinutes || String(left.nickname).localeCompare(String(right.nickname), 'de');
789      }
790
791      if (state.sortMode === 'name_asc') {
792        return String(left.nickname).localeCompare(String(right.nickname), 'de');
793      }
794
795      const leftScore = (isVehicleDriving(left) ? 1000 : 0) - (isVehicleInactive(left) ? 500 : 0) - (Number(left.rest_km) || 0) / 1000;
796      const rightScore = (isVehicleDriving(right) ? 1000 : 0) - (isVehicleInactive(right) ? 500 : 0) - (Number(right.rest_km) || 0) / 1000;
797      return rightScore - leftScore || String(left.nickname).localeCompare(String(right.nickname), 'de');
798    });
799  }
800
801  function buildOverlapGroups(items) {
802    const groups = [];
803    const visited = new Set();
804
805    for (let index = 0; index < items.length; index += 1) {
806      if (visited.has(index)) {
807        continue;
808      }
809
810      const queue = [index];
811      const members = [];
812      visited.add(index);
813
814      while (queue.length) {
815        const currentIndex = queue.shift();
816        const currentItem = items[currentIndex];
817        members.push(currentItem);
818
819        for (let candidateIndex = 0; candidateIndex < items.length; candidateIndex += 1) {
820          if (visited.has(candidateIndex) || candidateIndex === currentIndex) {
821            continue;
822          }
823
824          const candidateItem = items[candidateIndex];
825          const dx = currentItem.point.x - candidateItem.point.x;
826          const dy = currentItem.point.y - candidateItem.point.y;
827
828          if (Math.sqrt(dx * dx + dy * dy) <= MARKER_OVERLAP_DISTANCE) {
829            visited.add(candidateIndex);
830            queue.push(candidateIndex);
831          }
832        }
833      }
834
835      groups.push(members);
836    }
837
838    return groups;
839  }
840
841  function applyMarkerOverlapLayout() {
842    if (!state.map) {
843      return;
844    }
845
846    state.overlapCountByGuid.clear();
847
848    const zoom = state.map.getZoom();
849    const visibleItems = visibleVehiclesFromState().map(function (vehicle) {
850      const marker = state.markersByGuid.get(vehicle.guid);
851
852      if (!marker || !state.map.hasLayer(marker)) {
853        return null;
854      }
855
856      const baseLatLng = vehicleLatLng(vehicle);
857
858      return {
859        guid: vehicle.guid,
860        marker: marker,
861        point: state.map.project(baseLatLng, zoom),
862        baseLatLng: baseLatLng,
863      };
864    }).filter(Boolean);
865
866    buildOverlapGroups(visibleItems).forEach(function (group) {
867      group.forEach(function (item) {
868        state.overlapCountByGuid.set(item.guid, group.length);
869        item.marker.setLatLng(item.baseLatLng);
870      });
871
872      if (group.length < 2 || zoom < MARKER_OVERLAP_SPREAD_MIN_ZOOM) {
873        return;
874      }
875
876      const followedIndex = group.findIndex(function (item) {
877        return item.guid === state.followGuid;
878      });
879
880      const centerX = group.reduce(function (sum, item) { return sum + item.point.x; }, 0) / group.length;
881      const centerY = group.reduce(function (sum, item) { return sum + item.point.y; }, 0) / group.length;
882      const radius = Math.max(14, Math.min(30, 10 + group.length * 4));
883
884      group.forEach(function (item, itemIndex) {
885        if (followedIndex !== -1 && item.guid === state.followGuid) {
886          item.marker.setLatLng(item.baseLatLng);
887          return;
888        }
889
890        const normalizedIndex = followedIndex === -1
891          ? itemIndex
892          : itemIndex > followedIndex ? itemIndex - 1 : itemIndex;
893        const denominator = followedIndex === -1 ? group.length : group.length - 1;
894        const angle = denominator <= 1 ? 0 : (Math.PI * 2 * normalizedIndex) / denominator;
895        const spreadPoint = L.point(
896          centerX + Math.cos(angle) * radius,
897          centerY + Math.sin(angle) * radius
898        );
899
900        item.marker.setLatLng(state.map.unproject(spreadPoint, zoom));
901      });
902    });
903  }
904
905  function applyFollowSelection() {
906    if (!state.map || !state.followGuid || state.followPaused) {
907      return;
908    }
909
910    const marker = state.markersByGuid.get(state.followGuid);
911
912    if (!marker || !state.map.hasLayer(marker)) {
913      return;
914    }
915
916    markProgrammaticMapChange();
917    state.map.setView(marker.getLatLng(), Math.max(state.map.getZoom(), FOLLOW_MIN_ZOOM), {
918      animate: false,
919    });
920  }
921
922  function popupHtml(vehicle) {
923    const truckBrand = String(vehicle.truck_hersteller || '').trim();
924    const truckModel = String(vehicle.truck_modell || '').trim();
925    const cargo = vehicleCargoLabel(vehicle);
926
927    return [
928      '<div class="ets2-map-popup-card">',
929        '<div class="ets2-map-popup-head">',
930          '<div class="ets2-map-popup-titleblock">',
931            '<h3>' + escapeHtml(vehicle.nickname || 'Unbekannt') + '</h3>',
932            '<p>' + renderTruckBrandMarkup(truckBrand, truckModel) + '</p>',
933          '</div>',
934          '<span class="ets2-live-map-chip ' + (isVehicleDriving(vehicle) ? 'is-driving' : 'is-idle') + '">' + escapeHtml(vehicleStatusLabel(vehicle)) + '</span>',
935        '</div>',
936        '<div class="ets2-map-popup-topline">',
937          '<div class="ets2-map-popup-stat is-inline">',
938            '<strong>⏲ Speed</strong>',
939            '<span>' + Number(vehicle.speed || 0) + ' km/h</span>',
940          '</div>',
941          '<div class="ets2-map-popup-stat is-inline">',
942            '<strong>↔️ Rest</strong>',
943            '<span>' + Number(vehicle.rest_km || 0) + ' km</span>',
944          '</div>',
945        '</div>',
946        '<div class="ets2-map-popup-route">',
947          '<p>🚀 Von:<span>' + escapeHtml(vehicle.startort || '-') + '</span></p>',
948          '<p>🏁 Nach:<span>' + escapeHtml(vehicle.zielort || '-') + '</span></p>',
949        '</div>',
950        '<div class="ets2-map-popup-widecard">',
951          '<strong>📦 Ladung</strong>',
952          '<span>' + escapeHtml(cargo || '-') + '</span>',
953        '</div>',
954        '<div class="ets2-map-popup-grid">',
955          '<span><strong>⛽️ Tank</strong>' + formatPercent(vehicle.fuel) + '</span>',
956          '<span><strong>💥 Schaden</strong>' + formatPercent(vehicle.damage) + '</span>',
957        '</div>',
958        '<div class="ets2-map-popup-plates">',
959          '<div class="ets2-map-popup-plate-card">',
960            '<strong>▤ Trailer</strong>',
961            renderPlateMarkup(vehicle.trailer_plate || '-'),
962          '</div>',
963          '<div class="ets2-map-popup-plate-card">',
964            '<strong>🚚 LKW</strong>',
965            renderPlateMarkup(vehicle.truck_plate || '-'),
966          '</div>',
967        '</div>',
968        '<div class="ets2-map-popup-widecard">',
969          '<strong>🏢 Spedition</strong>',
970          '<span>' + escapeHtml(vehicle.spedition || '-') + '</span>',
971        '</div>',
972        '<div class="ets2-map-popup-widecard">',
973          '<strong>Debug Position</strong>',
974          '<span>' + escapeHtml(formatPositionDebug(vehicle)) + '</span>',
975        '</div>',
976      '</div>',
977    ].join('');
978  }
979
980  function detailsHtml(vehicle) {
981    if (!vehicle) {
982      return '<p class="ets2-live-map-empty">Kein Fahrer ausgew\u00e4hlt.</p>';
983    }
984
985    const truckBrand = String(vehicle.truck_hersteller || '').trim();
986    const truckModel = String(vehicle.truck_modell || '').trim();
987    const truck = [truckBrand, truckModel].filter(Boolean).join(' ');
988    const cargo = vehicleCargoLabel(vehicle);
989    const eta = etaData(vehicle);
990    const isFollowing = state.followGuid === vehicle.guid;
991
992    return '' +
993      '<h3>' + escapeHtml(vehicle.nickname || 'Unbekannt') + '</h3>' +
994      '<div class="ets2-live-map-details-actions">' +
995        '<button type="button" class="ets2-live-map-follow-btn' + (isFollowing && !state.followPaused ? ' is-active' : '') + '" data-action="toggle-follow" data-guid="' + escapeHtml(vehicle.guid) + '">' +
996          (isFollowing && !state.followPaused ? 'Folgt aktiv' : 'Fahrer folgen') +
997        '</button>' +
998        '<button type="button" class="ets2-live-map-follow-btn" data-action="center-driver" data-guid="' + escapeHtml(vehicle.guid) + '">Zentrieren</button>' +
999        '<button type="button" class="ets2-live-map-follow-btn" data-action="open-popup" data-guid="' + escapeHtml(vehicle.guid) + '">Popup</button>' +
1000        '<button type="button" class="ets2-live-map-follow-btn is-danger" data-action="stop-follow" data-guid="' + escapeHtml(vehicle.guid) + '"' + (isFollowing ? '' : ' disabled') + '>Stop</button>' +
1001      '</div>' +
1002      '<div class="ets2-live-map-details-grid">' +
1003        '<div><strong>Status</strong><span>' + escapeHtml(vehicleStatusLabel(vehicle)) + '</span></div>' +
1004        '<div><strong>Geschwindigkeit</strong><span>' + Number(vehicle.speed || 0) + ' km/h</span></div>' +
1005        '<div><strong>Reststrecke</strong><span>' + Number(vehicle.rest_km || 0) + ' km</span></div>' +
1006        '<div><strong>ETA</strong><span>' + escapeHtml(eta.arrivalText) + ' (' + escapeHtml(eta.durationText) + ')</span></div>' +
1007        '<div><strong>Kennzeichen</strong><span>' + renderPlateMarkup(vehicle.truck_plate || '-') + '</span></div>' +
1008        '<div><strong>Trailer</strong><span>' + renderPlateMarkup(vehicle.trailer_plate || '-') + '</span></div>' +
1009        '<div><strong>Truck</strong><span>' + renderTruckBrandMarkup(truckBrand, truckModel) + '</span></div>' +
1010        '<div><strong>Ladung</strong><span>' + escapeHtml(cargo || '-') + '</span></div>' +
1011        '<div><strong>Tank</strong><span>' + formatPercent(vehicle.fuel) + '</span></div>' +
1012        '<div><strong>Schaden</strong><span>' + formatPercent(vehicle.damage) + '</span></div>' +
1013        '<div><strong>Spedition</strong><span>' + escapeHtml(vehicle.spedition || '-') + '</span></div>' +
1014        '<div><strong>Zuletzt bewegt</strong><span>' + formatRelativeAge(Date.now() - vehicleMeta(vehicle).lastMovedAt) + '</span></div>' +
1015        '<div><strong>GUID</strong><span>' + escapeHtml(vehicle.guid || '-') + '</span></div>' +
1016        '<div><strong>Positionsquelle</strong><span>' + escapeHtml(vehicle.position_source || '-') + '</span></div>' +
1017        '<div><strong>Marker X/Z</strong><span>' + escapeHtml(formatCoordinatePair(vehicle.pos_x, vehicle.pos_z)) + '</span></div>' +
1018        '<div><strong>Raw X/Y/Z</strong><span>' + escapeHtml(formatCoordinateTriplet(vehicle.raw_pos_x, vehicle.raw_pos_y, vehicle.raw_pos_z)) + '</span></div>' +
1019        '<div><strong>Route X/Z</strong><span>' + escapeHtml(formatCoordinatePair(vehicle.route_pos_x, vehicle.route_pos_z)) + '</span></div>' +
1020        '<div><strong>Telemetrie</strong><span>' + escapeHtml(vehicle.telemetry_updated_at || '-') + '</span></div>' +
1021      '</div>' +
1022      '<div class="ets2-live-map-details-route">' +
1023        '<p><strong>Von:</strong> ' + escapeHtml(vehicle.startort || '-') + '</p>' +
1024        '<p><strong>Nach:</strong> ' + escapeHtml(vehicle.zielort || '-') + '</p>' +
1025      '</div>';
1026  }
1027
1028  function formatCoordinate(value) {
1029    const numeric = Number(value);
1030
1031    if (!Number.isFinite(numeric)) {
1032      return '-';
1033    }
1034
1035    return numeric.toFixed(2);
1036  }
1037
1038  function formatCoordinatePair(x, z) {
1039    if (!Number.isFinite(Number(x)) || !Number.isFinite(Number(z))) {
1040      return '-';
1041    }
1042
1043    return formatCoordinate(x) + ' / ' + formatCoordinate(z);
1044  }
1045
1046  function formatCoordinateTriplet(x, y, z) {
1047    if (!Number.isFinite(Number(x)) || !Number.isFinite(Number(y)) || !Number.isFinite(Number(z))) {
1048      return '-';
1049    }
1050
1051    return formatCoordinate(x) + ' / ' + formatCoordinate(y) + ' / ' + formatCoordinate(z);
1052  }
1053
1054  function formatPositionDebug(vehicle) {
1055    return [
1056      'Quelle: ' + String(vehicle.position_source || '-'),
1057      'Marker ' + formatCoordinatePair(vehicle.pos_x, vehicle.pos_z),
1058      'Raw ' + formatCoordinateTriplet(vehicle.raw_pos_x, vehicle.raw_pos_y, vehicle.raw_pos_z),
1059      'Route ' + formatCoordinatePair(vehicle.route_pos_x, vehicle.route_pos_z),
1060    ].join(' | ');
1061  }
1062
1063  function formatPercent(value) {
1064    const numeric = Number(value);
1065
1066    if (!Number.isFinite(numeric)) {
1067      return '-';
1068    }
1069
1070    const scaled = numeric <= 1 ? numeric * 100 : numeric;
1071    return Math.max(0, Math.min(100, Math.round(scaled))) + '%';
1072  }
1073
1074  function formatRelativeAge(ms) {
1075    if (ms < 60000) {
1076      return '< 1 min';
1077    }
1078
1079    const minutes = Math.round(ms / 60000);
1080
1081    if (minutes < 60) {
1082      return minutes + ' min';
1083    }
1084
1085    const hours = Math.floor(minutes / 60);
1086    const restMinutes = minutes % 60;
1087    return hours + 'h ' + String(restMinutes).padStart(2, '0') + 'm';
1088  }
1089
1090  function overlayTypeLabel(type) {
1091    const labels = {
1092      Company: 'Firmen',
1093      Fuel: 'Tankstellen',
1094      Parking: 'Parkpl\u00e4tze',
1095      Overlay: 'Schilder',
1096      Service: 'Services',
1097      Garage: 'Garagen',
1098      BusStop: 'Bushaltestellen',
1099      'Bus Stop': 'Bushaltestellen',
1100      TruckDealer: 'H\u00e4ndler',
1101      Recruitment: 'Recruiting',
1102      Viewpoint: 'Aussichtspunkte',
1103      Ferry: 'F\u00e4hren',
1104      WeightStation: 'Waagestationen',
1105      Train: 'Z\u00fcge',
1106    };
1107
1108    return labels[type] || type;
1109  }
1110
1111  function overlayTypeDescription(type) {
1112    const descriptions = {
1113      Company: 'Firmenlogos und Lieferziele',
1114      Fuel: 'Tank-Icons auf der Karte',
1115      Parking: 'Parkpl\u00e4tze und Ruhepunkte',
1116      Overlay: 'Stra\u00dfen- und Symbol-Overlays',
1117      Service: 'Werkstatt- und Servicepunkte',
1118      Garage: 'Garagenstandorte',
1119      'Bus Stop': 'Bus-Haltepunkte',
1120      TruckDealer: 'LKW-H\u00e4ndler',
1121      Recruitment: 'Recruiting-Agenturen',
1122      Viewpoint: 'Viewpoints und Fotopunkte',
1123      Ferry: 'F\u00e4hren und Hafenziele',
1124      WeightStation: 'Kontroll- und Wiegestationen',
1125      Train: 'Zugterminals',
1126    };
1127
1128    return descriptions[type] || 'Overlay aus dem Kartenexport';
1129  }
1130
1131  function overlayMarkerSize(entry) {
1132    const width = Number(entry.Width) || 32;
1133    const height = Number(entry.Height) || 32;
1134    const maxWidth = entry.Type === 'Company' ? 88 : 28;
1135    const scale = Math.min(1, maxWidth / width);
1136
1137    return [
1138      Math.max(18, Math.round(width * scale)),
1139      Math.max(18, Math.round(height * scale)),
1140    ];
1141  }
1142
1143  function overlayIcon(entry) {
1144    const iconSize = overlayMarkerSize(entry);
1145    const imagePath = '/_SITES/ETS2_LIVE_MAP/tileset/Overlays/' + entry.Name + '.png';
1146
1147    return L.icon({
1148      iconUrl: imagePath,
1149      iconSize: iconSize,
1150      iconAnchor: [Math.round(iconSize[0] / 2), Math.round(iconSize[1] / 2)],
1151      className: 'ets2-map-overlay-icon',
1152    });
1153  }
1154
1155  function syncOverlayLayerVisibility() {
1156    if (!state.map) {
1157      return;
1158    }
1159
1160    state.overlayLayerGroupsByType.forEach(function (layerGroup, type) {
1161      const shouldShow = state.visibleOverlayTypes.has(type);
1162
1163      if (shouldShow) {
1164        if (!state.map.hasLayer(layerGroup)) {
1165          layerGroup.addTo(state.map);
1166        }
1167        return;
1168      }
1169
1170      if (state.map.hasLayer(layerGroup)) {
1171        layerGroup.remove();
1172      }
1173    });
1174  }
1175
1176  function renderOverlayControls() {
1177    if (!els.overlayList) {
1178      return;
1179    }
1180
1181    const overlayPriority = {
1182      Company: 1,
1183      Fuel: 2,
1184      Service: 3,
1185      Garage: 4,
1186      TruckDealer: 5,
1187      Recruitment: 6,
1188      Ferry: 7,
1189      WeightStation: 8,
1190      Parking: 9,
1191      Viewpoint: 10,
1192      Train: 11,
1193      'Bus Stop': 12,
1194      BusStop: 12,
1195      Overlay: 13,
1196    };
1197
1198    const types = Array.from(state.overlayCountsByType.keys()).sort(function (left, right) {
1199      const leftPriority = overlayPriority[left] || 99;
1200      const rightPriority = overlayPriority[right] || 99;
1201
1202      if (leftPriority !== rightPriority) {
1203        return leftPriority - rightPriority;
1204      }
1205
1206      return String(overlayTypeLabel(left)).localeCompare(String(overlayTypeLabel(right)), 'de');
1207    });
1208
1209    if (!types.length) {
1210      els.overlayList.innerHTML = '<p class="ets2-live-map-empty">Keine Overlay-Daten gefunden.</p>';
1211      return;
1212    }
1213
1214    els.overlayList.innerHTML = types.map(function (type) {
1215      const checked = state.visibleOverlayTypes.has(type) ? ' checked' : '';
1216      const safeType = escapeHtml(type);
1217
1218      return '' +
1219        '<label class="ets2-live-map-layer">' +
1220          '<span>' +
1221            '<strong>' + escapeHtml(overlayTypeLabel(type)) + '</strong>' +
1222            '<small>' + escapeHtml(overlayTypeDescription(type)) + ' · ' + String(state.overlayCountsByType.get(type) || 0) + '</small>' +
1223          '</span>' +
1224          '<input type="checkbox" data-overlay-type="' + safeType + '"' + checked + '>
1224' +
1225        '</label>';
1226    }).join('');
1227
1228    Array.from(els.overlayList.querySelectorAll('[data-overlay-type]')).forEach(function (input) {
1229      input.addEventListener('change', function () {
1230        const type = input.getAttribute('data-overlay-type');
1231
1232        if (!type) {
1233          return;
1234        }
1235
1236        if (input.checked) {
1237          state.visibleOverlayTypes.add(type);
1238        } else {
1239          state.visibleOverlayTypes.delete(type);
1240        }
1241
1242        syncOverlayLayerVisibility();
1243        persistUiState();
1244      });
1245    });
1246  }
1247
1248  async function loadOverlays() {
1249    if (!state.overlaysUrl || !state.map) {
1250      return;
1251    }
1252
1253    const response = await fetch(state.overlaysUrl, {
1254      cache: 'no-store',
1255      headers: {
1256        Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
1257      },
1258    });
1259
1260    if (!response.ok) {
1261      throw new Error('Overlays.json konnte nicht geladen werden');
1262    }
1263
1264    const text = (await response.text()).replace(/^\uFEFF/, '');
1265    const payload = JSON.parse(text);
1266    const overlays = Array.isArray(payload) ? payload : [];
1267
1268    state.overlayLayerGroupsByType.forEach(function (layerGroup) {
1269      layerGroup.remove();
1270    });
1271    state.overlayLayerGroupsByType.clear();
1272    state.overlayCountsByType.clear();
1273
1274    overlays.forEach(function (entry) {
1275      if (!entry || typeof entry !== 'object') {
1276        return;
1277      }
1278
1279      const type = String(entry.Type || 'Overlay');
1280      const group = state.overlayLayerGroupsByType.get(type) || L.layerGroup();
1281
1282      if (!state.overlayLayerGroupsByType.has(type)) {
1283        state.overlayLayerGroupsByType.set(type, group);
1284        state.overlayCountsByType.set(type, 0);
1285      }
1286
1287      const marker = L.marker(gameCoordsLatLng(entry.X, entry.Y), {
1288        icon: overlayIcon(entry),
1289        keyboard: false,
1290        interactive: false,
1291        zIndexOffset: type === 'Overlay' ? 50 : 120,
1292      });
1293
1294      marker.bindTooltip(overlayTypeLabel(type) + ': ' + String(entry.Name || '-'), {
1295        direction: 'top',
1296        offset: [0, -6],
1297        opacity: 0.96,
1298        className: 'ets2-map-name-tooltip',
1299      });
1300
1301      marker.addTo(group);
1302      state.overlayCountsByType.set(type, (state.overlayCountsByType.get(type) || 0) + 1);
1303    });
1304
1305    renderOverlayControls();
1306    syncOverlayLayerVisibility();
1307  }
1308
1309  function renderPlateMarkup(plate) {
1310    const value = String(plate || '-').trim() || '-';
1311
1312    if (value === '-') {
1313      return '<span class="ets2-plate is-empty">-</span>';
1314    }
1315
1316    return '' +
1317      '<span class="ets2-plate" aria-label="Kennzeichen ' + escapeHtml(value) + '">' +
1318        '<span class="ets2-plate-eu"><span>D</span></span>' +
1319        '<span class="ets2-plate-text">' + escapeHtml(value) + '</span>' +
1320      '</span>';
1321  }
1322
1323  function normalizeTruckBrand(brand) {
1324    const value = String(brand || '').trim().toLowerCase();
1325
1326    if (!value) {
1327      return '';
1328    }
1329
1330    if (value.includes('mercedes')) {
1331      return 'mercedes-benz';
1332    }
1333
1334    if (value.includes('volkswagen') || value.includes('vw')) {
1335      return 'volkswagen';
1336    }
1337
1338    if (value.includes('daf')) {
1339      return 'daf';
1340    }
1341
1342    if (value.includes('ford')) {
1343      return 'ford';
1344    }
1345
1346    if (value.includes('iveco')) {
1347      return 'iveco';
1348    }
1349
1350    if (value.includes('man')) {
1351      return 'man';
1352    }
1353
1354    if (value.includes('renault')) {
1355      return 'renault';
1356    }
1357
1358    if (value.includes('scania')) {
1359      return 'scania';
1360    }
1361
1362    if (value.includes('volvo')) {
1363      return 'volvo';
1364    }
1365
1366    return value.replace(/[^a-z0-9-]/g, '-');
1367  }
1368
1369  function renderTruckBrandMarkup(brand, model) {
1370    const brandText = String(brand || '').trim();
1371    const modelText = String(model || '').trim();
1372    const normalizedBrand = normalizeTruckBrand(brandText);
1373    const imagePath = normalizedBrand ? '/_IMG/trucks/' + normalizedBrand + '.png' : '';
1374    const label = [brandText, modelText].filter(Boolean).join(' ') || 'Truck unbekannt';
1375    const logo = imagePath
1376      ? '<img class="ets2-truck-brand-logo" src="' + escapeHtml(imagePath) + '" alt="' + escapeHtml(brandText || 'Truck') + '" loading="lazy">'
1377      : '<span class="ets2-truck-brand-fallback">' + escapeHtml(brandText || 'Truck') + '</span>';
1378
1379    return '' +
1380      '<span class="ets2-truck-brand" aria-label="' + escapeHtml(label) + '">' +
1381        logo +
1382        (modelText ? '<span class="ets2-truck-brand-model">' + escapeHtml(modelText) + '</span>' : '') +
1383      '</span>';
1384  }
1385
1386  function initMap(config) {
1387    state.config = config;
1388    const scaleX = Number(config.map.tileSize) / (Number(config.map.x2) - Number(config.map.x1));
1389    const scaleY = Number(config.map.tileSize) / (Number(config.map.y2) - Number(config.map.y1));
1390    state.crs = L.extend({}, L.CRS.Simple, {
1391      transformation: new L.Transformation(
1392        scaleX,
1393        -(Number(config.map.x1) * scaleX),
1394        scaleY,
1395        -(Number(config.map.y1) * scaleY)
1396      ),
1397    });
1398
1399    state.map = L.map(els.canvas, {
1400      crs: state.crs,
1401      minZoom: config.map.minZoom,
1402      maxZoom: config.map.maxZoom,
1403      zoomSnap: 1,
1404      zoomControl: false,
1405      attributionControl: true,
1406      preferCanvas: true,
1407      zoomAnimation: false,
1408      fadeAnimation: false,
1409      markerZoomAnimation: false,
1410    });
1411
1412    state.bounds = L.latLngBounds(
1413      L.latLng(Number(config.map.y1), Number(config.map.x1)),
1414      L.latLng(Number(config.map.y2), Number(config.map.x2))
1415    );
1416
1417    const contentBounds = config.map.contentBounds
1418      ? L.latLngBounds(
1419          L.latLng(Number(config.map.contentBounds.y1), Number(config.map.contentBounds.x1)),
1420          L.latLng(Number(config.map.contentBounds.y2), Number(config.map.contentBounds.x2))
1421        )
1422      : state.bounds;
1423    const renderBounds = contentBounds.pad(0.15);
1424
1425    L.tileLayer(state.tilesUrlTemplate, {
1426      tileSize: config.map.tileSize || 512,
1427      noWrap: true,
1428      minZoom: config.map.minZoom,
1429      maxZoom: config.map.maxZoom,
1430      errorTileUrl: '',
1431      attribution: '&copy; SCS_Map_Tiles',
1432    }).addTo(state.map);
1433
1434    L.control.zoom({
1435      position: 'bottomleft',
1436    }).addTo(state.map);
1437
1438    state.map.on('zoomstart movestart', function () {
1439      els.canvas.classList.add('is-map-transforming');
1440
1441      if (!isProgrammaticMapChange()) {
1442        pauseFollow('Manuell pausiert');
1443      }
1444    });
1445
1446    state.map.on('zoomend moveend', function () {
1447      els.canvas.classList.remove('is-map-transforming');
1448    });
1449
1450    markProgrammaticMapChange();
1451    state.map.fitBounds(renderBounds);
1452    window.setTimeout(function () {
1453      state.map.invalidateSize();
1454    }, 50);
1455
1456    if (els.fit) {
1457      els.fit.addEventListener('click', function () {
1458        fitAllMarkers();
1459      });
1460    }
1461  }
1462
1463  function setSelectedGuid(guid, enableFollow) {
1464    state.selectedGuid = guid || null;
1465
1466    if (enableFollow && guid) {
1467      state.followGuid = guid;
1468      state.followPaused = false;
1469      state.followPauseReason = '';
1470    }
1471
1472    updateFollowCard();
1473    renderListSelection();
1474    if (enableFollow) {
1475      applyFollowSelection();
1476    }
1477    persistUiState();
1478  }
1479
1480  function updateMarkers(vehicles) {
1481    const nextGuids = new Set();
1482
1483    vehicles.forEach(function (vehicle) {
1484      nextGuids.add(vehicle.guid);
1485      state.vehiclesByGuid.set(vehicle.guid, vehicle);
1486
1487      const nextLatLng = vehicleLatLng(vehicle);
1488      let marker = state.markersByGuid.get(vehicle.guid);
1489      const isVisible = state.showMarkers && visibleVehicles([vehicle]).length > 0;
1490
1491      if (!marker) {
1492        marker = L.marker(nextLatLng, {
1493          icon: buildDivIcon(vehicle, vehicle.guid === state.selectedGuid),
1494          keyboard: false,
1495        });
1496
1497        marker.bindPopup(popupHtml(vehicle), {
1498          className: 'ets2-map-leaflet-popup',
1499          offset: [0, -8],
1500        });
1501        marker.bindTooltip(vehicleTooltipHtml(vehicle), {
1502          direction: 'top',
1503          offset: [0, -14],
1504          opacity: 1,
1505          className: 'ets2-map-name-tooltip',
1506        });
1507        marker.on('click', function () {
1508          setSelectedGuid(vehicle.guid, true);
1509        });
1510        marker.addTo(state.map);
1511
1512        state.markersByGuid.set(vehicle.guid, marker);
1513      } else {
1514        marker.setLatLng(nextLatLng);
1515        marker.setIcon(buildDivIcon(vehicle, vehicle.guid === state.selectedGuid));
1516        marker.setPopupContent(popupHtml(vehicle));
1517        marker.setTooltipContent(vehicleTooltipHtml(vehicle));
1518      }
1519
1520      if (isVisible) {
1521        if (!state.map.hasLayer(marker)) {
1522          marker.addTo(state.map);
1523        }
1524      } else if (state.map.hasLayer(marker)) {
1525        marker.closeTooltip();
1526        marker.remove();
1527      }
1528    });
1529
1530    state.markersByGuid.forEach(function (marker, guid) {
1531      if (nextGuids.has(guid)) {
1532        return;
1533      }
1534
1535      marker.remove();
1536      state.markersByGuid.delete(guid);
1537      state.vehiclesByGuid.delete(guid);
1538
1539      if (state.selectedGuid === guid) {
1540        state.selectedGuid = null;
1541      }
1542
1543      if (state.followGuid === guid) {
1544        stopFollow();
1545      }
1546    });
1547
1548    if (!state.hasFitOnce && vehicles.length) {
1549      fitAllMarkers();
1550      state.hasFitOnce = true;
1551    }
1552
1553    applyMarkerOverlapLayout();
1554    syncMarkerVisualState();
1555    updateFollowCard();
1556    applyFollowSelection();
1557  }
1558
1559  function fitAllMarkers() {
1560    const vehicles = visibleVehiclesFromState();
1561
1562    if (!vehicles.length) {
1563      markProgrammaticMapChange();
1564      state.map.fitBounds(state.bounds);
1565      return;
1566    }
1567
1568    const groupBounds = L.latLngBounds(vehicles.map(vehicleLatLng));
1569    const isAllScope = state.scope === 'all';
1570    const bounds = groupBounds.pad(isAllScope ? 0.2 : 0.2);
1571
1572    markProgrammaticMapChange();
1573
1574    if (!isAllScope) {
1575      state.map.fitBounds(bounds, {
1576        maxZoom: 7,
1577      });
1578      return;
1579    }
1580
1581    const fittedZoom = state.map.getBoundsZoom(bounds);
1582    const targetZoom = Math.min(state.map.getMaxZoom(), fittedZoom + 1);
1583    state.map.setView(bounds.getCenter(), targetZoom);
1584  }
1585
1586  function listSignature(vehicles) {
1587    return vehicles.map(function (vehicle) {
1588      return [
1589        vehicle.guid,
1590        vehicle.nickname,
1591        vehicle.speed,
1592        vehicle.rest_km,
1593        vehicleStatusLabel(vehicle),
1594        state.selectedGuid === vehicle.guid ? '1' : '0',
1595        state.followGuid === vehicle.guid && !state.followPaused ? '1' : '0',
1596        etaData(vehicle).arrivalText,
1597      ].join(':');
1598    }).join('|') + '|' + state.sortMode + '|' + state.searchQuery + '|' + state.statusFilter + '|' + state.showMarkers + '|' + state.showDriving + '|' + state.showIdle;
1599  }
1600
1601  function detailsSignature(vehicle) {
1602    if (!vehicle) {
1603      return 'empty:' + state.followGuid + ':' + state.followPaused;
1604    }
1605
1606    return [
1607      vehicle.guid,
1608      vehicle.nickname,
1609      vehicle.speed,
1610      vehicle.rest_km,
1611      vehicle.fuel,
1612      vehicle.damage,
1613      vehicle.ladung,
1614      vehicle.trailer_plate,
1615      vehicle.position_source,
1616      vehicle.pos_x,
1617      vehicle.pos_z,
1618      vehicle.raw_pos_x,
1619      vehicle.raw_pos_y,
1620      vehicle.raw_pos_z,
1621      vehicle.route_pos_x,
1622      vehicle.route_pos_z,
1623      vehicle.telemetry_updated_at,
1624      vehicleStatusLabel(vehicle),
1625      etaData(vehicle).arrivalText,
1626      state.followGuid,
1627      state.followPaused ? '1' : '0',
1628    ].join('|');
1629  }
1630
1631  function renderList(vehicles) {
1632    const filteredVehicles = sortVehicles(visibleVehicles(vehicles));
1633    const signature = listSignature(filteredVehicles);
1634
1635    updateVisibleVehicleCount();
1636
1637    if (signature === state.lastListSignature) {
1638      return;
1639    }
1640
1641    state.lastListSignature = signature;
1642
1643    if (!filteredVehicles.length) {
1644      els.list.innerHTML = '<p class="ets2-live-map-empty">Keine Fahrer passend zur aktuellen Ansicht gefunden.</p>';
1645      return;
1646    }
1647
1648    els.list.innerHTML = filteredVehicles.map(function (vehicle) {
1649      const eta = etaData(vehicle);
1650      const selected = vehicle.guid === state.selectedGuid ? ' is-active' : '';
1651      const following = state.followGuid === vehicle.guid && !state.followPaused ? ' is-following' : '';
1652
1653      return '' +
1654        '<button type="button" class="ets2-live-map-item' + selected + following + '" data-guid="' + escapeHtml(vehicle.guid) + '">' +
1655          '<div class="ets2-live-map-item-head">' +
1656            '<strong>' + escapeHtml(vehicle.nickname || 'Unbekannt') + '</strong>' +
1657            '<span class="ets2-live-map-chip ' + (isVehicleDriving(vehicle) ? 'is-driving' : 'is-idle') + '">' + escapeHtml(vehicleStatusLabel(vehicle)) + '</span>' +
1658          '</div>' +
1659          '<p>' + renderTruckBrandMarkup(String(vehicle.truck_hersteller || '').trim(), String(vehicle.truck_modell || '').trim()) + '</p>' +
1660          '<div class="ets2-live-map-item-meta">' +
1661            '<span>' + Number(vehicle.speed || 0) + ' km/h</span>' +
1662            '<span>' + Number(vehicle.rest_km || 0) + ' km Rest</span>' +
1663          '</div>' +
1664          '<div class="ets2-live-map-item-submeta">' +
1665            '<span>ETA ' + escapeHtml(eta.arrivalText) + '</span>' +
1666            '<span>' + escapeHtml(eta.durationText) + '</span>' +
1667          '</div>' +
1668        '</button>';
1669    }).join('');
1670
1671    Array.from(els.list.querySelectorAll('.ets2-live-map-item')).forEac
1671h(function (button) {
1672      button.addEventListener('click', function () {
1673        const marker = state.markersByGuid.get(button.dataset.guid);
1674
1675        if (!marker) {
1676          return;
1677        }
1678
1679        setSelectedGuid(button.dataset.guid, true);
1680        marker.openPopup();
1681        closeSidebar();
1682      });
1683    });
1684  }
1685
1686  function renderListSelection() {
1687    Array.from(els.list.querySelectorAll('.ets2-live-map-item')).forEach(function (button) {
1688      const guid = button.dataset.guid;
1689      button.classList.toggle('is-active', guid === state.selectedGuid);
1690      button.classList.toggle('is-following', guid === state.followGuid && !state.followPaused);
1691    });
1692
1693    syncMarkerVisualState();
1694    renderDetails();
1695  }
1696
1697  function bindDetailActions() {
1698    Array.from(els.details.querySelectorAll('[data-action]')).forEach(function (button) {
1699      button.addEventListener('click', function () {
1700        const guid = button.getAttribute('data-guid');
1701        const action = button.getAttribute('data-action');
1702        const marker = guid ? state.markersByGuid.get(guid) : null;
1703
1704        if (action === 'toggle-follow' && guid) {
1705          if (state.followGuid === guid && !state.followPaused) {
1706            pauseFollow('Manuell pausiert');
1707          } else {
1708            setSelectedGuid(guid, true);
1709          }
1710          return;
1711        }
1712
1713        if (action === 'center-driver' && marker) {
1714          markProgrammaticMapChange();
1715          state.map.setView(marker.getLatLng(), Math.max(state.map.getZoom(), FOLLOW_MIN_ZOOM), {
1716            animate: false,
1717          });
1718          return;
1719        }
1720
1721        if (action === 'open-popup' && marker) {
1722          marker.openPopup();
1723          return;
1724        }
1725
1726        if (action === 'stop-follow') {
1727          stopFollow();
1728        }
1729      });
1730    });
1731  }
1732
1733  function renderDetails() {
1734    const vehicle = state.selectedGuid ? state.vehiclesByGuid.get(state.selectedGuid) : null;
1735    const signature = detailsSignature(vehicle || null);
1736
1737    if (signature === state.lastDetailsSignature) {
1738      return;
1739    }
1740
1741    state.lastDetailsSignature = signature;
1742    els.details.innerHTML = detailsHtml(vehicle || null);
1743    bindDetailActions();
1744  }
1745
1746  function rerender(forceFull) {
1747    if (forceFull) {
1748      state.lastListSignature = '';
1749      state.lastDetailsSignature = '';
1750    }
1751
1752    updateMarkers(state.lastVehicles);
1753    renderList(state.lastVehicles);
1754    renderDetails();
1755    persistUiState();
1756  }
1757
1758  function feedSignature(vehicles) {
1759    return vehicles.map(function (vehicle) {
1760      return [
1761        vehicle.guid,
1762        vehicle.pos_x,
1763        vehicle.pos_z,
1764        vehicle.speed,
1765        vehicle.rest_km,
1766        vehicle.fuel,
1767        vehicle.damage,
1768        vehicle.truck_plate,
1769        vehicle.trailer_plate,
1770        vehicle.ladung,
1771      ].join(':');
1772    }).join('|');
1773  }
1774
1775  async function loadFeed() {
1776    if (state.isLoadingFeed) {
1777      return;
1778    }
1779
1780    state.isLoadingFeed = true;
1781
1782    const url = new URL(state.feedUrl, window.location.origin);
1783    url.searchParams.set('scope', state.scope);
1784    url.searchParams.set('_', String(Date.now()));
1785
1786    try {
1787      const response = await fetch(url.toString(), {
1788        credentials: 'same-origin',
1789        headers: {
1790          Accept: 'application/json',
1791        },
1792      });
1793
1794      const text = (await response.text()).replace(/^\uFEFF/, '');
1795      let payload = null;
1796
1797      try {
1798        payload = JSON.parse(text);
1799      } catch (error) {
1800        throw new Error(text.trim() || 'Feed lieferte kein JSON');
1801      }
1802
1803      if (!response.ok || payload.error) {
1804        throw new Error(payload.details || payload.error || 'Feed-Fehler');
1805      }
1806
1807      const vehicles = payload.vehicles || [];
1808
1809      setUpdated(payload.generated_at);
1810      state.consecutiveFeedErrors = 0;
1811      updateVehicleMeta(vehicles);
1812      state.lastVehicles = vehicles;
1813      state.lastFeedSignature = feedSignature(vehicles);
1814
1815      const visibleCount = visibleVehicles(vehicles).length;
1816      setStatus(visibleCount + ' Fahrer sichtbar');
1817      rerender(false);
1818    } finally {
1819      state.isLoadingFeed = false;
1820    }
1821  }
1822
1823  function nextRefreshDelay() {
1824    if (document.hidden) {
1825      return FEED_REFRESH_HIDDEN_MS;
1826    }
1827
1828    if (!state.consecutiveFeedErrors) {
1829      return FEED_REFRESH_MS;
1830    }
1831
1832    return Math.min(FEED_REFRESH_MS * Math.pow(2, state.consecutiveFeedErrors), FEED_REFRESH_MAX_MS);
1833  }
1834
1835  function scheduleRefresh(delay) {
1836    if (state.timerId) {
1837      window.clearTimeout(state.timerId);
1838    }
1839
1840    state.timerId = window.setTimeout(async function () {
1841      if (!document.hidden) {
1842        await refresh();
1843      }
1844
1845      scheduleRefresh(nextRefreshDelay());
1846    }, delay);
1847  }
1848
1849  async function refresh() {
1850    try {
1851      await loadFeed();
1852    } catch (error) {
1853      state.consecutiveFeedErrors += 1;
1854      setStatus(error.message || 'Feed konnte nicht geladen werden');
1855    }
1856  }
1857
1858  function bindStaticEvents() {
1859    if (els.followResume) {
1860      els.followResume.addEventListener('click', resumeFollow);
1861    }
1862
1863    if (els.followStop) {
1864      els.followStop.addEventListener('click', stopFollow);
1865    }
1866
1867      els.scopes.forEach(function (button) {
1868        button.addEventListener('click', function () {
1869          if (button.disabled) {
1870            return;
1871          }
1872
1873          if (button.dataset.scope === 'company') {
1874            state.scope = state.scope === 'company' ? 'all' : 'company';
1875          } else {
1876            if (button.dataset.scope === state.scope) {
1877              return;
1878            }
1879
1880            state.scope = button.dataset.scope;
1881          }
1882
1883          state.hasFitOnce = false;
1884          state.selectedGuid = null;
1885          stopFollow();
1886        setActiveScope();
1887        persistUiState();
1888        refresh();
1889      });
1890    });
1891
1892    els.panelButtons.forEach(function (button) {
1893      button.addEventListener('click', function () {
1894        setActivePanel(button.dataset.panelTarget);
1895        persistUiState();
1896        openSidebar();
1897      });
1898    });
1899
1900    els.statusFilters.forEach(function (button) {
1901      button.addEventListener('click', function () {
1902        if (button.dataset.statusFilter === state.statusFilter) {
1903          return;
1904        }
1905
1906        state.statusFilter = button.dataset.statusFilter || 'all';
1907
1908        if (state.statusFilter === 'driving') {
1909          state.showDriving = true;
1910        } else if (state.statusFilter === 'idle') {
1911          state.showIdle = true;
1912        }
1913
1914        syncLayerVisibilityInputs();
1915        setActiveStatusFilter();
1916        rerender(true);
1917      });
1918    });
1919
1920    if (els.sort) {
1921      els.sort.addEventListener('change', function () {
1922        state.sortMode = els.sort.value || 'activity';
1923        rerender(true);
1924      });
1925    }
1926
1927    if (els.search) {
1928      els.search.addEventListener('input', function () {
1929        state.searchQuery = els.search.value.trim().toLowerCase();
1930        rerender(true);
1931      });
1932    }
1933
1934    if (els.layerMarkers) {
1935      els.layerMarkers.addEventListener('change', function () {
1936        state.showMarkers = els.layerMarkers.checked;
1937        rerender(true);
1938      });
1939    }
1940
1941    if (els.layerDriving) {
1942      els.layerDriving.addEventListener('change', function () {
1943        state.showDriving = els.layerDriving.checked;
1944        rerender(true);
1945      });
1946    }
1947
1948    if (els.layerIdle) {
1949      els.layerIdle.addEventListener('change', function () {
1950        state.showIdle = els.layerIdle.checked;
1951        rerender(true);
1952      });
1953    }
1954  }
1955
1956  async function bootstrap() {
1957    try {
1958      applyStoredUi();
1959      setActiveScope();
1960      setActivePanel(state.activePanel);
1961      setActiveStatusFilter();
1962      setActiveSortMode();
1963      hidePageFooter();
1964      syncViewportLayout();
1965      setupOffcanvas();
1966      bindMapDismiss();
1967      bindStaticEvents();
1968      updateFollowCard();
1969      setStatus('Tile-Konfiguration wird geladen');
1970
1971      const response = await fetch(state.tilesInfoUrl, {
1972        cache: 'no-store',
1973        headers: {
1974          Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
1975        },
1976      });
1977      const rawText = (await response.text()).replace(/^\uFEFF/, '');
1978      let parsedConfig = null;
1979
1980      try {
1981        parsedConfig = JSON.parse(rawText);
1982      } catch (error) {
1983        throw new Error('TileMapInfo ist kein JSON: ' + rawText.slice(0, 220));
1984      }
1985
1986      let config = null;
1987
1988      try {
1989        config = normalizeTileConfig(parsedConfig);
1990      } catch (error) {
1991        throw new Error('TileMapInfo Formatfehler (' + describeTileConfigPayload(parsedConfig) + ')');
1992      }
1993
1994      if (!response.ok) {
1995        throw new Error('TileMapInfo konnte nicht geladen werden');
1996      }
1997
1998      initMap(config);
1999      await loadOverlays();
2000      setStatus('Lokale ETS2-Tiles geladen');
2001      await refresh();
2002      scheduleRefresh(nextRefreshDelay());
2003
2004      document.addEventListener('visibilitychange', function () {
2005        if (!document.hidden) {
2006          syncViewportLayout();
2007          refresh();
2008        } else {
2009          setStatus('Live-Refresh pausiert (Tab inaktiv)');
2010        }
2011
2012        scheduleRefresh(nextRefreshDelay());
2013      });
2014
2015      window.addEventListener('resize', syncViewportLayout);
2016      window.addEventListener('orientationchange', syncViewportLayout);
2017      window.addEventListener('fullscreenchange', syncViewportLayout);
2018
2019      if (window.visualViewport) {
2020        window.visualViewport.addEventListener('resize', syncViewportLayout);
2021        window.visualViewport.addEventListener('scroll', syncViewportLayout);
2022      }
2023
2024      state.map.on('zoomend', function () {
2025        applyMarkerOverlapLayout();
2026        syncMarkerVisualState();
2027        applyFollowSelection();
2028      });
2029
2030      state.map.on('moveend', function () {
2031        applyMarkerOverlapLayout();
2032        updateFollowCard();
2033      });
2034
2035      persistUiState();
2036      syncViewportLayout();
2037    } catch (error) {
2038      setStatus(error.message || 'Map konnte nicht initialisiert werden');
2039    }
2040  }
2041
2042  function escapeHtml(value) {
2043    return String(value)
2044      .replaceAll('&', '&amp;')
2045      .replaceAll('<', '&lt;')
2046      .replaceAll('>', '&gt;')
2047      .replaceAll('"', '&quot;')
2048      .replaceAll("'", '&#039;');
2049  }
2050
2051  bootstrap();
2052})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.