차량 목적지 경로 미리보기 추가

This commit is contained in:
2026-06-06 19:10:24 +09:00
parent 34bb805400
commit 1dc93c5c78
4 changed files with 564 additions and 7 deletions

View File

@@ -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" `

View File

@@ -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 [];

View File

@@ -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 `
<span class="route-destination-pin">도착</span>
<strong>${escapeHtml(destinationName)}</strong>
<small>${escapeHtml(etaText)}</small>
`;
}
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;

View File

@@ -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);