diff --git a/README.md b/README.md
index fdb3ff2..ba49e24 100644
--- a/README.md
+++ b/README.md
@@ -181,8 +181,18 @@ $body = @{
cargoWeight = "1.2t"
origin = "서울 상차지"
destination = "평택 물류센터"
+ destinationLatitude = 36.9921
+ destinationLongitude = 127.1128
+ routePath = @(
+ @{ latitude = 37.5665; longitude = 126.9780 },
+ @{ latitude = 37.33; longitude = 127.05 },
+ @{ latitude = 36.9921; longitude = 127.1128 }
+ )
+ remainingDistanceKm = 74.5
+ etaMinutes = 68
+ estimatedArrivalAt = (Get-Date).AddMinutes(68).ToUniversalTime().ToString("o")
recordedAt = (Get-Date).ToUniversalTime().ToString("o")
-} | ConvertTo-Json
+} | ConvertTo-Json -Depth 4
Invoke-RestMethod `
-Uri "http://localhost:8080/api/v1/locations" `
diff --git a/apps/server/src/server.js b/apps/server/src/server.js
index c0d2e43..a64edf2 100644
--- a/apps/server/src/server.js
+++ b/apps/server/src/server.js
@@ -565,6 +565,14 @@ function publishSimulatorLocations() {
function createSimulatorLocationBody(vehicle, nowMs) {
const routeState = interpolateRoute(vehicle.route, nowMs, vehicle.loopMs, hashOffset(vehicle.driverId));
const speedWave = Math.sin((nowMs + hashOffset(vehicle.vehicleNo)) / 17000) * 8;
+ const speed = Math.max(18, Math.round(vehicle.speedBase + speedWave));
+ const routePath = createSimulatedRoutePath(
+ { latitude: routeState.latitude, longitude: routeState.longitude },
+ routeState.destinationPoint,
+ vehicle.driverId
+ );
+ const remainingDistanceKm = roundNumber(calculatePathDistanceKm(routePath) * 1.08, 1);
+ const etaMinutes = Math.max(3, Math.round((remainingDistanceKm / Math.max(speed, 24)) * 60));
return {
driverId: vehicle.driverId,
@@ -573,13 +581,20 @@ function createSimulatorLocationBody(vehicle, nowMs) {
latitude: routeState.latitude,
longitude: routeState.longitude,
accuracy: 8,
- speed: Math.max(18, Math.round(vehicle.speedBase + speedWave)),
+ speed,
heading: routeState.heading,
cargoName: vehicle.cargoName,
cargoQuantity: vehicle.cargoQuantity,
cargoWeight: vehicle.cargoWeight,
origin: routeState.origin,
destination: routeState.destination,
+ destinationLatitude: routeState.destinationPoint.latitude,
+ destinationLongitude: routeState.destinationPoint.longitude,
+ routePath,
+ routeSource: "simulated",
+ remainingDistanceKm,
+ etaMinutes,
+ estimatedArrivalAt: new Date(nowMs + etaMinutes * 60 * 1000).toISOString(),
provider: "simulator",
simulated: true,
recordedAt: new Date(nowMs).toISOString()
@@ -599,7 +614,8 @@ function interpolateRoute(route, nowMs, loopMs, offsetMs) {
longitude: interpolateNumber(from.longitude, to.longitude, localProgress),
heading: Math.round(calculateBearing(from, to)),
origin: from.name,
- destination: to.name
+ destination: to.name,
+ destinationPoint: to
};
}
@@ -751,6 +767,13 @@ function createLocationRecord(body, driver) {
cargoWeight: optionalString(body.cargoWeight || body.weight, 40),
origin: optionalString(body.origin || body.departure, 80),
destination: optionalString(body.destination || body.arrival, 80),
+ destinationLatitude: optionalNumber(body.destinationLatitude || body.destinationLat || body.arrivalLatitude || body.arrivalLat),
+ destinationLongitude: optionalNumber(body.destinationLongitude || body.destinationLng || body.destinationLon || body.arrivalLongitude || body.arrivalLng || body.arrivalLon),
+ routePath: normalizeRoutePath(body.routePath || body.path || body.polyline),
+ routeSource: optionalString(body.routeSource || body.routingProvider, 40),
+ remainingDistanceKm: optionalNumber(body.remainingDistanceKm || body.distanceKm || body.routeDistanceKm),
+ etaMinutes: optionalNumber(body.etaMinutes || body.estimatedMinutes || body.remainingMinutes),
+ estimatedArrivalAt: normalizeOptionalDate(body.estimatedArrivalAt || body.etaAt || body.arrivalAt),
provider: body.provider ? String(body.provider) : "",
simulated: Boolean(body.simulated || driver.simulated),
recordedAt: normalizeDate(body.recordedAt),
@@ -767,6 +790,81 @@ function point(name, latitude, longitude) {
return { name, latitude, longitude };
}
+function createSimulatedRoutePath(from, to, seed) {
+ const deltaLatitude = to.latitude - from.latitude;
+ const deltaLongitude = to.longitude - from.longitude;
+ const length = Math.sqrt(deltaLatitude ** 2 + deltaLongitude ** 2) || 1;
+ const offsetDirection = hashOffset(seed) % 2 === 0 ? 1 : -1;
+ const offsetSize = Math.min(0.32, Math.max(0.025, length * 0.14));
+ const perpendicularLatitude = (-deltaLongitude / length) * offsetSize * offsetDirection;
+ const perpendicularLongitude = (deltaLatitude / length) * offsetSize * offsetDirection;
+
+ return [
+ normalizeRoutePoint(from),
+ normalizeRoutePoint({
+ latitude: interpolateNumber(from.latitude, to.latitude, 0.35) + perpendicularLatitude,
+ longitude: interpolateNumber(from.longitude, to.longitude, 0.35) + perpendicularLongitude
+ }),
+ normalizeRoutePoint({
+ latitude: interpolateNumber(from.latitude, to.latitude, 0.72) - perpendicularLatitude * 0.35,
+ longitude: interpolateNumber(from.longitude, to.longitude, 0.72) - perpendicularLongitude * 0.35
+ }),
+ normalizeRoutePoint(to)
+ ];
+}
+
+function normalizeRoutePath(value) {
+ if (!Array.isArray(value)) return [];
+
+ return value
+ .map((pointValue) => normalizeRoutePoint(pointValue))
+ .filter(Boolean)
+ .slice(0, 48);
+}
+
+function normalizeRoutePoint(value) {
+ if (!value) return null;
+
+ const latitude = Array.isArray(value)
+ ? Number(value[0])
+ : Number(value.latitude ?? value.lat);
+ const longitude = Array.isArray(value)
+ ? Number(value[1])
+ : Number(value.longitude ?? value.lng ?? value.lon);
+
+ if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) return null;
+ if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) return null;
+
+ return {
+ latitude: roundNumber(latitude, 6),
+ longitude: roundNumber(longitude, 6)
+ };
+}
+
+function calculatePathDistanceKm(routePath) {
+ let distance = 0;
+ for (let index = 1; index < routePath.length; index += 1) {
+ distance += calculateDistanceKm(routePath[index - 1], routePath[index]);
+ }
+ return distance;
+}
+
+function calculateDistanceKm(from, to) {
+ const earthRadiusKm = 6371;
+ const deltaLatitude = toRadians(to.latitude - from.latitude);
+ const deltaLongitude = toRadians(to.longitude - from.longitude);
+ const fromLatitude = toRadians(from.latitude);
+ const toLatitude = toRadians(to.latitude);
+ const a = Math.sin(deltaLatitude / 2) ** 2 +
+ Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2;
+ return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
+function roundNumber(value, decimals) {
+ const multiplier = 10 ** decimals;
+ return Math.round(value * multiplier) / multiplier;
+}
+
function smoothStep(value) {
return value * value * (3 - 2 * value);
}
@@ -833,6 +931,12 @@ function normalizeDate(value) {
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
}
+function normalizeOptionalDate(value) {
+ if (!value) return null;
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
+}
+
function readHistory({ driverId, limit }) {
if (!fs.existsSync(HISTORY_FILE)) return [];
diff --git a/apps/web/public/app.js b/apps/web/public/app.js
index 5371de9..21aae5d 100644
--- a/apps/web/public/app.js
+++ b/apps/web/public/app.js
@@ -3,6 +3,8 @@ const state = {
mapProvider: "",
markers: new Map(),
infoOverlay: null,
+ routeOverlay: null,
+ routeDestinationOverlay: null,
drivers: new Map(),
locations: new Map(),
selectedDriverId: "",
@@ -334,6 +336,16 @@ function replaceLocations(locations) {
}
render();
updateMarkers({ fitBounds: locations.length > 0 });
+
+ if (state.selectedDriverId) {
+ const selectedLocation = state.locations.get(state.selectedDriverId);
+ if (selectedLocation) {
+ renderRoutePreview(selectedLocation, { fit: false });
+ showInfoOverlay(selectedLocation);
+ } else {
+ clearRouteOverlay();
+ }
+ }
}
function replaceDrivers(drivers) {
@@ -351,10 +363,12 @@ function upsertLocation(location, options = {}) {
if (state.selectedDriverId === location.driverId && !elements.historyDrawer.classList.contains("is-hidden")) {
loadLocationHistory(location);
+ renderRoutePreview(location, { fit: false });
+ showInfoOverlay(location);
}
if (options.focus) {
- centerMapOnLocation(location);
+ fitMapToRoute(location) || centerMapOnLocation(location);
}
}
@@ -443,7 +457,13 @@ function createVehicleItem(location) {
const meta = document.createElement("div");
meta.className = "vehicle-meta";
- meta.textContent = `${location.driverName || location.driverId} · ${getCargoSummary(location)} · ${getRouteSummary(location)} · ${formatDateTime(location.receivedAt)}`;
+ meta.textContent = [
+ location.driverName || location.driverId,
+ getCargoSummary(location),
+ getRouteSummary(location),
+ getRouteProgressSummary(location),
+ formatDateTime(location.receivedAt)
+ ].filter(Boolean).join(" · ");
const speed = document.createElement("div");
speed.className = "speed";
@@ -758,12 +778,132 @@ function centerMapOnLocation(location, options = {}) {
}
}
+function renderRoutePreview(location, options = {}) {
+ clearRouteOverlay();
+
+ const routePath = getRoutePath(location);
+ if (routePath.length < 2) return false;
+
+ if (state.mapProvider === "kakao") {
+ const path = routePath.map((point) => new kakao.maps.LatLng(point.latitude, point.longitude));
+ state.routeOverlay = new kakao.maps.Polyline({
+ path,
+ strokeWeight: 5,
+ strokeColor: "#1f6feb",
+ strokeOpacity: 0.86,
+ strokeStyle: "solid"
+ });
+ state.routeOverlay.setMap(state.map);
+
+ state.routeDestinationOverlay = new kakao.maps.CustomOverlay({
+ position: path[path.length - 1],
+ content: createDestinationMarkerElement(location),
+ yAnchor: 1.1
+ });
+ state.routeDestinationOverlay.setMap(state.map);
+ }
+
+ if (state.mapProvider === "leaflet") {
+ const path = routePath.map((point) => [point.latitude, point.longitude]);
+ state.routeOverlay = L.polyline(path, {
+ color: "#1f6feb",
+ weight: 5,
+ opacity: 0.86,
+ lineCap: "round",
+ lineJoin: "round"
+ }).addTo(state.map);
+ state.routeDestinationOverlay = L.marker(path[path.length - 1], {
+ icon: L.divIcon({
+ className: "route-destination-leaflet",
+ html: createDestinationMarkerHtml(location),
+ iconSize: [116, 42],
+ iconAnchor: [58, 42]
+ })
+ }).addTo(state.map);
+ }
+
+ if (options.fit !== false) {
+ fitMapToRoute(location);
+ }
+
+ return true;
+}
+
+function clearRouteOverlay() {
+ if (state.routeOverlay) {
+ if (typeof state.routeOverlay.setMap === "function") {
+ state.routeOverlay.setMap(null);
+ } else if (typeof state.routeOverlay.remove === "function") {
+ state.routeOverlay.remove();
+ }
+ }
+
+ if (state.routeDestinationOverlay) {
+ if (typeof state.routeDestinationOverlay.setMap === "function") {
+ state.routeDestinationOverlay.setMap(null);
+ } else if (typeof state.routeDestinationOverlay.remove === "function") {
+ state.routeDestinationOverlay.remove();
+ }
+ }
+
+ state.routeOverlay = null;
+ state.routeDestinationOverlay = null;
+}
+
+function fitMapToRoute(location) {
+ const routePath = getRoutePath(location);
+ if (!state.map || !state.mapProvider || routePath.length < 2) return false;
+
+ if (state.mapProvider === "kakao") {
+ const bounds = new kakao.maps.LatLngBounds();
+ for (const point of routePath) {
+ bounds.extend(new kakao.maps.LatLng(point.latitude, point.longitude));
+ }
+ state.map.setBounds(bounds);
+ return true;
+ }
+
+ if (state.mapProvider === "leaflet") {
+ state.map.fitBounds(
+ L.latLngBounds(routePath.map((point) => [point.latitude, point.longitude])),
+ {
+ maxZoom: 13,
+ padding: [48, 48]
+ }
+ );
+ return true;
+ }
+
+ return false;
+}
+
+function createDestinationMarkerElement(location) {
+ const marker = document.createElement("div");
+ marker.className = "route-destination-marker";
+ marker.innerHTML = createDestinationMarkerHtml(location);
+ return marker;
+}
+
+function createDestinationMarkerHtml(location) {
+ const metrics = getRouteMetrics(location);
+ const destinationName = getDestinationName(location) || "목적지";
+ const etaText = metrics ? formatEtaTime(metrics.estimatedArrivalAt) : "ETA";
+
+ return `
+ 도착
+ ${escapeHtml(destinationName)}
+ ${escapeHtml(etaText)}
+ `;
+}
+
function focusLocation(location) {
loadLocationHistory(location);
if (!state.map || !state.mapProvider) return;
- centerMapOnLocation(location, { zoom: true });
+ if (!renderRoutePreview(location, { fit: true })) {
+ centerMapOnLocation(location, { zoom: true });
+ }
showInfoOverlay(location);
}
@@ -819,6 +959,7 @@ function renderHistory(location, records) {
function closeHistoryDrawer() {
state.selectedDriverId = "";
elements.historyDrawer.classList.add("is-hidden");
+ clearRouteOverlay();
}
function showInfoOverlay(location) {
@@ -871,16 +1012,48 @@ function createPopupElement(location, status) {
const destination = document.createElement("div");
destination.textContent = getRouteSummary(location);
+ const routeInfo = createRouteInfoElement(location);
+
const receivedAt = document.createElement("div");
receivedAt.textContent = `수신: ${formatDateTime(location.receivedAt)}`;
const movement = document.createElement("div");
movement.textContent = `속도: ${formatSpeed(location.speed)}`;
- container.append(title, driver, cargo, quantity, weight, destination, movement, receivedAt);
+ container.append(title, driver, cargo, quantity, weight, destination, routeInfo, movement, receivedAt);
return container;
}
+function createRouteInfoElement(location) {
+ const routeInfo = document.createElement("div");
+ routeInfo.className = "popup-route";
+
+ const metrics = getRouteMetrics(location);
+ if (!metrics) {
+ routeInfo.textContent = "경로: 목적지 좌표 대기";
+ return routeInfo;
+ }
+
+ const source = firstString(location.routeSource, location.routingProvider) || "simulated";
+ routeInfo.append(
+ createPopupRouteLine("남은 거리", formatDistance(metrics.remainingDistanceKm)),
+ createPopupRouteLine("예상 도착", formatEtaTime(metrics.estimatedArrivalAt)),
+ createPopupRouteLine("소요 시간", formatDuration(metrics.etaMinutes)),
+ createPopupRouteLine("경로", source === "simulated" ? "모의 경로" : source)
+ );
+ return routeInfo;
+}
+
+function createPopupRouteLine(label, value) {
+ const row = document.createElement("div");
+ const labelElement = document.createElement("span");
+ labelElement.textContent = label;
+ const valueElement = document.createElement("strong");
+ valueElement.textContent = value;
+ row.append(labelElement, valueElement);
+ return row;
+}
+
async function fetchJson(url, options) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
@@ -1008,6 +1181,195 @@ function getRouteSummary(location) {
return "도착지: 미등록";
}
+function getRouteProgressSummary(location) {
+ const metrics = getRouteMetrics(location);
+ if (!metrics) return "";
+ return `남은 ${formatDistance(metrics.remainingDistanceKm)} · ETA ${formatEtaTime(metrics.estimatedArrivalAt)}`;
+}
+
+function getRouteMetrics(location) {
+ const routePath = getRoutePath(location);
+ if (routePath.length < 2) return null;
+
+ const providedDistance = parseFiniteNumber(location.remainingDistanceKm || location.distanceKm || location.routeDistanceKm);
+ const remainingDistanceKm = providedDistance || calculatePathDistanceKm(routePath);
+ const providedEta = parseFiniteNumber(location.etaMinutes || location.estimatedMinutes || location.remainingMinutes);
+ const speed = Math.max(24, parseFiniteNumber(location.speed) || 52);
+ const etaMinutes = Math.max(2, Math.round(providedEta || (remainingDistanceKm / speed) * 60));
+ const providedArrival = parseOptionalDate(location.estimatedArrivalAt || location.etaAt || location.arrivalAt);
+ const estimatedArrivalAt = providedArrival || new Date(Date.now() + etaMinutes * 60 * 1000);
+
+ return {
+ remainingDistanceKm,
+ etaMinutes,
+ estimatedArrivalAt
+ };
+}
+
+function getRoutePath(location) {
+ const current = normalizeDisplayRoutePoint({
+ latitude: location.latitude,
+ longitude: location.longitude
+ });
+ if (!current) return [];
+
+ const providedPath = normalizeDisplayRoutePath(location.routePath || location.path || location.polyline);
+ const destination = getDestinationCoordinate(location) ||
+ (providedPath.length ? providedPath[providedPath.length - 1] : null);
+
+ if (providedPath.length >= 2) {
+ return ensureRouteEndpoints(providedPath, current, destination);
+ }
+
+ if (!destination) return [];
+ return createFallbackRoutePath(current, destination, location.driverId || location.vehicleNo || "");
+}
+
+function ensureRouteEndpoints(routePath, current, destination) {
+ const normalizedPath = [...routePath];
+
+ if (calculateDistanceKm(current, normalizedPath[0]) > 0.1) {
+ normalizedPath.unshift(current);
+ }
+
+ if (destination && calculateDistanceKm(destination, normalizedPath[normalizedPath.length - 1]) > 0.1) {
+ normalizedPath.push(destination);
+ }
+
+ return normalizedPath;
+}
+
+function getDestinationCoordinate(location) {
+ const latitude = parseFiniteNumber(
+ location.destinationLatitude || location.destinationLat || location.arrivalLatitude || location.arrivalLat
+ );
+ const longitude = parseFiniteNumber(
+ location.destinationLongitude || location.destinationLng || location.destinationLon ||
+ location.arrivalLongitude || location.arrivalLng || location.arrivalLon
+ );
+
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
+ return normalizeDisplayRoutePoint({ latitude, longitude });
+}
+
+function getDestinationName(location) {
+ return firstString(location.destination, location.arrival);
+}
+
+function normalizeDisplayRoutePath(value) {
+ if (!Array.isArray(value)) return [];
+ return value.map(normalizeDisplayRoutePoint).filter(Boolean);
+}
+
+function normalizeDisplayRoutePoint(value) {
+ if (!value) return null;
+ const latitude = Array.isArray(value)
+ ? parseFiniteNumber(value[0])
+ : parseFiniteNumber(value.latitude ?? value.lat);
+ const longitude = Array.isArray(value)
+ ? parseFiniteNumber(value[1])
+ : parseFiniteNumber(value.longitude ?? value.lng ?? value.lon);
+
+ if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) return null;
+ if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) return null;
+ return { latitude, longitude };
+}
+
+function createFallbackRoutePath(from, to, seed) {
+ const deltaLatitude = to.latitude - from.latitude;
+ const deltaLongitude = to.longitude - from.longitude;
+ const length = Math.sqrt(deltaLatitude ** 2 + deltaLongitude ** 2) || 1;
+ const offsetDirection = hashText(seed) % 2 === 0 ? 1 : -1;
+ const offsetSize = Math.min(0.28, Math.max(0.02, length * 0.12));
+ const perpendicularLatitude = (-deltaLongitude / length) * offsetSize * offsetDirection;
+ const perpendicularLongitude = (deltaLatitude / length) * offsetSize * offsetDirection;
+
+ return [
+ from,
+ {
+ latitude: interpolateNumber(from.latitude, to.latitude, 0.36) + perpendicularLatitude,
+ longitude: interpolateNumber(from.longitude, to.longitude, 0.36) + perpendicularLongitude
+ },
+ {
+ latitude: interpolateNumber(from.latitude, to.latitude, 0.72) - perpendicularLatitude * 0.35,
+ longitude: interpolateNumber(from.longitude, to.longitude, 0.72) - perpendicularLongitude * 0.35
+ },
+ to
+ ];
+}
+
+function calculatePathDistanceKm(routePath) {
+ let distance = 0;
+ for (let index = 1; index < routePath.length; index += 1) {
+ distance += calculateDistanceKm(routePath[index - 1], routePath[index]);
+ }
+ return distance;
+}
+
+function calculateDistanceKm(from, to) {
+ const earthRadiusKm = 6371;
+ const deltaLatitude = toRadians(to.latitude - from.latitude);
+ const deltaLongitude = toRadians(to.longitude - from.longitude);
+ const fromLatitude = toRadians(from.latitude);
+ const toLatitude = toRadians(to.latitude);
+ const a = Math.sin(deltaLatitude / 2) ** 2 +
+ Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2;
+ return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
+function interpolateNumber(from, to, progress) {
+ return from + (to - from) * progress;
+}
+
+function toRadians(value) {
+ return value * Math.PI / 180;
+}
+
+function hashText(value) {
+ let hash = 0;
+ for (const character of String(value)) {
+ hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
+ }
+ return hash;
+}
+
+function parseFiniteNumber(value) {
+ if (value === undefined || value === null || value === "") return null;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function parseOptionalDate(value) {
+ if (!value) return null;
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function formatDistance(value) {
+ const distance = Number(value);
+ if (!Number.isFinite(distance)) return "-";
+ if (distance < 1) return `${Math.round(distance * 1000)} m`;
+ if (distance < 10) return `${distance.toFixed(1)} km`;
+ return `${Math.round(distance)} km`;
+}
+
+function formatDuration(minutes) {
+ const duration = Math.max(0, Math.round(Number(minutes) || 0));
+ if (duration < 60) return `약 ${duration}분`;
+ const hours = Math.floor(duration / 60);
+ const remainingMinutes = duration % 60;
+ return remainingMinutes ? `약 ${hours}시간 ${remainingMinutes}분` : `약 ${hours}시간`;
+}
+
+function formatEtaTime(value) {
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) return "-";
+ return new Intl.DateTimeFormat("ko-KR", {
+ hour: "2-digit",
+ minute: "2-digit"
+ }).format(date);
+}
+
function firstString(...values) {
for (const value of values) {
if (value === undefined || value === null) continue;
diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css
index b637fdb..c73844b 100644
--- a/apps/web/public/styles.css
+++ b/apps/web/public/styles.css
@@ -724,6 +724,87 @@ button:hover {
font-weight: 900;
}
+.popup-route {
+ display: grid;
+ gap: 4px;
+ margin: 8px 0;
+ padding: 8px;
+ border: 1px solid rgba(31, 111, 235, 0.18);
+ border-radius: 6px;
+ background: rgba(31, 111, 235, 0.07);
+}
+
+.popup-route div {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.popup-route span {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.popup-route strong {
+ color: #174ea6;
+ font-size: 12px;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.route-destination-marker,
+.route-destination-leaflet {
+ pointer-events: none;
+}
+
+.route-destination-marker,
+.route-destination-leaflet,
+.route-destination-leaflet > div {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ grid-template-rows: auto auto;
+ align-items: center;
+ column-gap: 7px;
+ min-width: 112px;
+ padding: 7px 9px;
+ border: 1px solid rgba(23, 33, 43, 0.16);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: 0 10px 24px rgba(20, 40, 70, 0.18);
+}
+
+.route-destination-pin {
+ grid-row: 1 / span 2;
+ display: grid;
+ place-items: center;
+ min-width: 34px;
+ height: 24px;
+ border-radius: 999px;
+ background: #1f6feb;
+ color: #ffffff;
+ font-size: 11px;
+ font-weight: 900;
+}
+
+.route-destination-marker strong,
+.route-destination-leaflet strong {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--text);
+ font-size: 12px;
+ line-height: 1.15;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.route-destination-marker small,
+.route-destination-leaflet small {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+}
+
@media (max-width: 860px) {
.app-shell {
grid-template-rows: auto minmax(0, 1fr);