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: '© 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('&', '&') 2045 .replaceAll('<', '<') 2046 .replaceAll('>', '>') 2047 .replaceAll('"', '"') 2048 .replaceAll("'", '''); 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.