Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2025-10-20 13:13:06 +02:00
1 parent 710c47f481
commit 69f1742486
3 files changed
+693 -76

No files matched your search

+193 -44
View File
@@ -1,19 +1,56 @@
// ============================= // =============================
// Karte initialisieren // Dark Mode
// =============================
const darkModeToggle = document.getElementById('darkModeToggle');
const body = document.body;
const savedTheme = localStorage.getItem('theme') || 'light';
if (savedTheme === 'dark') {
body.classList.add('dark-mode');
darkModeToggle.querySelector('.toggle-icon').textContent = '☀️';
}
darkModeToggle.addEventListener('click', () => {
body.classList.toggle('dark-mode');
const isDark = body.classList.contains('dark-mode');
darkModeToggle.querySelector('.toggle-icon').textContent = isDark ? '☀️' : '🌙';
localStorage.setItem('theme', isDark ? 'dark' : 'light');
updateMapTiles();
});
// =============================
// Map Setup
// ============================= // =============================
const map = L.map('map').setView([52.52, 13.405], 13); const map = L.map('map').setView([52.52, 13.405], 13);
let currentTileLayer;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { function updateMapTiles() {
maxZoom: 19, if (currentTileLayer) {
attribution: '© OpenStreetMap contributors' map.removeLayer(currentTileLayer);
}).addTo(map); }
const isDark = body.classList.contains('dark-mode');
if (isDark) {
currentTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors © CARTO'
}).addTo(map);
} else {
currentTileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
}
}
updateMapTiles();
// Layer für Fahrzeuge
const vehiclesLayer = L.layerGroup().addTo(map); const vehiclesLayer = L.layerGroup().addTo(map);
const vehicleMarkers = {}; const vehicleMarkers = {};
// ============================= // =============================
// Farben & Symbole // Colors & Symbols
// ============================= // =============================
const lineColors = { const lineColors = {
'S45': '#CD9C53', 'S46': '#CD9C53', 'S47': '#CD9C53', 'S6': '#CD9C53', 'S65': '#CD9C53', 'S66': '#CD9C53', 'S45': '#CD9C53', 'S46': '#CD9C53', 'S47': '#CD9C53', 'S6': '#CD9C53', 'S65': '#CD9C53', 'S66': '#CD9C53',
@@ -36,8 +73,13 @@ const symbols = {
regional: '🚆', express: '🚄', longDistance: '🚅', ferry: '🛥️' regional: '🚆', express: '🚄', longDistance: '🚅', ferry: '🛥️'
}; };
const productNames = {
bus: 'Bus', tram: 'Straßenbahn', subway: 'U-Bahn', suburban: 'S-Bahn',
regional: 'Regional', express: 'Express', longDistance: 'Fernverkehr', ferry: 'Fähre'
};
// ============================= // =============================
// Hilfsfunktionen // Helper Functions
// ============================= // =============================
function getLineColor(lineName, product) { function getLineColor(lineName, product) {
if(lineName === 'U12') return null; if(lineName === 'U12') return null;
@@ -59,8 +101,8 @@ function createDivIcon(vehicle) {
if(lineName === 'U12') { if(lineName === 'U12') {
const iconHtml = ` const iconHtml = `
<div style="text-align:center; font-size:24px; position: relative; width: 30px; height: 40px;"> <div style="text-align:center; font-size:26px; position: relative; width: 30px; height: 42px;">
<svg width="30" height="40" viewBox="0 0 30 40" xmlns="http://www.w3.org/2000/svg" style="position:absolute; top:0; left:0;"> <svg width="30" height="42" viewBox="0 0 30 42" xmlns="http://www.w3.org/2000/svg" style="position:absolute; top:0; left:0;">
<defs> <defs>
<linearGradient id="u12grad" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="u12grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7DAD4C" /> <stop offset="0%" stop-color="#7DAD4C" />
@@ -69,34 +111,45 @@ function createDivIcon(vehicle) {
<stop offset="100%" stop-color="#DA421E" /> <stop offset="100%" stop-color="#DA421E" />
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="30" height="40" fill="url(#u12grad)" /> <rect width="30" height="42" rx="4" fill="url(#u12grad)" />
</svg> </svg>
<div style="position:absolute; top:0; left:0; width:30px; height:40px; line-height:40px; font-size:24px; font-weight:bold; color:#fff; mix-blend-mode: difference;"> <div style="position:absolute; top:0; left:0; width:30px; height:42px; line-height:42px; font-size:26px; font-weight:bold; color:#fff; mix-blend-mode: difference;">
${symbol} ${symbol}
</div> </div>
<div class="line-label" style="position:absolute; bottom:0; left:50%; transform: translateX(-50%); font-size:12px; background:none; color:#000; font-weight:bold; mix-blend-mode:none;"> <div class="line-label" style="position:absolute; bottom:2px; left:50%; transform: translateX(-50%); font-size:11px; background:none; color:#000; font-weight:bold; mix-blend-mode:none;">
U12 U12
</div> </div>
</div> </div>
`; `;
return L.divIcon({ html: iconHtml, className: '', iconSize: [30, 40], iconAnchor: [15, 20] }); return L.divIcon({ html: iconHtml, className: 'vehicle-marker', iconSize: [30, 42], iconAnchor: [15, 21] });
} }
const lineColor = getLineColor(lineName, product); const lineColor = getLineColor(lineName, product);
const labelStyle = `background: ${lineColor}; color: white; padding: 2px 6px; border-radius: 3px; display: inline-block; font-weight: bold; font-size: 12px; user-select: none; font-family: Arial, sans-serif;`;
const iconHtml = ` const iconHtml = `
<div style="text-align: center; font-size: 26px; line-height: 1;"> <div style="text-align: center; font-size: 28px; line-height: 1;">
<div style="color: ${lineColor}; filter: drop-shadow(0 0 1px rgba(0,0,0,0.5));">${symbol}</div> <div style="color: ${lineColor}; filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));">${symbol}</div>
<div class="line-label" style="${labelStyle}">${lineName}</div> <div class="line-label" style="background: ${lineColor};">${lineName}</div>
</div> </div>
`; `;
return L.divIcon({ html: iconHtml, className: '', iconSize: [30, 40], iconAnchor: [15, 20] }); return L.divIcon({ html: iconHtml, className: 'vehicle-marker', iconSize: [30, 42], iconAnchor: [15, 21] });
} }
// ============================= // =============================
// Fahrzeuge laden // Update Stats
// =============================
function updateStats(vehicleCount) {
document.getElementById('vehicleCount').textContent = vehicleCount;
const now = new Date();
document.getElementById('updateTime').textContent = now.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
});
}
// =============================
// Load Vehicles
// ============================= // =============================
async function loadVehicles() { async function loadVehicles() {
const bounds = map.getBounds(); const bounds = map.getBounds();
@@ -117,11 +170,32 @@ async function loadVehicles() {
newIds.add(id); newIds.add(id);
const icon = createDivIcon(vehicle); const icon = createDivIcon(vehicle);
const lineName = vehicle.line?.name || 'Unbekannt';
const product = vehicle.line?.product || 'unknown';
const productName = productNames[product] || 'Unbekannt';
const direction = vehicle.direction || 'Unbekannt';
const popupContent = `
<div class="popup-header">
<div class="popup-title">${symbols[product] || '❓'} ${lineName}</div>
</div>
<div class="popup-body">
<div class="info-row">
<span class="info-label">Typ</span>
<span class="info-value">${productName}</span>
</div>
<div class="info-row">
<span class="info-label">Richtung</span>
<span class="info-value">${direction}</span>
</div>
</div>
`;
if (vehicleMarkers[id]) { if (vehicleMarkers[id]) {
vehicleMarkers[id].setLatLng([loc.latitude, loc.longitude]); vehicleMarkers[id].setLatLng([loc.latitude, loc.longitude]);
} else { } else {
const marker = L.marker([loc.latitude, loc.longitude], { icon }) const marker = L.marker([loc.latitude, loc.longitude], { icon })
.bindPopup(`<b>Linie:</b> ${vehicle.line?.name || 'unbekannt'}<br><b>Typ:</b> ${vehicle.line?.product || 'unbekannt'}<br><b>Richtung:</b> ${vehicle.direction || 'unbekannt'}`) .bindPopup(popupContent, { maxWidth: 300 })
.addTo(vehiclesLayer); .addTo(vehiclesLayer);
vehicleMarkers[id] = marker; vehicleMarkers[id] = marker;
} }
@@ -133,14 +207,38 @@ async function loadVehicles() {
delete vehicleMarkers[id]; delete vehicleMarkers[id];
} }
}); });
updateStats(newIds.size);
} catch (e) { } catch (e) {
console.error('Fehler beim Laden der Fahrzeuge:', e); console.error('Fehler beim Laden der Fahrzeuge:', e);
} }
} }
// ============================= // =============================
// Geolocation & Auto-Update // Geolocation
// ============================= // =============================
let locationMarker = null;
const locationIcon = L.divIcon({
html: `
<div class="location-marker-icon" style="width: 40px; height: 40px;">
<div style="
background: #27E7F5;
border: 4px solid white;
border-radius: 50%;
width: 20px;
height: 20px;
box-shadow: 0 2px 8px rgba(39, 231, 245, 0.5);
position: relative;
z-index: 10;
"></div>
</div>
`,
className: '',
iconSize: [40, 40],
iconAnchor: [20, 20]
});
function locateAndLoad() { function locateAndLoad() {
if (!navigator.geolocation) { if (!navigator.geolocation) {
loadVehicles(); loadVehicles();
@@ -148,6 +246,16 @@ function locateAndLoad() {
} }
navigator.geolocation.getCurrentPosition(pos => { navigator.geolocation.getCurrentPosition(pos => {
map.setView([pos.coords.latitude, pos.coords.longitude], 14); map.setView([pos.coords.latitude, pos.coords.longitude], 14);
// Standort-Marker hinzufügen oder aktualisieren
if (locationMarker) {
locationMarker.setLatLng([pos.coords.latitude, pos.coords.longitude]);
} else {
locationMarker = L.marker([pos.coords.latitude, pos.coords.longitude], { icon: locationIcon })
.addTo(map)
.bindPopup('<b>📍 Dein Standort</b>');
}
loadVehicles(); loadVehicles();
}, () => { }, () => {
loadVehicles(); loadVehicles();
@@ -159,7 +267,7 @@ setInterval(loadVehicles, 30000);
map.on('moveend', loadVehicles); map.on('moveend', loadVehicles);
// ============================= // =============================
// Bahnhöfe mit Live-Abfahrten // Stations
// ============================= // =============================
const stations = [ const stations = [
{ name: "Berlin Zoologischer Garten", id: "900023201", coords: [52.5069, 13.3326] }, { name: "Berlin Zoologischer Garten", id: "900023201", coords: [52.5069, 13.3326] },
@@ -171,22 +279,29 @@ const stations = [
]; ];
async function loadStationDepartures(stationId, marker, stationName) { async function loadStationDepartures(stationId, marker, stationName) {
const symbols = {
bus: '🚌', tram: '🚎', subway: '🚉', suburban: '🚋',
regional: '🚆', express: '🚄', longDistance: '🚅', ferry: '🛥️'
};
try { try {
const res = await fetch(`https://v6.vbb.transport.rest/stops/${stationId}/departures?duration=30&results=10`); const res = await fetch(`https://v6.vbb.transport.rest/stops/${stationId}/departures?duration=30&results=10`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
const departures = data.departures || []; const departures = data.departures || [];
let html = `<b style="font-size:14px;">${stationName}</b><br>`; let html = `
html += `<table style="border-collapse:collapse; width:100%; font-size:12px;">`; <div class="popup-header">
html += `<tr style="border-bottom:1px solid #ccc;"><th style="text-align:left; padding:2px 4px;">Linie</th><th style="text-align:left; padding:2px 4px;">Richtung</th><th style="text-align:left; padding:2px 4px;">Zeit</th></tr>`; <div class="popup-title">🚉 ${stationName}</div>
</div>
<div class="popup-body" style="padding: 0;">
<table class="departure-table">
<thead>
<tr>
<th>Linie</th>
<th>Richtung</th>
<th>Zeit</th>
</tr>
</thead>
<tbody>
`;
departures.forEach((dep, index) => { departures.forEach(dep => {
const line = dep.line?.name || "?"; const line = dep.line?.name || "?";
const product = dep.line?.product; const product = dep.line?.product;
const icon = symbols[product] || '❓'; const icon = symbols[product] || '❓';
@@ -195,31 +310,65 @@ async function loadStationDepartures(stationId, marker, stationName) {
const planned = dep.plannedWhen ? new Date(dep.plannedWhen) : null; const planned = dep.plannedWhen ? new Date(dep.plannedWhen) : null;
const delay = when && planned ? Math.round((when - planned) / 60000) : 0; const delay = when && planned ? Math.round((when - planned) / 60000) : 0;
const timeStr = when ? when.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "n/a"; const timeStr = when ? when.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "n/a";
const delayStr = delay > 0 ? `<span style="color:red;">+${delay} min</span>` : ""; const delayStr = delay > 0 ? `<span class="delay">+${delay}'</span>` : "";
const color = getLineColor(line, product); const color = getLineColor(line, product);
const bg = index % 2 === 0 ? '#f9f9f9' : '#fff';
html += `<tr style="background:${bg};"> html += `
<td style="padding:4px 6px; font-weight:bold; color:${color}">${icon} ${line}</td> <tr>
<td style="padding:4px 6px;">${direction}</td> <td class="line-cell" style="color: ${color}">${icon} ${line}</td>
<td style="padding:4px 6px;">${timeStr} ${delayStr}</td> <td class="direction-cell">${direction}</td>
</tr>`; <td class="time-cell">${timeStr}${delayStr}</td>
</tr>
`;
}); });
html += `</table>`; html += `
</tbody>
</table>
</div>
`;
marker.setPopupContent(html).openPopup(); marker.setPopupContent(html).openPopup();
} catch (err) { } catch (err) {
marker.setPopupContent(`<b>${stationName}</b><br>Fehler beim Laden`); marker.setPopupContent(`
<div class="popup-header">
<div class="popup-title">🚉 ${stationName}</div>
</div>
<div class="popup-body">
<p style="color: var(--text-secondary); text-align: center; padding: 20px 0;">Fehler beim Laden der Abfahrten</p>
</div>
`);
console.error(err); console.error(err);
} }
} }
const stationIcon = L.divIcon({
html: `
<div class="station-marker-icon" style="width: 50px; height: 50px;">
<div style="
font-size: 42px;
line-height: 1;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
">📍</div>
</div>
`,
className: 'station-marker',
iconSize: [50, 50],
iconAnchor: [25, 45]
});
stations.forEach(station => { stations.forEach(station => {
const marker = L.marker(station.coords) const marker = L.marker(station.coords, { icon: stationIcon })
.addTo(map) .addTo(map)
.bindPopup(`<b>${station.name}</b><br>Lade Abfahrten...`); .bindPopup(`
<div class="popup-header">
<div class="popup-title">🚉 ${station.name}</div>
</div>
<div class="popup-body">
<p style="color: var(--text-secondary); text-align: center; padding: 20px 0;">Lade Abfahrten...</p>
</div>
`, { maxWidth: 400 });
marker.on('click', () => loadStationDepartures(station.id, marker, station.name)); marker.on('click', () => loadStationDepartures(station.id, marker, station.name));
}); });
+25 -2
View File
@@ -8,10 +8,33 @@
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body> <body>
<!-- Top Bar -->
<div id="topbar">
<div class="topbar-left">
<div class="logo">
<span class="logo-icon">🚇</span>
<span class="logo-text">VBB Live</span>
</div>
<div class="stats">
<div class="stat">
<span class="stat-number" id="vehicleCount">0</span>
<span class="stat-label">Fahrzeuge</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-number" id="updateTime">--:--</span>
<span class="stat-label">Aktualisiert</span>
</div>
</div>
</div>
<button id="darkModeToggle" class="toggle-btn" title="Dark Mode">
<span class="toggle-icon">🌙</span>
</button>
</div>
<div id="map"></div> <div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
+475 -30
View File
@@ -1,43 +1,488 @@
html, body, #map { :root {
height: 100%; --bg-primary: #ffffff;
margin: 0; --bg-secondary: #f8f9fa;
padding: 0; --text-primary: #1a1a1a;
--text-secondary: #6c757d;
--border-color: #dee2e6;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--accent: #0066ff;
--accent-hover: #0052cc;
} }
#controls { body.dark-mode {
position: absolute; --bg-primary: #1a1a1a;
top: 10px; --bg-secondary: #2d2d2d;
right: 10px; --text-primary: #ffffff;
background: rgba(255,255,255,0.9); --text-secondary: #a0a0a0;
padding: 8px 12px; --border-color: #3d3d3d;
border-radius: 5px; --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
z-index: 1000; --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
font-family: Arial, sans-serif; --accent: #4a9eff;
--accent-hover: #6bb0ff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
#map {
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
}
/* Top Bar */
#topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
display: flex; display: flex;
flex-wrap: wrap; /* wenn zu viele, umbrechen */ align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 1001;
box-shadow: var(--shadow-sm);
transition: all 0.3s ease;
}
.topbar-left {
display: flex;
align-items: center;
gap: 32px;
}
/* Logo */
.logo {
display: flex;
align-items: center;
gap: 10px;
user-select: none;
}
.logo-icon {
font-size: 28px;
line-height: 1;
}
.logo-text {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5px;
}
/* Stats */
.stats {
display: flex;
align-items: center;
gap: 20px;
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-number {
font-size: 18px;
font-weight: 700;
color: var(--accent);
line-height: 1;
}
.stat-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.stat-divider {
width: 1px;
height: 30px;
background: var(--border-color);
}
/* Toggle Button */
.toggle-btn {
width: 40px;
height: 40px;
border-radius: 10px;
border: none;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
font-size: 20px;
}
.toggle-btn:hover {
background: var(--accent);
transform: scale(1.05);
}
.toggle-btn:active {
transform: scale(0.95);
}
.toggle-icon {
transition: transform 0.3s ease;
}
body.dark-mode .toggle-icon {
transform: rotate(180deg);
}
/* Leaflet Marker */
.vehicle-marker {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
cursor: pointer;
transition: filter 0.2s ease;
}
.vehicle-marker:hover {
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.5)) brightness(1.1);
}
/* Line Labels */
.line-label {
padding: 3px 8px;
border-radius: 4px;
font-weight: 700;
font-size: 11px;
display: inline-block;
color: white;
margin-top: 3px;
user-select: none;
letter-spacing: 0.3px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* Popups */
.leaflet-popup-content-wrapper {
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 12px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-color);
padding: 0;
overflow: hidden;
min-width: 280px;
}
.leaflet-popup-content {
margin: 0;
font-size: 14px;
line-height: 1.5;
}
.leaflet-popup-tip {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-right: none;
border-bottom: none;
}
.popup-header {
background: var(--bg-secondary);
padding: 15px;
border-bottom: 1px solid var(--border-color);
}
.popup-title {
font-size: 16px;
font-weight: 700;
color: var(--accent);
display: flex;
align-items: center;
gap: 8px; gap: 8px;
} }
#controls label { .popup-body {
padding: 15px;
}
.info-row {
display: flex; display: flex;
align-items: center; justify-content: space-between;
gap: 4px; padding: 8px 0;
font-size: 12px; border-bottom: 1px solid var(--border-color);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: var(--text-secondary);
font-size: 13px;
}
.info-value {
font-weight: 600;
font-size: 13px;
}
/* Station Table */
.departure-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.departure-table th {
text-align: left;
padding: 12px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--border-color);
}
.departure-table td {
padding: 12px;
border-bottom: 1px solid var(--border-color);
}
.departure-table tr:last-child td {
border-bottom: none;
}
.departure-table tr:hover {
background: var(--bg-secondary);
}
.line-cell {
font-weight: 700;
white-space: nowrap;
}
.direction-cell {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-cell {
font-weight: 600;
white-space: nowrap;
}
.delay {
color: #dc3545;
font-weight: 700;
margin-left: 4px;
}
/* Station Markers */
.station-marker {
cursor: pointer; cursor: pointer;
} }
.leaflet-marker-icon { .station-marker-icon {
transition: transform 1s linear; display: flex;
align-items: center;
justify-content: center;
position: relative;
} }
.line-label { .station-marker-icon::before {
padding: 2px 6px; content: '';
border-radius: 3px; position: absolute;
font-weight: bold; top: 50%;
font-size: 10px; left: 50%;
display: inline-block; transform: translate(-50%, -50%);
color: white; width: 30px;
margin-top: 2px; height: 30px;
user-select: none; border: 3px solid var(--accent);
font-family: Arial, sans-serif; border-radius: 50%;
animation: pulse-ring 2s ease-out infinite;
} }
.station-marker-icon::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 30px;
height: 30px;
border: 2px solid var(--accent);
border-radius: 50%;
animation: pulse-ring 2s ease-out infinite 1s;
}
@keyframes pulse-ring {
0% {
width: 30px;
height: 30px;
opacity: 1;
}
100% {
width: 60px;
height: 60px;
opacity: 0;
}
}
.station-marker:hover .station-marker-icon {
animation: station-bounce 0.5s ease;
}
@keyframes station-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
body.dark-mode .station-marker-icon::before,
body.dark-mode .station-marker-icon::after {
border-color: var(--accent);
}
/* Location Marker */
.location-marker-icon {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.location-marker-icon::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
border: 3px solid #27E7F5;
border-radius: 50%;
animation: location-pulse 2s ease-out infinite;
}
.location-marker-icon::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
border: 2px solid #27E7F5;
border-radius: 50%;
animation: location-pulse 2s ease-out infinite 1s;
}
@keyframes location-pulse {
0% {
width: 20px;
height: 20px;
opacity: 1;
}
100% {
width: 50px;
height: 50px;
opacity: 0;
}
}
/* Responsive */
@media (max-width: 768px) {
#topbar {
padding: 0 15px;
}
.topbar-left {
gap: 16px;
}
.logo-text {
font-size: 18px;
}
.stats {
gap: 12px;
}
.stat-number {
font-size: 16px;
}
.stat-label {
font-size: 10px;
}
#map {
top: 60px;
}
.leaflet-popup-content-wrapper {
min-width: 260px;
}
}
@media (max-width: 480px) {
.logo-text {
display: none;
}
.stat-label {
display: none;
}
.stat-divider {
display: none;
}
.stats {
gap: 16px;
}
}
/* Smooth Transitions */
.leaflet-popup-content-wrapper,
.toggle-btn,
.leaflet-marker-icon {
transition: all 0.3s ease;
}
/* Loading State */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.loading {
animation: pulse 1.5s ease-in-out infinite;
}