From 94805d6fab56d1f8beb85971a44e8fbb2efd1859 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Wed, 10 Jun 2026 22:59:44 +0900 Subject: [PATCH] Improve mobile CargoRadar controls --- apps/web/public/app.js | 463 +++++- apps/web/public/index.html | 11 + apps/web/public/styles.css | 3046 +++++++++++++++++++++++++++++++----- 3 files changed, 3095 insertions(+), 425 deletions(-) diff --git a/apps/web/public/app.js b/apps/web/public/app.js index 62123c4..ec35a19 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -11,6 +11,10 @@ const state = { programmaticMapChangeTimer: 0, pendingLocationUpdates: new Map(), locationUpdateBatchTimer: 0, + mapResizeTimer: 0, + mapZoomMode: "", + mapLayer: "road", + leafletTileLayer: null, drivers: new Map(), locations: new Map(), selectedDriverId: "", @@ -83,6 +87,9 @@ const elements = { mapActiveCount: document.getElementById("mapActiveCount"), mapEtaSummary: document.getElementById("mapEtaSummary"), mapUpdatedAt: document.getElementById("mapUpdatedAt"), + mapLayerButtons: [...document.querySelectorAll("[data-map-layer]")], + mapZoomButtons: [...document.querySelectorAll("[data-map-zoom]")], + mapExpandButton: document.getElementById("mapExpandButton"), routeFocusPanel: document.getElementById("routeFocusPanel"), routeFocusTitle: document.getElementById("routeFocusTitle"), routeFocusMeta: document.getElementById("routeFocusMeta"), @@ -171,9 +178,11 @@ async function initializeMap() { }); state.mapProvider = "kakao"; - state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT); - state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT); + enableMapGestures(); + applyMapLayer(state.mapLayer); + syncMapControls(); bindMapInteractionTracking(); + updateMapZoomMode(); hideMapMessage(); updateInitialMapFit(); return; @@ -217,20 +226,166 @@ async function initializeLeafletMap() { state.mapProvider = "leaflet"; state.map = L.map("map", { attributionControl: true, - zoomControl: true + dragging: true, + touchZoom: true, + scrollWheelZoom: true, + doubleClickZoom: true, + zoomControl: false }).setView([36.45, 127.85], 7); - L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 19, - attribution: '© OpenStreetMap contributors' - }).addTo(state.map); + enableMapGestures(); + state.leafletTileLayer = createLeafletTileLayer(state.mapLayer).addTo(state.map); + syncMapControls(); bindMapInteractionTracking(); + updateMapZoomMode(); showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중"); window.setTimeout(hideMapMessage, 4500); 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 © Esri" + } + ); + } + + return L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", { + maxZoom: 19, + attribution: '© OpenStreetMap contributors © CARTO' + }); +} + +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() { if (window.L?.map) return Promise.resolve(); @@ -288,6 +443,29 @@ function bindActions() { elements.refreshButton.addEventListener("click", refreshLatestLocations); elements.demoButton.addEventListener("click", postDemoLocation); 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.driversTab.addEventListener("click", () => setActiveView("drivers")); elements.driverForm.addEventListener("submit", submitDriverForm); @@ -337,6 +515,7 @@ function submitLoginForm(event) { function showLoginScreen(options = {}) { clearLaunchSequence(); clearStoredLoginState(); + setMapExpanded(false); if (state.eventSource) { state.eventSource.close(); 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") { 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; } if (state.mapProvider === "leaflet") { state.map.on("dragstart", 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) { state.isProgrammaticMapChange = true; window.clearTimeout(state.programmaticMapChangeTimer); @@ -1043,7 +1266,8 @@ function updateKakaoMarker(location) { overlay: new kakao.maps.CustomOverlay({ position: kakaoPosition, content: markerElement, - yAnchor: 1 + xAnchor: 0.5, + yAnchor: 0.5 }), element: markerElement, currentPosition: targetPosition @@ -1052,6 +1276,7 @@ function updateKakaoMarker(location) { state.markers.set(location.driverId, marker); } else { marker.overlay.setContent(markerElement); + marker.overlay.setMap(state.map); marker.element = markerElement; animateMarkerPosition(marker, targetPosition); } @@ -1065,12 +1290,13 @@ function updateLeafletMarker(location) { const existingMarker = state.markers.get(location.driverId); const markerHeading = resolveMarkerHeading(location, existingMarker?.currentPosition, targetPosition); const selectedClass = state.selectedDriverId === location.driverId ? "is-selected" : ""; + const iconMetrics = getTruckMarkerIconMetrics(); const icon = L.divIcon({ className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className} ${selectedClass}`, html: createTruckMarkerHtml(location, vehicleType, markerHeading), - iconSize: [104, 68], - iconAnchor: [52, 64], - popupAnchor: [0, -50] + iconSize: iconMetrics.size, + iconAnchor: iconMetrics.anchor, + popupAnchor: iconMetrics.popupAnchor }); 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) { const vehicleType = getVehicleType(location); const markerElement = document.createElement("button"); @@ -1111,112 +1361,132 @@ function createTruckMarkerElement(location, status, heading) { 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); - const vehiclePictogram = createVehiclePictogramSvg(vehicleType.className); return ` -