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