diff --git a/.env.example b/.env.example index 9898a63..7f6e340 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,4 @@ ADMIN_TOKEN= ADMIN_AUTH_REQUIRED=false KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 SIMULATOR_ENABLED=true -SIMULATOR_INTERVAL_MS=4000 +SIMULATOR_INTERVAL_MS=1000 diff --git a/.env.nas.example b/.env.nas.example index d04ea7d..6c69cec 100644 --- a/.env.nas.example +++ b/.env.nas.example @@ -3,4 +3,4 @@ ADMIN_TOKEN=change_this_admin_token ADMIN_AUTH_REQUIRED=false KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 SIMULATOR_ENABLED=true -SIMULATOR_INTERVAL_MS=4000 +SIMULATOR_INTERVAL_MS=1000 diff --git a/README.md b/README.md index ba49e24..ac11da3 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ behind Synology Reverse Proxy. During early testing, keep `SIMULATOR_ENABLED=true` to show moving demo vehicles without Android phones. The simulator publishes fresh locations every -`SIMULATOR_INTERVAL_MS` milliseconds. +`SIMULATOR_INTERVAL_MS` milliseconds. The web map interpolates between fresh +positions so vehicles move smoothly instead of jumping between points. ## Maintenance Scripts diff --git a/apps/server/src/server.js b/apps/server/src/server.js index a64edf2..f7efbd2 100644 --- a/apps/server/src/server.js +++ b/apps/server/src/server.js @@ -12,7 +12,7 @@ const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ""; const ADMIN_AUTH_REQUIRED = parseBoolean(process.env.ADMIN_AUTH_REQUIRED, false); const DEFAULT_KAKAO_JAVASCRIPT_KEY = "07f4728599ceaf4299f77c772a135423"; const SIMULATOR_ENABLED = parseBoolean(process.env.SIMULATOR_ENABLED, true); -const SIMULATOR_INTERVAL_MS = clampNumber(Number(process.env.SIMULATOR_INTERVAL_MS || 4000), 1000, 60000); +const SIMULATOR_INTERVAL_MS = clampNumber(Number(process.env.SIMULATOR_INTERVAL_MS || 1000), 1000, 60000); const SIMULATOR_HISTORY_EVERY = clampNumber(Number(process.env.SIMULATOR_HISTORY_EVERY || 3), 1, 30); const DRIVERS_FILE = path.join(DATA_DIR, "drivers.json"); diff --git a/apps/web/public/app.js b/apps/web/public/app.js index 21aae5d..9a8feb4 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -5,6 +5,7 @@ const state = { infoOverlay: null, routeOverlay: null, routeDestinationOverlay: null, + markerAnimationMs: 900, drivers: new Map(), locations: new Map(), selectedDriverId: "", @@ -652,7 +653,8 @@ function updateMarker(location) { } function updateKakaoMarker(location) { - const position = new kakao.maps.LatLng(location.latitude, location.longitude); + const targetPosition = createPosition(location.latitude, location.longitude); + const kakaoPosition = new kakao.maps.LatLng(targetPosition.latitude, targetPosition.longitude); const status = getLocationStatus(location); const markerElement = createTruckMarkerElement(location, status); @@ -662,23 +664,25 @@ function updateKakaoMarker(location) { marker = { provider: "kakao", overlay: new kakao.maps.CustomOverlay({ - position, + position: kakaoPosition, content: markerElement, yAnchor: 1 - }) + }), + currentPosition: targetPosition }; marker.overlay.setMap(state.map); state.markers.set(location.driverId, marker); } else { - marker.overlay.setPosition(position); marker.overlay.setContent(markerElement); + animateMarkerPosition(marker, targetPosition); } } function updateLeafletMarker(location) { const status = getLocationStatus(location); const vehicleType = getVehicleType(location); - const position = [location.latitude, location.longitude]; + const targetPosition = createPosition(location.latitude, location.longitude); + const leafletPosition = [targetPosition.latitude, targetPosition.longitude]; const icon = L.divIcon({ className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className}`, html: createTruckMarkerHtml(location, vehicleType), @@ -692,16 +696,17 @@ function updateLeafletMarker(location) { if (!marker) { marker = { provider: "leaflet", - marker: L.marker(position, { icon, title: location.vehicleNo || location.driverId }) + marker: L.marker(leafletPosition, { icon, title: location.vehicleNo || location.driverId }), + currentPosition: targetPosition }; marker.marker.on("click", () => focusLocation(location)); marker.marker.addTo(state.map); state.markers.set(location.driverId, marker); } else { - marker.marker.setLatLng(position); marker.marker.setIcon(icon); marker.marker.off("click"); marker.marker.on("click", () => focusLocation(location)); + animateMarkerPosition(marker, targetPosition); } } @@ -728,7 +733,85 @@ function createTruckMarkerHtml(location, vehicleType = getVehicleType(location)) `; } +function animateMarkerPosition(marker, targetPosition) { + const fromPosition = marker.currentPosition || getMarkerPosition(marker) || targetPosition; + + if (calculateDistanceKm(fromPosition, targetPosition) < 0.01) { + setMarkerPosition(marker, targetPosition); + marker.currentPosition = targetPosition; + return; + } + + cancelMarkerAnimation(marker); + + const startedAt = window.performance.now(); + const duration = state.markerAnimationMs; + + const tick = (timestamp) => { + const progress = Math.min(1, (timestamp - startedAt) / duration); + const easedProgress = easeInOutCubic(progress); + const nextPosition = { + latitude: interpolateNumber(fromPosition.latitude, targetPosition.latitude, easedProgress), + longitude: interpolateNumber(fromPosition.longitude, targetPosition.longitude, easedProgress) + }; + + setMarkerPosition(marker, nextPosition); + marker.currentPosition = nextPosition; + + if (progress < 1) { + marker.animationFrame = window.requestAnimationFrame(tick); + } else { + marker.animationFrame = null; + marker.currentPosition = targetPosition; + setMarkerPosition(marker, targetPosition); + } + }; + + marker.animationFrame = window.requestAnimationFrame(tick); +} + +function cancelMarkerAnimation(marker) { + if (!marker?.animationFrame) return; + window.cancelAnimationFrame(marker.animationFrame); + marker.animationFrame = null; +} + +function getMarkerPosition(marker) { + if (marker.provider === "leaflet" && marker.marker?.getLatLng) { + const position = marker.marker.getLatLng(); + return createPosition(position.lat, position.lng); + } + + return marker.currentPosition || null; +} + +function setMarkerPosition(marker, position) { + if (marker.provider === "kakao") { + marker.overlay.setPosition(new kakao.maps.LatLng(position.latitude, position.longitude)); + return; + } + + if (marker.provider === "leaflet") { + marker.marker.setLatLng([position.latitude, position.longitude]); + } +} + +function createPosition(latitude, longitude) { + return { + latitude: Number(latitude), + longitude: Number(longitude) + }; +} + +function easeInOutCubic(value) { + return value < 0.5 + ? 4 * value * value * value + : 1 - ((-2 * value + 2) ** 3) / 2; +} + function removeMarker(marker) { + cancelMarkerAnimation(marker); + if (marker.provider === "kakao") { marker.overlay.setMap(null); return;