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 `
-
-