관제 지도 갱신 안정화

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, routeOverlay: null,
routeDestinationOverlay: null, routeDestinationOverlay: null,
markerAnimationMs: 4800, markerAnimationMs: 4800,
hasFittedInitialLocations: false,
isProgrammaticMapChange: false,
programmaticMapChangeTimer: 0,
pendingLocationUpdates: new Map(),
locationUpdateBatchTimer: 0,
drivers: new Map(), drivers: new Map(),
locations: new Map(), locations: new Map(),
selectedDriverId: "", 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.ZoomControl(), kakao.maps.ControlPosition.RIGHT);
state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT); state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT);
bindMapInteractionTracking();
hideMapMessage(); hideMapMessage();
updateMarkers({ fitBounds: state.locations.size > 0 }); updateInitialMapFit();
return; return;
} catch (error) { } catch (error) {
console.warn("Kakao map unavailable. Falling back to OpenStreetMap.", 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' attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(state.map); }).addTo(state.map);
bindMapInteractionTracking();
showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중"); showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중");
window.setTimeout(hideMapMessage, 4500); window.setTimeout(hideMapMessage, 4500);
updateMarkers({ fitBounds: state.locations.size > 0 }); updateInitialMapFit();
} }
function loadLeafletAssets() { function loadLeafletAssets() {
@@ -316,7 +323,7 @@ function connectEvents() {
}); });
state.eventSource.addEventListener("location.updated", (event) => { state.eventSource.addEventListener("location.updated", (event) => {
const location = JSON.parse(event.data); const location = JSON.parse(event.data);
upsertLocation(location, { focus: state.selectedDriverId === location.driverId }); queueLocationUpdate(location);
}); });
state.eventSource.addEventListener("drivers.updated", (event) => { state.eventSource.addEventListener("drivers.updated", (event) => {
const payload = JSON.parse(event.data); const payload = JSON.parse(event.data);
@@ -377,7 +384,8 @@ function replaceLocations(locations) {
state.locations.set(location.driverId, location); state.locations.set(location.driverId, location);
} }
render(); render();
updateMarkers({ fitBounds: locations.length > 0 }); updateMarkers();
updateInitialMapFit();
if (state.selectedDriverId) { if (state.selectedDriverId) {
const selectedLocation = state.locations.get(state.selectedDriverId); const selectedLocation = state.locations.get(state.selectedDriverId);
@@ -387,6 +395,7 @@ function replaceLocations(locations) {
renderRouteFocusPanel(selectedLocation); renderRouteFocusPanel(selectedLocation);
} else { } else {
clearRouteOverlay(); clearRouteOverlay();
clearInfoOverlay();
renderRouteFocusPanel(null); renderRouteFocusPanel(null);
} }
} }
@@ -400,24 +409,48 @@ function replaceDrivers(drivers) {
renderDrivers(); renderDrivers();
} }
function upsertLocation(location, options = {}) { function queueLocationUpdate(location) {
state.locations.set(location.driverId, location); state.pendingLocationUpdates.set(location.driverId, location);
render();
updateMarker(location);
if (state.selectedDriverId === location.driverId && !elements.historyDrawer.classList.contains("is-hidden")) { if (state.locationUpdateBatchTimer) return;
loadLocationHistory(location);
renderRoutePreview(location, { fit: false }); state.locationUpdateBatchTimer = window.setTimeout(() => {
showInfoOverlay(location); state.locationUpdateBatchTimer = 0;
renderRouteFocusPanel(location); 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) { render();
fitMapToRoute(location) || centerMapOnLocation(location); 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() { function render() {
const vehicleListScrollTop = elements.vehicleList.scrollTop;
const locations = [...state.locations.values()].sort((left, right) => { const locations = [...state.locations.values()].sort((left, right) => {
return new Date(right.receivedAt).getTime() - new Date(left.receivedAt).getTime(); return new Date(right.receivedAt).getTime() - new Date(left.receivedAt).getTime();
}); });
@@ -450,12 +483,15 @@ function render() {
empty.className = "empty-state"; empty.className = "empty-state";
empty.textContent = locations.length ? "조건에 맞는 차량이 없습니다." : "수신 위치가 없습니다."; empty.textContent = locations.length ? "조건에 맞는 차량이 없습니다." : "수신 위치가 없습니다.";
elements.vehicleList.append(empty); elements.vehicleList.append(empty);
elements.vehicleList.scrollTop = vehicleListScrollTop;
return; return;
} }
for (const location of visibleLocations) { for (const location of visibleLocations) {
elements.vehicleList.append(createVehicleItem(location)); elements.vehicleList.append(createVehicleItem(location));
} }
elements.vehicleList.scrollTop = vehicleListScrollTop;
} }
function renderOperationsSummary(locations, counts) { 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) { function updateMarker(location) {
if (!state.map || !state.mapProvider) return; if (!state.map || !state.mapProvider) return;
@@ -784,9 +860,9 @@ function updateKakaoMarker(location) {
const targetPosition = createPosition(location.latitude, location.longitude); const targetPosition = createPosition(location.latitude, location.longitude);
const kakaoPosition = new kakao.maps.LatLng(targetPosition.latitude, targetPosition.longitude); const kakaoPosition = new kakao.maps.LatLng(targetPosition.latitude, targetPosition.longitude);
const status = getLocationStatus(location); const status = getLocationStatus(location);
const markerElement = createTruckMarkerElement(location, status);
let marker = state.markers.get(location.driverId); let marker = state.markers.get(location.driverId);
const markerHeading = resolveMarkerHeading(location, marker?.currentPosition, targetPosition);
const markerElement = createTruckMarkerElement(location, status, markerHeading);
if (!marker) { if (!marker) {
marker = { marker = {
@@ -811,15 +887,17 @@ function updateLeafletMarker(location) {
const vehicleType = getVehicleType(location); const vehicleType = getVehicleType(location);
const targetPosition = createPosition(location.latitude, location.longitude); const targetPosition = createPosition(location.latitude, location.longitude);
const leafletPosition = [targetPosition.latitude, targetPosition.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({ const icon = L.divIcon({
className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className}`, className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className}`,
html: createTruckMarkerHtml(location, vehicleType), html: createTruckMarkerHtml(location, vehicleType, markerHeading),
iconSize: [74, 38], iconSize: [86, 56],
iconAnchor: [37, 38], iconAnchor: [43, 56],
popupAnchor: [0, -30] popupAnchor: [0, -44]
}); });
let marker = state.markers.get(location.driverId); let marker = existingMarker;
if (!marker) { if (!marker) {
marker = { marker = {
@@ -838,29 +916,69 @@ function updateLeafletMarker(location) {
} }
} }
function createTruckMarkerElement(location, status) { function createTruckMarkerElement(location, status, heading) {
const vehicleType = getVehicleType(location); const vehicleType = getVehicleType(location);
const markerElement = document.createElement("button"); const markerElement = document.createElement("button");
markerElement.className = `truck-marker ${status.key} ${vehicleType.className}`; markerElement.className = `truck-marker ${status.key} ${vehicleType.className}`;
markerElement.type = "button"; markerElement.type = "button";
markerElement.title = `${vehicleType.label} ${location.vehicleNo || location.driverId}`; markerElement.title = `${vehicleType.label} ${location.vehicleNo || location.driverId}`;
markerElement.setAttribute("aria-label", `${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)); markerElement.addEventListener("click", () => focusLocation(location));
return markerElement; 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 label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4);
const rotation = normalizeMarkerRotation(heading);
return ` 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__type">${escapeHtml(vehicleType.shortLabel)}</span>
<span class="truck-marker__plate">${escapeHtml(label)}</span> <span class="truck-marker__plate">${escapeHtml(label)}</span>
</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) { function animateMarkerPosition(marker, targetPosition) {
const fromPosition = marker.currentPosition || getMarkerPosition(marker) || targetPosition; const fromPosition = marker.currentPosition || getMarkerPosition(marker) || targetPosition;
@@ -956,19 +1074,24 @@ function fitMapToLocations() {
for (const location of state.locations.values()) { for (const location of state.locations.values()) {
bounds.extend(new kakao.maps.LatLng(location.latitude, location.longitude)); bounds.extend(new kakao.maps.LatLng(location.latitude, location.longitude));
} }
state.map.setBounds(bounds); runProgrammaticMapChange(() => state.map.setBounds(bounds));
return; return true;
} }
if (state.mapProvider === "leaflet") { if (state.mapProvider === "leaflet") {
const bounds = L.latLngBounds([...state.locations.values()].map((location) => { const bounds = L.latLngBounds([...state.locations.values()].map((location) => {
return [location.latitude, location.longitude]; return [location.latitude, location.longitude];
})); }));
state.map.fitBounds(bounds, { runProgrammaticMapChange(() => {
maxZoom: 14, state.map.fitBounds(bounds, {
padding: [40, 40] maxZoom: 14,
padding: [40, 40]
});
}); });
return true;
} }
return false;
} }
function centerMapOnLocation(location, options = {}) { function centerMapOnLocation(location, options = {}) {
@@ -976,16 +1099,20 @@ function centerMapOnLocation(location, options = {}) {
if (state.mapProvider === "kakao") { if (state.mapProvider === "kakao") {
const position = new kakao.maps.LatLng(location.latitude, location.longitude); const position = new kakao.maps.LatLng(location.latitude, location.longitude);
state.map.setCenter(position); runProgrammaticMapChange(() => {
if (options.zoom && state.map.getLevel() > 4) { state.map.setCenter(position);
state.map.setLevel(4); if (options.zoom && state.map.getLevel() > 4) {
} state.map.setLevel(4);
}
});
return; return;
} }
if (state.mapProvider === "leaflet") { if (state.mapProvider === "leaflet") {
const nextZoom = options.zoom ? Math.max(state.map.getZoom(), 14) : state.map.getZoom(); 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) { for (const point of routePath) {
bounds.extend(new kakao.maps.LatLng(point.latitude, point.longitude)); bounds.extend(new kakao.maps.LatLng(point.latitude, point.longitude));
} }
state.map.setBounds(bounds); runProgrammaticMapChange(() => state.map.setBounds(bounds));
return true; return true;
} }
if (state.mapProvider === "leaflet") { if (state.mapProvider === "leaflet") {
state.map.fitBounds( runProgrammaticMapChange(() => {
L.latLngBounds(routePath.map((point) => [point.latitude, point.longitude])), state.map.fitBounds(
{ L.latLngBounds(routePath.map((point) => [point.latitude, point.longitude])),
maxZoom: 13, {
padding: [48, 48] maxZoom: 13,
} padding: [48, 48]
); }
);
});
return true; return true;
} }
@@ -1194,15 +1323,28 @@ function closeHistoryDrawer() {
state.selectedDriverId = ""; state.selectedDriverId = "";
elements.historyDrawer.classList.add("is-hidden"); elements.historyDrawer.classList.add("is-hidden");
clearRouteOverlay(); clearRouteOverlay();
clearInfoOverlay();
renderRouteFocusPanel(null); renderRouteFocusPanel(null);
render(); render();
} }
function showInfoOverlay(location) { function showInfoOverlay(location) {
if (shouldSuppressMapPopup()) {
clearInfoOverlay();
return;
}
const status = getLocationStatus(location); const status = getLocationStatus(location);
const content = createPopupElement(location, status); const content = createPopupElement(location, status);
if (state.mapProvider === "leaflet") { if (state.mapProvider === "leaflet") {
if (state.infoOverlay) {
state.infoOverlay
.setLatLng([location.latitude, location.longitude])
.setContent(content);
return;
}
state.infoOverlay = L.popup({ state.infoOverlay = L.popup({
closeButton: true, closeButton: true,
offset: [0, -24] offset: [0, -24]
@@ -1223,6 +1365,22 @@ function showInfoOverlay(location) {
state.infoOverlay.setMap(state.map); 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) { function createPopupElement(location, status) {
const vehicleType = getVehicleType(location); const vehicleType = getVehicleType(location);
const container = document.createElement("div"); 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)); 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) { function interpolateNumber(from, to, progress) {
return from + (to - from) * progress; return from + (to - from) * progress;
} }

View File

@@ -932,17 +932,19 @@ button:hover {
--truck-color: var(--blue); --truck-color: var(--blue);
--truck-cab: #1556b8; --truck-cab: #1556b8;
--truck-accent: rgba(255, 255, 255, 0.2); --truck-accent: rgba(255, 255, 255, 0.2);
--truck-heading: 0deg;
--body-width: 42px; --body-width: 42px;
--body-height: 24px; --body-height: 24px;
--cab-width: 19px; --cab-width: 19px;
position: relative; position: relative;
display: inline-flex; display: grid;
align-items: flex-end; place-items: center;
width: max-content; width: 86px;
min-width: calc(var(--body-width) + var(--cab-width)); min-width: 86px;
min-height: 38px; height: 56px;
min-height: 56px;
border: 0; border: 0;
padding: 0 0 8px; padding: 0;
background: transparent; background: transparent;
color: #ffffff; color: #ffffff;
filter: drop-shadow(0 8px 13px rgba(16, 32, 48, 0.28)); filter: drop-shadow(0 8px 13px rgba(16, 32, 48, 0.28));
@@ -1017,8 +1019,19 @@ button:hover {
--truck-cab: #8f1d15; --truck-cab: #8f1d15;
} }
.truck-marker::before, .truck-marker__vehicle {
.truck-marker::after { 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: ""; content: "";
position: absolute; position: absolute;
bottom: 1px; bottom: 1px;
@@ -1029,19 +1042,17 @@ button:hover {
background: #17212b; background: #17212b;
} }
.truck-marker::before { .truck-marker__vehicle::before {
left: 11px; left: 11px;
} }
.truck-marker::after { .truck-marker__vehicle::after {
right: 12px; right: 12px;
} }
.truck-marker__body { .truck-marker__body {
position: relative; position: relative;
display: grid; display: block;
grid-template-rows: 1fr 1fr;
place-items: center;
width: var(--body-width); width: var(--body-width);
height: var(--body-height); height: var(--body-height);
border: 2px solid #ffffff; border: 2px solid #ffffff;
@@ -1050,31 +1061,43 @@ button:hover {
background: background:
var(--truck-accent), var(--truck-accent),
var(--truck-color); var(--truck-color);
font-size: 10px;
font-weight: 900;
line-height: 1;
white-space: nowrap;
overflow: hidden;
} }
.truck-marker.type-van .truck-marker__body { .truck-marker.type-van .truck-marker__body {
grid-template-rows: 1fr;
border-radius: 9px 0 0 7px; border-radius: 9px 0 0 7px;
} }
.truck-marker.type-van .truck-marker__type { .truck-marker__label {
display: none; 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 { .truck-marker__type,
align-self: end; .truck-marker__plate {
font-size: 9px; display: inline-block;
opacity: 0.88; min-width: 0;
} }
.truck-marker__plate { .truck-marker__plate {
align-self: start; overflow: hidden;
font-size: 11px; text-overflow: ellipsis;
} }
.truck-marker__cab { .truck-marker__cab {
@@ -1319,6 +1342,35 @@ button:hover {
overflow: auto; 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 { .vehicle-item {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }