diff --git a/apps/web/public/app.js b/apps/web/public/app.js index 736a630..a6e5294 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -6,6 +6,11 @@ const state = { routeOverlay: null, routeDestinationOverlay: null, markerAnimationMs: 4800, + hasFittedInitialLocations: false, + isProgrammaticMapChange: false, + programmaticMapChangeTimer: 0, + pendingLocationUpdates: new Map(), + locationUpdateBatchTimer: 0, drivers: new Map(), locations: new Map(), selectedDriverId: "", @@ -123,8 +128,9 @@ async function initializeMap() { state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT); state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT); + bindMapInteractionTracking(); hideMapMessage(); - updateMarkers({ fitBounds: state.locations.size > 0 }); + updateInitialMapFit(); return; } catch (error) { console.warn("Kakao map unavailable. Falling back to OpenStreetMap.", error); @@ -174,9 +180,10 @@ async function initializeLeafletMap() { attribution: '© OpenStreetMap contributors' }).addTo(state.map); + bindMapInteractionTracking(); showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중"); window.setTimeout(hideMapMessage, 4500); - updateMarkers({ fitBounds: state.locations.size > 0 }); + updateInitialMapFit(); } function loadLeafletAssets() { @@ -316,7 +323,7 @@ function connectEvents() { }); state.eventSource.addEventListener("location.updated", (event) => { const location = JSON.parse(event.data); - upsertLocation(location, { focus: state.selectedDriverId === location.driverId }); + queueLocationUpdate(location); }); state.eventSource.addEventListener("drivers.updated", (event) => { const payload = JSON.parse(event.data); @@ -377,7 +384,8 @@ function replaceLocations(locations) { state.locations.set(location.driverId, location); } render(); - updateMarkers({ fitBounds: locations.length > 0 }); + updateMarkers(); + updateInitialMapFit(); if (state.selectedDriverId) { const selectedLocation = state.locations.get(state.selectedDriverId); @@ -387,6 +395,7 @@ function replaceLocations(locations) { renderRouteFocusPanel(selectedLocation); } else { clearRouteOverlay(); + clearInfoOverlay(); renderRouteFocusPanel(null); } } @@ -400,24 +409,48 @@ function replaceDrivers(drivers) { renderDrivers(); } -function upsertLocation(location, options = {}) { - state.locations.set(location.driverId, location); - render(); - updateMarker(location); +function queueLocationUpdate(location) { + state.pendingLocationUpdates.set(location.driverId, location); - if (state.selectedDriverId === location.driverId && !elements.historyDrawer.classList.contains("is-hidden")) { - loadLocationHistory(location); - renderRoutePreview(location, { fit: false }); - showInfoOverlay(location); - renderRouteFocusPanel(location); + if (state.locationUpdateBatchTimer) return; + + state.locationUpdateBatchTimer = window.setTimeout(() => { + state.locationUpdateBatchTimer = 0; + flushLocationUpdates(); + }, 180); +} + +function flushLocationUpdates() { + if (!state.pendingLocationUpdates.size) return; + + const locations = [...state.pendingLocationUpdates.values()]; + state.pendingLocationUpdates.clear(); + applyLocationUpdates(locations); +} + +function applyLocationUpdates(locations) { + if (!locations.length) return; + + for (const location of locations) { + state.locations.set(location.driverId, location); } - if (options.focus) { - fitMapToRoute(location) || centerMapOnLocation(location); + render(); + updateMarkers(); + + const selectedLocation = state.selectedDriverId + ? state.locations.get(state.selectedDriverId) + : null; + + if (selectedLocation && !elements.historyDrawer.classList.contains("is-hidden")) { + renderRoutePreview(selectedLocation, { fit: false }); + showInfoOverlay(selectedLocation); + renderRouteFocusPanel(selectedLocation); } } function render() { + const vehicleListScrollTop = elements.vehicleList.scrollTop; const locations = [...state.locations.values()].sort((left, right) => { return new Date(right.receivedAt).getTime() - new Date(left.receivedAt).getTime(); }); @@ -450,12 +483,15 @@ function render() { empty.className = "empty-state"; empty.textContent = locations.length ? "조건에 맞는 차량이 없습니다." : "수신 위치가 없습니다."; elements.vehicleList.append(empty); + elements.vehicleList.scrollTop = vehicleListScrollTop; return; } for (const location of visibleLocations) { elements.vehicleList.append(createVehicleItem(location)); } + + elements.vehicleList.scrollTop = vehicleListScrollTop; } function renderOperationsSummary(locations, counts) { @@ -769,6 +805,46 @@ function updateMarkers({ fitBounds = false } = {}) { } } +function updateInitialMapFit() { + if (state.hasFittedInitialLocations || !state.map || !state.locations.size) return; + + updateMarkers({ fitBounds: true }); + state.hasFittedInitialLocations = true; +} + +function bindMapInteractionTracking() { + if (!state.map || !state.mapProvider) return; + + const markUserInteraction = () => { + if (!state.isProgrammaticMapChange) { + state.hasFittedInitialLocations = true; + } + }; + + if (state.mapProvider === "kakao") { + kakao.maps.event.addListener(state.map, "dragstart", markUserInteraction); + kakao.maps.event.addListener(state.map, "zoom_changed", markUserInteraction); + return; + } + + if (state.mapProvider === "leaflet") { + state.map.on("dragstart", markUserInteraction); + state.map.on("zoomstart", markUserInteraction); + } +} + +function runProgrammaticMapChange(callback) { + state.isProgrammaticMapChange = true; + window.clearTimeout(state.programmaticMapChangeTimer); + try { + return callback(); + } finally { + state.programmaticMapChangeTimer = window.setTimeout(() => { + state.isProgrammaticMapChange = false; + }, 350); + } +} + function updateMarker(location) { if (!state.map || !state.mapProvider) return; @@ -784,9 +860,9 @@ function updateKakaoMarker(location) { 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); - let marker = state.markers.get(location.driverId); + const markerHeading = resolveMarkerHeading(location, marker?.currentPosition, targetPosition); + const markerElement = createTruckMarkerElement(location, status, markerHeading); if (!marker) { marker = { @@ -811,15 +887,17 @@ function updateLeafletMarker(location) { const vehicleType = getVehicleType(location); const targetPosition = createPosition(location.latitude, location.longitude); const leafletPosition = [targetPosition.latitude, targetPosition.longitude]; + const existingMarker = state.markers.get(location.driverId); + const markerHeading = resolveMarkerHeading(location, existingMarker?.currentPosition, targetPosition); const icon = L.divIcon({ className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className}`, - html: createTruckMarkerHtml(location, vehicleType), - iconSize: [74, 38], - iconAnchor: [37, 38], - popupAnchor: [0, -30] + html: createTruckMarkerHtml(location, vehicleType, markerHeading), + iconSize: [86, 56], + iconAnchor: [43, 56], + popupAnchor: [0, -44] }); - let marker = state.markers.get(location.driverId); + let marker = existingMarker; if (!marker) { marker = { @@ -838,29 +916,69 @@ function updateLeafletMarker(location) { } } -function createTruckMarkerElement(location, status) { +function createTruckMarkerElement(location, status, heading) { const vehicleType = getVehicleType(location); const markerElement = document.createElement("button"); markerElement.className = `truck-marker ${status.key} ${vehicleType.className}`; markerElement.type = "button"; markerElement.title = `${vehicleType.label} ${location.vehicleNo || location.driverId}`; markerElement.setAttribute("aria-label", `${vehicleType.label} ${location.vehicleNo || location.driverId} 위치 보기`); - markerElement.innerHTML = createTruckMarkerHtml(location, vehicleType); + markerElement.innerHTML = createTruckMarkerHtml(location, vehicleType, heading); markerElement.addEventListener("click", () => focusLocation(location)); return markerElement; } -function createTruckMarkerHtml(location, vehicleType = getVehicleType(location)) { +function createTruckMarkerHtml(location, vehicleType = getVehicleType(location), heading = getLocationHeading(location)) { const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4); + const rotation = normalizeMarkerRotation(heading); return ` - + + + + + ${escapeHtml(vehicleType.shortLabel)} ${escapeHtml(label)} - `; } +function resolveMarkerHeading(location, fromPosition, targetPosition) { + const routeHeading = getRouteStartHeading(location); + if (routeHeading !== null) return routeHeading; + + if (fromPosition && targetPosition && calculateDistanceKm(fromPosition, targetPosition) >= 0.003) { + return calculateBearing(fromPosition, targetPosition); + } + + return getLocationHeading(location); +} + +function getRouteStartHeading(location) { + const routePath = getRoutePath(location); + if (routePath.length < 2) return null; + + const from = routePath[0]; + for (let index = 1; index < routePath.length; index += 1) { + const to = routePath[index]; + if (calculateDistanceKm(from, to) >= 0.003) { + return calculateBearing(from, to); + } + } + + return null; +} + +function getLocationHeading(location) { + const heading = parseFiniteNumber(location.heading); + return heading === null ? 90 : heading; +} + +function normalizeMarkerRotation(heading) { + const normalizedHeading = ((Number(heading) % 360) + 360) % 360; + return Math.round(normalizedHeading - 90); +} + function animateMarkerPosition(marker, targetPosition) { const fromPosition = marker.currentPosition || getMarkerPosition(marker) || targetPosition; @@ -956,19 +1074,24 @@ function fitMapToLocations() { for (const location of state.locations.values()) { bounds.extend(new kakao.maps.LatLng(location.latitude, location.longitude)); } - state.map.setBounds(bounds); - return; + runProgrammaticMapChange(() => state.map.setBounds(bounds)); + return true; } if (state.mapProvider === "leaflet") { const bounds = L.latLngBounds([...state.locations.values()].map((location) => { return [location.latitude, location.longitude]; })); - state.map.fitBounds(bounds, { - maxZoom: 14, - padding: [40, 40] + runProgrammaticMapChange(() => { + state.map.fitBounds(bounds, { + maxZoom: 14, + padding: [40, 40] + }); }); + return true; } + + return false; } function centerMapOnLocation(location, options = {}) { @@ -976,16 +1099,20 @@ function centerMapOnLocation(location, options = {}) { if (state.mapProvider === "kakao") { const position = new kakao.maps.LatLng(location.latitude, location.longitude); - state.map.setCenter(position); - if (options.zoom && state.map.getLevel() > 4) { - state.map.setLevel(4); - } + runProgrammaticMapChange(() => { + state.map.setCenter(position); + if (options.zoom && state.map.getLevel() > 4) { + state.map.setLevel(4); + } + }); return; } if (state.mapProvider === "leaflet") { const nextZoom = options.zoom ? Math.max(state.map.getZoom(), 14) : state.map.getZoom(); - state.map.setView([location.latitude, location.longitude], nextZoom, { animate: true }); + runProgrammaticMapChange(() => { + state.map.setView([location.latitude, location.longitude], nextZoom, { animate: true }); + }); } } @@ -1070,18 +1197,20 @@ function fitMapToRoute(location) { for (const point of routePath) { bounds.extend(new kakao.maps.LatLng(point.latitude, point.longitude)); } - state.map.setBounds(bounds); + runProgrammaticMapChange(() => 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] - } - ); + runProgrammaticMapChange(() => { + state.map.fitBounds( + L.latLngBounds(routePath.map((point) => [point.latitude, point.longitude])), + { + maxZoom: 13, + padding: [48, 48] + } + ); + }); return true; } @@ -1194,15 +1323,28 @@ function closeHistoryDrawer() { state.selectedDriverId = ""; elements.historyDrawer.classList.add("is-hidden"); clearRouteOverlay(); + clearInfoOverlay(); renderRouteFocusPanel(null); render(); } function showInfoOverlay(location) { + if (shouldSuppressMapPopup()) { + clearInfoOverlay(); + return; + } + const status = getLocationStatus(location); const content = createPopupElement(location, status); if (state.mapProvider === "leaflet") { + if (state.infoOverlay) { + state.infoOverlay + .setLatLng([location.latitude, location.longitude]) + .setContent(content); + return; + } + state.infoOverlay = L.popup({ closeButton: true, offset: [0, -24] @@ -1223,6 +1365,22 @@ function showInfoOverlay(location) { state.infoOverlay.setMap(state.map); } +function clearInfoOverlay() { + if (!state.infoOverlay) return; + + if (typeof state.infoOverlay.setMap === "function") { + state.infoOverlay.setMap(null); + } else if (state.mapProvider === "leaflet" && state.map?.closePopup) { + state.map.closePopup(state.infoOverlay); + } + + state.infoOverlay = null; +} + +function shouldSuppressMapPopup() { + return window.matchMedia("(max-width: 560px)").matches; +} + function createPopupElement(location, status) { const vehicleType = getVehicleType(location); const container = document.createElement("div"); @@ -1560,6 +1718,16 @@ function calculateDistanceKm(from, to) { return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } +function calculateBearing(from, to) { + const fromLatitude = toRadians(from.latitude); + const toLatitude = toRadians(to.latitude); + const deltaLongitude = toRadians(to.longitude - from.longitude); + const y = Math.sin(deltaLongitude) * Math.cos(toLatitude); + const x = Math.cos(fromLatitude) * Math.sin(toLatitude) - + Math.sin(fromLatitude) * Math.cos(toLatitude) * Math.cos(deltaLongitude); + return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; +} + function interpolateNumber(from, to, progress) { return from + (to - from) * progress; } diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css index cf96348..a694005 100644 --- a/apps/web/public/styles.css +++ b/apps/web/public/styles.css @@ -932,17 +932,19 @@ button:hover { --truck-color: var(--blue); --truck-cab: #1556b8; --truck-accent: rgba(255, 255, 255, 0.2); + --truck-heading: 0deg; --body-width: 42px; --body-height: 24px; --cab-width: 19px; position: relative; - display: inline-flex; - align-items: flex-end; - width: max-content; - min-width: calc(var(--body-width) + var(--cab-width)); - min-height: 38px; + display: grid; + place-items: center; + width: 86px; + min-width: 86px; + height: 56px; + min-height: 56px; border: 0; - padding: 0 0 8px; + padding: 0; background: transparent; color: #ffffff; filter: drop-shadow(0 8px 13px rgba(16, 32, 48, 0.28)); @@ -1017,8 +1019,19 @@ button:hover { --truck-cab: #8f1d15; } -.truck-marker::before, -.truck-marker::after { +.truck-marker__vehicle { + position: relative; + display: inline-flex; + align-items: flex-end; + justify-content: center; + padding-bottom: 8px; + transform: rotate(var(--truck-heading)); + transform-origin: 50% 44%; + transition: transform 0.35s ease; +} + +.truck-marker__vehicle::before, +.truck-marker__vehicle::after { content: ""; position: absolute; bottom: 1px; @@ -1029,19 +1042,17 @@ button:hover { background: #17212b; } -.truck-marker::before { +.truck-marker__vehicle::before { left: 11px; } -.truck-marker::after { +.truck-marker__vehicle::after { right: 12px; } .truck-marker__body { position: relative; - display: grid; - grid-template-rows: 1fr 1fr; - place-items: center; + display: block; width: var(--body-width); height: var(--body-height); border: 2px solid #ffffff; @@ -1050,31 +1061,43 @@ button:hover { background: var(--truck-accent), var(--truck-color); - font-size: 10px; - font-weight: 900; - line-height: 1; - white-space: nowrap; - overflow: hidden; } .truck-marker.type-van .truck-marker__body { - grid-template-rows: 1fr; border-radius: 9px 0 0 7px; } -.truck-marker.type-van .truck-marker__type { - display: none; +.truck-marker__label { + position: absolute; + left: 50%; + bottom: 0; + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 82px; + min-height: 18px; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 999px; + background: rgba(15, 23, 42, 0.88); + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.22); + color: #ffffff; + font-size: 10px; + font-weight: 900; + line-height: 1.1; + transform: translateX(-50%); + white-space: nowrap; } -.truck-marker__type { - align-self: end; - font-size: 9px; - opacity: 0.88; +.truck-marker__type, +.truck-marker__plate { + display: inline-block; + min-width: 0; } .truck-marker__plate { - align-self: start; - font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; } .truck-marker__cab { @@ -1319,6 +1342,35 @@ button:hover { overflow: auto; } + .history-drawer { + right: 8px; + bottom: 8px; + left: 8px; + max-height: min(178px, calc(100% - 86px)); + } + + .history-heading { + gap: 8px; + padding: 8px 10px; + } + + .history-heading strong { + font-size: 14px; + } + + .history-heading span { + font-size: 11px; + } + + .history-list { + max-height: 122px; + } + + .history-row { + padding: 7px 10px; + font-size: 12px; + } + .vehicle-item { grid-template-columns: minmax(0, 1fr); }