관제 지도 갱신 안정화

This commit is contained in:
2026-06-06 21:51:18 +09:00
parent 3b21cfb9a9
commit 5278b8cf06
2 changed files with 292 additions and 72 deletions

View File

@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 `
<span class="truck-marker__body">
<span class="truck-marker__vehicle" style="--truck-heading: ${rotation}deg">
<span class="truck-marker__body"></span>
<span class="truck-marker__cab"></span>
</span>
<span class="truck-marker__label">
<span class="truck-marker__type">${escapeHtml(vehicleType.shortLabel)}</span>
<span class="truck-marker__plate">${escapeHtml(label)}</span>
</span>
<span class="truck-marker__cab"></span>
`;
}
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;
}