Improve mobile CargoRadar controls

This commit is contained in:
2026-06-10 22:59:44 +09:00
parent cfcde81874
commit 94805d6fab
3 changed files with 3095 additions and 425 deletions

View File

@@ -11,6 +11,10 @@ const state = {
programmaticMapChangeTimer: 0, programmaticMapChangeTimer: 0,
pendingLocationUpdates: new Map(), pendingLocationUpdates: new Map(),
locationUpdateBatchTimer: 0, locationUpdateBatchTimer: 0,
mapResizeTimer: 0,
mapZoomMode: "",
mapLayer: "road",
leafletTileLayer: null,
drivers: new Map(), drivers: new Map(),
locations: new Map(), locations: new Map(),
selectedDriverId: "", selectedDriverId: "",
@@ -83,6 +87,9 @@ const elements = {
mapActiveCount: document.getElementById("mapActiveCount"), mapActiveCount: document.getElementById("mapActiveCount"),
mapEtaSummary: document.getElementById("mapEtaSummary"), mapEtaSummary: document.getElementById("mapEtaSummary"),
mapUpdatedAt: document.getElementById("mapUpdatedAt"), mapUpdatedAt: document.getElementById("mapUpdatedAt"),
mapLayerButtons: [...document.querySelectorAll("[data-map-layer]")],
mapZoomButtons: [...document.querySelectorAll("[data-map-zoom]")],
mapExpandButton: document.getElementById("mapExpandButton"),
routeFocusPanel: document.getElementById("routeFocusPanel"), routeFocusPanel: document.getElementById("routeFocusPanel"),
routeFocusTitle: document.getElementById("routeFocusTitle"), routeFocusTitle: document.getElementById("routeFocusTitle"),
routeFocusMeta: document.getElementById("routeFocusMeta"), routeFocusMeta: document.getElementById("routeFocusMeta"),
@@ -171,9 +178,11 @@ async function initializeMap() {
}); });
state.mapProvider = "kakao"; state.mapProvider = "kakao";
state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT); enableMapGestures();
state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT); applyMapLayer(state.mapLayer);
syncMapControls();
bindMapInteractionTracking(); bindMapInteractionTracking();
updateMapZoomMode();
hideMapMessage(); hideMapMessage();
updateInitialMapFit(); updateInitialMapFit();
return; return;
@@ -217,20 +226,166 @@ async function initializeLeafletMap() {
state.mapProvider = "leaflet"; state.mapProvider = "leaflet";
state.map = L.map("map", { state.map = L.map("map", {
attributionControl: true, attributionControl: true,
zoomControl: true dragging: true,
touchZoom: true,
scrollWheelZoom: true,
doubleClickZoom: true,
zoomControl: false
}).setView([36.45, 127.85], 7); }).setView([36.45, 127.85], 7);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { enableMapGestures();
maxZoom: 19, state.leafletTileLayer = createLeafletTileLayer(state.mapLayer).addTo(state.map);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' syncMapControls();
}).addTo(state.map);
bindMapInteractionTracking(); bindMapInteractionTracking();
updateMapZoomMode();
showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중"); showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중");
window.setTimeout(hideMapMessage, 4500); window.setTimeout(hideMapMessage, 4500);
updateInitialMapFit(); updateInitialMapFit();
} }
function createLeafletTileLayer(layer) {
if (layer === "sky") {
return L.tileLayer(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{
maxZoom: 19,
attribution: "Tiles &copy; Esri"
}
);
}
return L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
});
}
function applyMapLayer(layer) {
const nextLayer = layer === "sky" ? "sky" : "road";
state.mapLayer = nextLayer;
if (state.mapProvider === "kakao" && state.map && window.kakao?.maps) {
const skyTypeId = kakao.maps.MapTypeId.SKYVIEW || kakao.maps.MapTypeId.HYBRID || kakao.maps.MapTypeId.ROADMAP;
state.map.setMapTypeId(nextLayer === "sky" ? skyTypeId : kakao.maps.MapTypeId.ROADMAP);
redrawKakaoMap();
if (state.locations.size > 0) {
window.setTimeout(() => updateMarkers(), 80);
}
}
if (state.mapProvider === "leaflet" && state.map && window.L?.tileLayer) {
if (state.leafletTileLayer) {
state.leafletTileLayer.remove();
}
state.leafletTileLayer = createLeafletTileLayer(nextLayer).addTo(state.map);
}
syncMapControls();
}
function redrawKakaoMap() {
if (state.mapProvider !== "kakao" || !state.map) return;
if (typeof state.map.relayout === "function") {
state.map.relayout();
}
if (typeof state.map.getCenter === "function" && typeof state.map.setCenter === "function") {
state.map.setCenter(state.map.getCenter());
}
enableMapGestures();
}
function resizeMapViewport() {
if (!state.map || !state.mapProvider) return;
if (state.mapProvider === "kakao") {
redrawKakaoMap();
return;
}
if (state.mapProvider === "leaflet" && typeof state.map.invalidateSize === "function") {
state.map.invalidateSize();
}
enableMapGestures();
}
function enableMapGestures() {
if (!state.map || !state.mapProvider) return;
if (state.mapProvider === "kakao") {
if (typeof state.map.setDraggable === "function") {
state.map.setDraggable(true);
}
if (typeof state.map.setZoomable === "function") {
state.map.setZoomable(true);
}
return;
}
if (state.mapProvider !== "leaflet") return;
const gestureHandlers = [
state.map.dragging,
state.map.touchZoom,
state.map.scrollWheelZoom,
state.map.doubleClickZoom,
state.map.boxZoom,
state.map.keyboard,
state.map.tap
];
for (const handler of gestureHandlers) {
if (typeof handler?.enable === "function") {
handler.enable();
}
}
}
function adjustMapZoom(direction) {
if (!state.map || !state.mapProvider) return;
state.hasFittedInitialLocations = true;
if (state.mapProvider === "kakao" && typeof state.map.getLevel === "function") {
const delta = direction === "in" ? -1 : 1;
const nextLevel = Math.max(1, Math.min(14, state.map.getLevel() + delta));
state.map.setLevel(nextLevel);
updateMapZoomMode();
return;
}
if (state.mapProvider === "leaflet") {
if (direction === "in") {
state.map.zoomIn();
} else {
state.map.zoomOut();
}
}
}
function syncMapControls() {
for (const button of elements.mapLayerButtons) {
const isActive = button.dataset.mapLayer === state.mapLayer;
button.classList.toggle("is-active", isActive);
button.setAttribute("aria-pressed", String(isActive));
}
if (elements.mapPanel) {
elements.mapPanel.classList.toggle("is-layer-sky", state.mapLayer === "sky");
}
}
function setMapExpanded(isExpanded) {
document.body.classList.toggle("is-map-expanded", isExpanded);
elements.mapExpandButton.textContent = isExpanded ? "목록 보기" : "지도 크게";
elements.mapExpandButton.setAttribute("aria-pressed", String(isExpanded));
window.setTimeout(resizeMapViewport, 80);
}
function loadLeafletAssets() { function loadLeafletAssets() {
if (window.L?.map) return Promise.resolve(); if (window.L?.map) return Promise.resolve();
@@ -288,6 +443,29 @@ function bindActions() {
elements.refreshButton.addEventListener("click", refreshLatestLocations); elements.refreshButton.addEventListener("click", refreshLatestLocations);
elements.demoButton.addEventListener("click", postDemoLocation); elements.demoButton.addEventListener("click", postDemoLocation);
elements.logoutButton.addEventListener("click", showLoginScreen); elements.logoutButton.addEventListener("click", showLoginScreen);
for (const button of elements.mapLayerButtons) {
button.addEventListener("click", () => {
state.hasFittedInitialLocations = true;
applyMapLayer(button.dataset.mapLayer);
});
}
for (const button of elements.mapZoomButtons) {
button.addEventListener("click", () => {
adjustMapZoom(button.dataset.mapZoom);
});
}
elements.mapExpandButton.addEventListener("click", () => {
setMapExpanded(!document.body.classList.contains("is-map-expanded"));
});
window.addEventListener("resize", () => {
if (state.mapResizeTimer) {
window.clearTimeout(state.mapResizeTimer);
}
state.mapResizeTimer = window.setTimeout(() => {
state.mapResizeTimer = 0;
resizeMapViewport();
}, 120);
});
elements.locationsTab.addEventListener("click", () => setActiveView("locations")); elements.locationsTab.addEventListener("click", () => setActiveView("locations"));
elements.driversTab.addEventListener("click", () => setActiveView("drivers")); elements.driversTab.addEventListener("click", () => setActiveView("drivers"));
elements.driverForm.addEventListener("submit", submitDriverForm); elements.driverForm.addEventListener("submit", submitDriverForm);
@@ -337,6 +515,7 @@ function submitLoginForm(event) {
function showLoginScreen(options = {}) { function showLoginScreen(options = {}) {
clearLaunchSequence(); clearLaunchSequence();
clearStoredLoginState(); clearStoredLoginState();
setMapExpanded(false);
if (state.eventSource) { if (state.eventSource) {
state.eventSource.close(); state.eventSource.close();
state.eventSource = null; state.eventSource = null;
@@ -994,18 +1173,62 @@ function bindMapInteractionTracking() {
} }
}; };
const handleZoomChange = () => {
markUserInteraction();
updateMapZoomMode();
if (state.mapProvider === "kakao" && state.markers.size > 0) {
window.setTimeout(() => updateMarkers(), 80);
}
};
if (state.mapProvider === "kakao") { if (state.mapProvider === "kakao") {
kakao.maps.event.addListener(state.map, "dragstart", markUserInteraction); kakao.maps.event.addListener(state.map, "dragstart", markUserInteraction);
kakao.maps.event.addListener(state.map, "zoom_changed", markUserInteraction); kakao.maps.event.addListener(state.map, "zoom_changed", handleZoomChange);
return; return;
} }
if (state.mapProvider === "leaflet") { if (state.mapProvider === "leaflet") {
state.map.on("dragstart", markUserInteraction); state.map.on("dragstart", markUserInteraction);
state.map.on("zoomstart", markUserInteraction); state.map.on("zoomstart", markUserInteraction);
state.map.on("zoomend", handleZoomChange);
} }
} }
function updateMapZoomMode() {
if (!elements.mapPanel || !state.map || !state.mapProvider) return;
const mode = getMapZoomMode();
if (state.mapZoomMode === mode) return;
state.mapZoomMode = mode;
elements.mapPanel.dataset.zoomMode = mode;
elements.mapPanel.classList.toggle("is-zoom-far", mode === "far");
elements.mapPanel.classList.toggle("is-zoom-mid", mode === "mid");
elements.mapPanel.classList.toggle("is-zoom-near", mode === "near");
if (state.mapProvider === "leaflet" && state.markers.size > 0) {
updateMarkers();
}
}
function getMapZoomMode() {
if (state.mapProvider === "kakao" && typeof state.map.getLevel === "function") {
const level = state.map.getLevel();
if (level <= 5) return "near";
if (level <= 9) return "mid";
return "far";
}
if (state.mapProvider === "leaflet" && typeof state.map.getZoom === "function") {
const zoom = state.map.getZoom();
if (zoom >= 14) return "near";
if (zoom >= 10) return "mid";
return "far";
}
return "mid";
}
function runProgrammaticMapChange(callback) { function runProgrammaticMapChange(callback) {
state.isProgrammaticMapChange = true; state.isProgrammaticMapChange = true;
window.clearTimeout(state.programmaticMapChangeTimer); window.clearTimeout(state.programmaticMapChangeTimer);
@@ -1043,7 +1266,8 @@ function updateKakaoMarker(location) {
overlay: new kakao.maps.CustomOverlay({ overlay: new kakao.maps.CustomOverlay({
position: kakaoPosition, position: kakaoPosition,
content: markerElement, content: markerElement,
yAnchor: 1 xAnchor: 0.5,
yAnchor: 0.5
}), }),
element: markerElement, element: markerElement,
currentPosition: targetPosition currentPosition: targetPosition
@@ -1052,6 +1276,7 @@ function updateKakaoMarker(location) {
state.markers.set(location.driverId, marker); state.markers.set(location.driverId, marker);
} else { } else {
marker.overlay.setContent(markerElement); marker.overlay.setContent(markerElement);
marker.overlay.setMap(state.map);
marker.element = markerElement; marker.element = markerElement;
animateMarkerPosition(marker, targetPosition); animateMarkerPosition(marker, targetPosition);
} }
@@ -1065,12 +1290,13 @@ function updateLeafletMarker(location) {
const existingMarker = state.markers.get(location.driverId); const existingMarker = state.markers.get(location.driverId);
const markerHeading = resolveMarkerHeading(location, existingMarker?.currentPosition, targetPosition); const markerHeading = resolveMarkerHeading(location, existingMarker?.currentPosition, targetPosition);
const selectedClass = state.selectedDriverId === location.driverId ? "is-selected" : ""; const selectedClass = state.selectedDriverId === location.driverId ? "is-selected" : "";
const iconMetrics = getTruckMarkerIconMetrics();
const icon = L.divIcon({ const icon = L.divIcon({
className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className} ${selectedClass}`, className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className} ${selectedClass}`,
html: createTruckMarkerHtml(location, vehicleType, markerHeading), html: createTruckMarkerHtml(location, vehicleType, markerHeading),
iconSize: [104, 68], iconSize: iconMetrics.size,
iconAnchor: [52, 64], iconAnchor: iconMetrics.anchor,
popupAnchor: [0, -50] popupAnchor: iconMetrics.popupAnchor
}); });
let marker = existingMarker; let marker = existingMarker;
@@ -1092,6 +1318,30 @@ function updateLeafletMarker(location) {
} }
} }
function getTruckMarkerIconMetrics() {
if (state.mapZoomMode === "far") {
return {
size: [30, 30],
anchor: [15, 15],
popupAnchor: [0, -20]
};
}
if (state.mapZoomMode === "mid") {
return {
size: [76, 46],
anchor: [38, 23],
popupAnchor: [0, -28]
};
}
return {
size: [98, 58],
anchor: [49, 29],
popupAnchor: [0, -34]
};
}
function createTruckMarkerElement(location, status, heading) { function createTruckMarkerElement(location, status, heading) {
const vehicleType = getVehicleType(location); const vehicleType = getVehicleType(location);
const markerElement = document.createElement("button"); const markerElement = document.createElement("button");
@@ -1111,112 +1361,132 @@ function createTruckMarkerElement(location, status, heading) {
function createTruckMarkerHtml(location, vehicleType = getVehicleType(location), heading = getLocationHeading(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); const rotation = normalizeMarkerRotation(heading);
const vehiclePictogram = createVehiclePictogramSvg(vehicleType.className);
return ` return `
<span class="truck-marker__beacon" style="--truck-heading: ${rotation}deg" aria-hidden="true"> <span class="truck-marker__heading" style="--truck-heading: ${rotation}deg" aria-hidden="true">
<svg class="truck-marker__beacon-icon" viewBox="0 0 44 52" focusable="false" aria-hidden="true"> <svg class="truck-marker__heading-icon" viewBox="0 0 54 54" focusable="false" aria-hidden="true">
<path class="beacon-shadow" d="M22 4 39 45 22 36 5 45z"></path> <path class="heading-glow" d="M27 3 36 22 27 18 18 22z"></path>
<path class="beacon-body" d="M22 4 39 45 22 36 5 45z"></path> <path class="heading-tip" d="M27 5.5 34 20 27 16.6 20 20z"></path>
<path class="beacon-spine" d="M22 13v25"></path>
</svg> </svg>
</span> </span>
<span class="truck-marker__body-icon" aria-hidden="true">${vehiclePictogram}</span> <span class="truck-marker__badge">
<span class="truck-marker__status" aria-hidden="true"></span> ${createVehicleTopSvg(vehicleType.className)}
<span class="truck-marker__identity">
<span class="truck-marker__label-icon" aria-hidden="true">${vehiclePictogram}</span>
<span class="truck-marker__plate">${escapeHtml(label)}</span> <span class="truck-marker__plate">${escapeHtml(label)}</span>
<span class="truck-marker__type">${escapeHtml(vehicleType.shortLabel)}</span>
</span> </span>
<span class="truck-marker__status" aria-hidden="true"></span>
`; `;
} }
function createVehiclePictogramSvg(typeClass) { function createVehicleTopSvg(typeClass) {
if (typeClass === "type-trailer") { if (typeClass === "type-trailer") {
return ` return `
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true"> <svg class="truck-marker__photo truck-marker__photo--trailer" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
<rect class="icon-load" x="3" y="7" width="23" height="7" rx="2"></rect> <ellipse class="photo-shadow" cx="45" cy="31.5" rx="41" ry="3.8"></ellipse>
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path> <path class="photo-trailer" d="M6 13h54c2.2 0 3.8 1.6 3.8 3.8v10.4H6z"></path>
<path class="icon-line" d="M26 13h3"></path> <path class="photo-bedline" d="M9 17h50M9 22h50"></path>
<circle class="icon-wheel" cx="9" cy="17" r="2.2"></circle> <path class="photo-coupler" d="M64 23h7"></path>
<circle class="icon-wheel" cx="23" cy="17" r="2.2"></circle> <path class="photo-cab" d="M70 17h9.5l8.5 7.7V30H70z"></path>
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle> <path class="photo-window" d="M75 19.5h4.2l4.8 4.3h-9z"></path>
<path class="photo-light" d="M86.7 26h2.8v1.8h-2.8z"></path>
<circle class="photo-wheel" cx="14" cy="29.5" r="3.1"></circle>
<circle class="photo-wheel" cx="29" cy="29.5" r="3.1"></circle>
<circle class="photo-wheel" cx="76" cy="29.5" r="3.1"></circle>
</svg> </svg>
`; `;
} }
if (typeClass === "type-container") { if (typeClass === "type-container") {
return ` return `
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true"> <svg class="truck-marker__photo truck-marker__photo--container" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
<rect class="icon-load" x="3" y="6" width="24" height="9" rx="1.5"></rect> <ellipse class="photo-shadow" cx="45" cy="31.5" rx="41" ry="3.8"></ellipse>
<path class="icon-cab" d="M29 9h5l4 4v4h-9z"></path> <rect class="photo-container" x="5" y="10" width="59" height="17" rx="2"></rect>
<path class="icon-line" d="M8 7v7M13 7v7M18 7v7M23 7v7"></path> <path class="photo-ribs" d="M12 11.5v14M19 11.5v14M26 11.5v14M33 11.5v14M40 11.5v14M47 11.5v14M54 11.5v14"></path>
<circle class="icon-wheel" cx="10" cy="17" r="2.2"></circle> <path class="photo-coupler" d="M64 23h7"></path>
<circle class="icon-wheel" cx="24" cy="17" r="2.2"></circle> <path class="photo-cab" d="M70 17h9.5l8.5 7.7V30H70z"></path>
<circle class="icon-wheel" cx="35" cy="17" r="2.2"></circle> <path class="photo-window" d="M75 19.5h4.2l4.8 4.3h-9z"></path>
<path class="photo-light" d="M86.7 26h2.8v1.8h-2.8z"></path>
<circle class="photo-wheel" cx="17" cy="29.5" r="3.1"></circle>
<circle class="photo-wheel" cx="52" cy="29.5" r="3.1"></circle>
<circle class="photo-wheel" cx="76" cy="29.5" r="3.1"></circle>
</svg> </svg>
`; `;
} }
if (typeClass === "type-box") { if (typeClass === "type-box") {
return ` return `
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true"> <svg class="truck-marker__photo truck-marker__photo--box" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
<rect class="icon-load" x="6" y="4" width="20" height="12" rx="2"></rect> <ellipse class="photo-shadow" cx="43" cy="31.5" rx="38" ry="3.7"></ellipse>
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path> <rect class="photo-box" x="9" y="8" width="42" height="19" rx="2.2"></rect>
<path class="icon-line" d="M11 8h10M11 12h8"></path> <path class="photo-box-roof" d="M10 10h40"></path>
<circle class="icon-wheel" cx="12" cy="17" r="2.2"></circle> <path class="photo-cab" d="M55 16h12l8.5 7.7V30H55z"></path>
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle> <path class="photo-window" d="M60.3 18.8h5.2l5 4.8H59z"></path>
<path class="photo-door" d="M55 24h19"></path>
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
<circle class="photo-wheel" cx="64" cy="29.5" r="3.1"></circle>
</svg> </svg>
`; `;
} }
if (typeClass === "type-van") { if (typeClass === "type-van") {
return ` return `
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true"> <svg class="truck-marker__photo truck-marker__photo--van" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
<path class="icon-load" d="M6 16v-4c1-5 5-8 10-8h9c5 0 8 4 9 9l3 2v2H6z"></path> <ellipse class="photo-shadow" cx="42" cy="31.5" rx="36" ry="3.6"></ellipse>
<path class="icon-window" d="M15 6h7v6H11c1-3 2-5 4-6zM24 6h3c3 1 5 3 6 6h-9z"></path> <path class="photo-bed" d="M7 17h34v10H7z"></path>
<circle class="icon-wheel" cx="14" cy="17" r="2.2"></circle> <path class="photo-rail" d="M9 14.5v12M39 14.5v12M9 18h30"></path>
<circle class="icon-wheel" cx="30" cy="17" r="2.2"></circle> <path class="photo-cab photo-cabover" d="M44 12h16.5c5 0 8.8 3.8 8.8 8.8V30H44z"></path>
<path class="photo-window" d="M50 15h9.3l5.3 6.2H50z"></path>
<path class="photo-light" d="M67.2 24.5h2.8v1.8h-2.8z"></path>
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
<circle class="photo-wheel" cx="56" cy="29.5" r="3.1"></circle>
</svg> </svg>
`; `;
} }
if (typeClass === "type-cold") { if (typeClass === "type-cold") {
return ` return `
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true"> <svg class="truck-marker__photo truck-marker__photo--cold" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
<rect class="icon-load" x="6" y="5" width="20" height="11" rx="2"></rect> <ellipse class="photo-shadow" cx="43" cy="31.5" rx="38" ry="3.7"></ellipse>
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path> <rect class="photo-box" x="9" y="8" width="42" height="19" rx="2.2"></rect>
<path class="icon-snow" d="M16 7v7M12 9l8 4M20 9l-8 4"></path> <rect class="photo-reefer" x="43" y="10.5" width="6" height="7" rx="1"></rect>
<circle class="icon-wheel" cx="12" cy="17" r="2.2"></circle> <path class="photo-snow" d="M28 13v8M24.5 15l7 4M31.5 15l-7 4M25.5 12.5 28 15l2.5-2.5M25.5 21.5 28 19l2.5 2.5"></path>
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle> <path class="photo-cab" d="M55 16h12l8.5 7.7V30H55z"></path>
<path class="photo-window" d="M60.3 18.8h5.2l5 4.8H59z"></path>
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
<circle class="photo-wheel" cx="64" cy="29.5" r="3.1"></circle>
</svg> </svg>
`; `;
} }
if (typeClass === "type-wing") { if (typeClass === "type-wing") {
return ` return `
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true"> <svg class="truck-marker__photo truck-marker__photo--wing" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
<path class="icon-wing" d="M8 6h21l-6 8H4z"></path> <ellipse class="photo-shadow" cx="43" cy="31.5" rx="38" ry="3.7"></ellipse>
<rect class="icon-load" x="8" y="10" width="18" height="6" rx="2"></rect> <rect class="photo-box" x="9" y="8" width="42" height="19" rx="2.2"></rect>
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path> <path class="photo-wing" d="M11 10h38L39 24H4z"></path>
<circle class="icon-wheel" cx="13" cy="17" r="2.2"></circle> <path class="photo-wing-line" d="M13 12 8 23M47 12 37 24"></path>
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle> <path class="photo-cab" d="M55 16h12l8.5 7.7V30H55z"></path>
<path class="photo-window" d="M60.3 18.8h5.2l5 4.8H59z"></path>
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
<circle class="photo-wheel" cx="64" cy="29.5" r="3.1"></circle>
</svg> </svg>
`; `;
} }
return ` return `
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true"> <svg class="truck-marker__photo truck-marker__photo--cargo" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
<rect class="icon-load" x="6" y="7" width="20" height="9" rx="2"></rect> <ellipse class="photo-shadow" cx="42" cy="31.5" rx="37" ry="3.6"></ellipse>
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path> <path class="photo-bed" d="M7 15.5h39v11.5H7z"></path>
<path class="icon-line" d="M11 10h10M11 13h8"></path> <path class="photo-rail" d="M9 13.5v13M44 13.5v13M9 18h35M9 24h35"></path>
<circle class="icon-wheel" cx="12" cy="17" r="2.2"></circle> <path class="photo-cab" d="M50 15h13l8.7 8V30H50z"></path>
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle> <path class="photo-window" d="M55.5 17.5h5.5l5.2 5.2H54.7z"></path>
<path class="photo-light" d="M69.5 24.8h2.8v1.8h-2.8z"></path>
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
<circle class="photo-wheel" cx="58" cy="29.5" r="3.1"></circle>
</svg> </svg>
`; `;
} }
function resolveMarkerHeading(location, fromPosition, targetPosition) { function resolveMarkerHeading(location, fromPosition, targetPosition) {
const routeHeading = getRouteStartHeading(location); const routeHeading = getRouteForwardHeading(location, targetPosition);
if (routeHeading !== null) return routeHeading; if (routeHeading !== null) return routeHeading;
if (fromPosition && targetPosition && calculateDistanceKm(fromPosition, targetPosition) >= 0.003) { if (fromPosition && targetPosition && calculateDistanceKm(fromPosition, targetPosition) >= 0.003) {
@@ -1226,10 +1496,41 @@ function resolveMarkerHeading(location, fromPosition, targetPosition) {
return getLocationHeading(location); return getLocationHeading(location);
} }
function getRouteStartHeading(location) { function getRouteForwardHeading(location, currentPosition) {
const routePath = getRoutePath(location); const routePath = getRoutePath(location);
if (routePath.length < 2) return null; if (routePath.length < 2) return null;
const current = currentPosition || normalizeDisplayRoutePoint({
latitude: location.latitude,
longitude: location.longitude
});
if (!current) return getRouteStartHeading(routePath);
let nearestIndex = 0;
let nearestDistance = Infinity;
for (let index = 0; index < routePath.length; index += 1) {
const distance = calculateDistanceKm(current, routePath[index]);
if (distance < nearestDistance) {
nearestIndex = index;
nearestDistance = distance;
}
}
for (let index = nearestIndex + 1; index < routePath.length; index += 1) {
const to = routePath[index];
if (calculateDistanceKm(current, to) >= 0.008) {
return calculateBearing(current, to);
}
}
if (nearestIndex > 0) {
return calculateBearing(routePath[nearestIndex - 1], routePath[nearestIndex]);
}
return getRouteStartHeading(routePath);
}
function getRouteStartHeading(routePath) {
const from = routePath[0]; const from = routePath[0];
for (let index = 1; index < routePath.length; index += 1) { for (let index = 1; index < routePath.length; index += 1) {
const to = routePath[index]; const to = routePath[index];
@@ -1248,7 +1549,7 @@ function getLocationHeading(location) {
function normalizeMarkerRotation(heading) { function normalizeMarkerRotation(heading) {
const normalizedHeading = ((Number(heading) % 360) + 360) % 360; const normalizedHeading = ((Number(heading) % 360) + 360) % 360;
return Math.round(normalizedHeading - 90); return Math.round(normalizedHeading);
} }
function animateMarkerPosition(marker, targetPosition) { function animateMarkerPosition(marker, targetPosition) {
@@ -1401,7 +1702,7 @@ function renderRoutePreview(location, options = {}) {
state.routeOverlay = new kakao.maps.Polyline({ state.routeOverlay = new kakao.maps.Polyline({
path, path,
strokeWeight: 5, strokeWeight: 5,
strokeColor: "#1f6feb", strokeColor: "#0b8f9c",
strokeOpacity: 0.86, strokeOpacity: 0.86,
strokeStyle: "solid" strokeStyle: "solid"
}); });
@@ -1418,7 +1719,7 @@ function renderRoutePreview(location, options = {}) {
if (state.mapProvider === "leaflet") { if (state.mapProvider === "leaflet") {
const path = routePath.map((point) => [point.latitude, point.longitude]); const path = routePath.map((point) => [point.latitude, point.longitude]);
state.routeOverlay = L.polyline(path, { state.routeOverlay = L.polyline(path, {
color: "#1f6feb", color: "#0b8f9c",
weight: 5, weight: 5,
opacity: 0.86, opacity: 0.86,
lineCap: "round", lineCap: "round",
@@ -1551,12 +1852,14 @@ function openMarkerActionPanel(location) {
renderRouteFocusPanel(null); renderRouteFocusPanel(null);
renderMarkerActionPanel(location); renderMarkerActionPanel(location);
elements.markerActionPanel.classList.remove("is-hidden"); elements.markerActionPanel.classList.remove("is-hidden");
syncMapOverlayState();
render(); render();
updateMarkerSelectionClasses(); updateMarkerSelectionClasses();
} }
function hideMarkerActionPanel() { function hideMarkerActionPanel() {
elements.markerActionPanel.classList.add("is-hidden"); elements.markerActionPanel.classList.add("is-hidden");
syncMapOverlayState();
} }
function closeMarkerActionPanel() { function closeMarkerActionPanel() {
@@ -1689,6 +1992,7 @@ function setRouteFocusContent(eyebrow, title, meta) {
elements.routeFocusPanel.classList.add("is-active"); elements.routeFocusPanel.classList.add("is-active");
elements.routeFocusTitle.textContent = title; elements.routeFocusTitle.textContent = title;
elements.routeFocusMeta.textContent = meta; elements.routeFocusMeta.textContent = meta;
syncMapOverlayState();
} }
function hideRouteFocusPanel() { function hideRouteFocusPanel() {
@@ -1696,6 +2000,7 @@ function hideRouteFocusPanel() {
setRouteFocusEyebrow("차량 정보"); setRouteFocusEyebrow("차량 정보");
elements.routeFocusTitle.textContent = ""; elements.routeFocusTitle.textContent = "";
elements.routeFocusMeta.textContent = ""; elements.routeFocusMeta.textContent = "";
syncMapOverlayState();
} }
function renderCurrentLocationPanel(location) { function renderCurrentLocationPanel(location) {
@@ -1726,6 +2031,20 @@ function renderRouteFocusPanel(location) {
elements.routeFocusPanel.classList.add("is-active"); elements.routeFocusPanel.classList.add("is-active");
elements.routeFocusTitle.textContent = `${location.vehicleNo || location.driverId} · ${vehicleType.label}`; elements.routeFocusTitle.textContent = `${location.vehicleNo || location.driverId} · ${vehicleType.label}`;
elements.routeFocusMeta.textContent = `${cargo} · ${destination} · ${progress}`; elements.routeFocusMeta.textContent = `${cargo} · ${destination} · ${progress}`;
syncMapOverlayState();
}
function syncMapOverlayState() {
if (!elements.mapPanel) return;
elements.mapPanel.classList.toggle(
"has-route-focus",
elements.routeFocusPanel.classList.contains("is-active")
);
elements.mapPanel.classList.toggle(
"has-marker-actions",
!elements.markerActionPanel.classList.contains("is-hidden")
);
} }
function focusLocation(location) { function focusLocation(location) {

View File

@@ -205,6 +205,17 @@
<section class="map-panel" aria-label="지도"> <section class="map-panel" aria-label="지도">
<div id="map"></div> <div id="map"></div>
<div id="mapMessage" class="map-message">지도 로딩 중</div> <div id="mapMessage" class="map-message">지도 로딩 중</div>
<div class="map-control-deck" aria-label="지도 조작">
<div class="map-layer-toggle" role="group" aria-label="지도 유형">
<button id="mapRoadButton" class="is-active" type="button" data-map-layer="road" aria-pressed="true">지도</button>
<button id="mapSkyButton" type="button" data-map-layer="sky" aria-pressed="false">스카이뷰</button>
</div>
<div class="map-zoom-stack" role="group" aria-label="확대 축소">
<button id="mapZoomInButton" type="button" data-map-zoom="in" aria-label="확대">+</button>
<button id="mapZoomOutButton" type="button" data-map-zoom="out" aria-label="축소">-</button>
</div>
<button id="mapExpandButton" class="map-expand-button" type="button" aria-pressed="false">지도 크게</button>
</div>
<section class="map-status-panel" aria-label="지도 운영 상태"> <section class="map-status-panel" aria-label="지도 운영 상태">
<span class="section-eyebrow">운행 지도</span> <span class="section-eyebrow">운행 지도</span>
<strong id="mapStatusTitle">운행 현황</strong> <strong id="mapStatusTitle">운행 현황</strong>

File diff suppressed because it is too large Load Diff