From 1bf346a89857254ae3a0bcc42e7e946c88a00204 Mon Sep 17 00:00:00 2001 From: y2keui Date: Sat, 6 Jun 2026 22:06:16 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=88=EC=BB=A4=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=8C=A8=EB=84=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/app.js | 249 +++++++++++++++++++++++++++++++++++-- apps/web/public/index.html | 17 +++ apps/web/public/styles.css | 143 +++++++++++++++++++++ 3 files changed, 399 insertions(+), 10 deletions(-) diff --git a/apps/web/public/app.js b/apps/web/public/app.js index a6e5294..e9893bf 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -14,6 +14,7 @@ const state = { drivers: new Map(), locations: new Map(), selectedDriverId: "", + activeDetailMode: "", vehicleSearch: "", vehicleStatusFilter: "all", eventSource: null, @@ -51,6 +52,12 @@ const elements = { routeFocusPanel: document.getElementById("routeFocusPanel"), routeFocusTitle: document.getElementById("routeFocusTitle"), routeFocusMeta: document.getElementById("routeFocusMeta"), + markerActionPanel: document.getElementById("markerActionPanel"), + markerActionEyebrow: document.getElementById("markerActionEyebrow"), + markerActionTitle: document.getElementById("markerActionTitle"), + markerActionMeta: document.getElementById("markerActionMeta"), + markerActionSummary: document.getElementById("markerActionSummary"), + markerActionCloseButton: document.getElementById("markerActionCloseButton"), driverForm: document.getElementById("driverForm"), driverNameInput: document.getElementById("driverNameInput"), vehicleNoInput: document.getElementById("vehicleNoInput"), @@ -245,6 +252,19 @@ function bindActions() { elements.driversTab.addEventListener("click", () => setActiveView("drivers")); elements.driverForm.addEventListener("submit", submitDriverForm); elements.historyCloseButton.addEventListener("click", closeHistoryDrawer); + elements.markerActionCloseButton.addEventListener("click", closeMarkerActionPanel); + elements.markerActionPanel.addEventListener("click", (event) => { + const actionButton = event.target.closest("[data-marker-action]"); + if (!actionButton) return; + + const location = state.locations.get(state.selectedDriverId); + if (!location) { + closeMarkerActionPanel(); + return; + } + + runMarkerAction(actionButton.dataset.markerAction, location); + }); elements.vehicleSearchInput.addEventListener("input", () => { state.vehicleSearch = elements.vehicleSearchInput.value.trim(); render(); @@ -390,10 +410,9 @@ function replaceLocations(locations) { if (state.selectedDriverId) { const selectedLocation = state.locations.get(state.selectedDriverId); if (selectedLocation) { - renderRoutePreview(selectedLocation, { fit: false }); - showInfoOverlay(selectedLocation); - renderRouteFocusPanel(selectedLocation); + refreshSelectedDetail(selectedLocation); } else { + closeMarkerActionPanel(); clearRouteOverlay(); clearInfoOverlay(); renderRouteFocusPanel(null); @@ -442,10 +461,8 @@ function applyLocationUpdates(locations) { ? state.locations.get(state.selectedDriverId) : null; - if (selectedLocation && !elements.historyDrawer.classList.contains("is-hidden")) { - renderRoutePreview(selectedLocation, { fit: false }); - showInfoOverlay(selectedLocation); - renderRouteFocusPanel(selectedLocation); + if (selectedLocation) { + refreshSelectedDetail(selectedLocation); } } @@ -905,13 +922,13 @@ function updateLeafletMarker(location) { marker: L.marker(leafletPosition, { icon, title: location.vehicleNo || location.driverId }), currentPosition: targetPosition }; - marker.marker.on("click", () => focusLocation(location)); + marker.marker.on("click", () => openMarkerActionPanel(location)); marker.marker.addTo(state.map); state.markers.set(location.driverId, marker); } else { marker.marker.setIcon(icon); marker.marker.off("click"); - marker.marker.on("click", () => focusLocation(location)); + marker.marker.on("click", () => openMarkerActionPanel(location)); animateMarkerPosition(marker, targetPosition); } } @@ -924,7 +941,10 @@ function createTruckMarkerElement(location, status, heading) { markerElement.title = `${vehicleType.label} ${location.vehicleNo || location.driverId}`; markerElement.setAttribute("aria-label", `${vehicleType.label} ${location.vehicleNo || location.driverId} 위치 보기`); markerElement.innerHTML = createTruckMarkerHtml(location, vehicleType, heading); - markerElement.addEventListener("click", () => focusLocation(location)); + markerElement.addEventListener("click", (event) => { + event.stopPropagation(); + openMarkerActionPanel(location); + }); return markerElement; } @@ -1117,6 +1137,8 @@ function centerMapOnLocation(location, options = {}) { } function renderRoutePreview(location, options = {}) { + if (!state.map || !state.mapProvider) return false; + clearRouteOverlay(); const routePath = getRoutePath(location); @@ -1236,7 +1258,206 @@ function createDestinationMarkerHtml(location) { `; } +function refreshSelectedDetail(location) { + if (!elements.markerActionPanel.classList.contains("is-hidden")) { + renderMarkerActionPanel(location); + } + + if (state.activeDetailMode === "route") { + renderRouteFocusPanel(location); + renderRoutePreview(location, { fit: false }); + return; + } + + if (state.activeDetailMode === "cargo") { + renderCargoFocusPanel(location); + showInfoOverlay(location); + return; + } + + if (state.activeDetailMode === "history") { + renderRouteFocusPanel(location); + renderRoutePreview(location, { fit: false }); + if (state.infoOverlay) { + showInfoOverlay(location); + } + return; + } + + if (state.activeDetailMode === "center") { + renderCurrentLocationPanel(location); + showInfoOverlay(location); + } +} + +function openMarkerActionPanel(location) { + state.selectedDriverId = location.driverId; + state.activeDetailMode = "menu"; + elements.historyDrawer.classList.add("is-hidden"); + clearRouteOverlay(); + clearInfoOverlay(); + renderRouteFocusPanel(null); + renderMarkerActionPanel(location); + elements.markerActionPanel.classList.remove("is-hidden"); + render(); +} + +function hideMarkerActionPanel() { + elements.markerActionPanel.classList.add("is-hidden"); +} + +function closeMarkerActionPanel() { + hideMarkerActionPanel(); + if (state.activeDetailMode === "menu") { + state.activeDetailMode = ""; + } + state.selectedDriverId = ""; + render(); +} + +function renderMarkerActionPanel(location) { + const status = getLocationStatus(location); + const vehicleType = getVehicleType(location); + const metrics = getRouteMetrics(location); + const driver = location.driverName || location.driverId; + const title = location.vehicleNo || location.driverId; + const distance = metrics ? formatDistance(metrics.remainingDistanceKm) : "-"; + const eta = metrics ? formatEtaTime(metrics.estimatedArrivalAt) : "-"; + + elements.markerActionEyebrow.textContent = "차량 선택"; + elements.markerActionTitle.textContent = `${title} · ${vehicleType.label}`; + elements.markerActionMeta.textContent = [driver, status.label, formatSpeed(location.speed)].filter(Boolean).join(" · "); + elements.markerActionSummary.replaceChildren( + createMarkerActionSummaryItem("화물", getCargoSummary(location)), + createMarkerActionSummaryItem("목적지", getRouteSummary(location)), + createMarkerActionSummaryItem("남은 거리", distance), + createMarkerActionSummaryItem("도착 예정", eta) + ); +} + +function createMarkerActionSummaryItem(label, value) { + const item = document.createElement("span"); + const labelElement = document.createElement("b"); + const valueElement = document.createElement("em"); + labelElement.textContent = label; + valueElement.textContent = value || "-"; + item.append(labelElement, valueElement); + return item; +} + +function runMarkerAction(action, location) { + if (action === "route") { + showVehicleRoute(location); + return; + } + + if (action === "cargo") { + showVehicleCargo(location); + return; + } + + if (action === "history") { + showVehicleHistory(location); + return; + } + + if (action === "center") { + showVehicleCenter(location); + } +} + +function showVehicleRoute(location) { + state.selectedDriverId = location.driverId; + state.activeDetailMode = "route"; + hideMarkerActionPanel(); + elements.historyDrawer.classList.add("is-hidden"); + clearInfoOverlay(); + renderRouteFocusPanel(location); + render(); + + if (!state.map || !state.mapProvider) return; + + if (!renderRoutePreview(location, { fit: true })) { + centerMapOnLocation(location, { zoom: true }); + } +} + +function showVehicleCargo(location) { + state.selectedDriverId = location.driverId; + state.activeDetailMode = "cargo"; + hideMarkerActionPanel(); + elements.historyDrawer.classList.add("is-hidden"); + clearRouteOverlay(); + renderCargoFocusPanel(location); + render(); + showInfoOverlay(location); +} + +function showVehicleHistory(location) { + state.selectedDriverId = location.driverId; + state.activeDetailMode = "history"; + hideMarkerActionPanel(); + clearInfoOverlay(); + renderRouteFocusPanel(location); + render(); + renderRoutePreview(location, { fit: false }); + loadLocationHistory(location); +} + +function showVehicleCenter(location) { + state.selectedDriverId = location.driverId; + state.activeDetailMode = "center"; + hideMarkerActionPanel(); + elements.historyDrawer.classList.add("is-hidden"); + clearRouteOverlay(); + renderCurrentLocationPanel(location); + render(); + + if (state.map && state.mapProvider) { + centerMapOnLocation(location, { zoom: true }); + } + showInfoOverlay(location); +} + +function setRouteFocusEyebrow(label) { + const eyebrow = elements.routeFocusPanel.querySelector(".section-eyebrow"); + if (eyebrow) { + eyebrow.textContent = label; + } +} + +function setRouteFocusContent(eyebrow, title, meta) { + setRouteFocusEyebrow(eyebrow); + elements.routeFocusPanel.classList.add("is-active"); + elements.routeFocusTitle.textContent = title; + elements.routeFocusMeta.textContent = meta; +} + +function renderCargoFocusPanel(location) { + const vehicleType = getVehicleType(location); + const product = getCargoName(location) || "미등록"; + const quantity = getCargoQuantity(location) || "수량 미등록"; + const weight = getCargoWeight(location) || "중량 미등록"; + const route = getRouteSummary(location); + setRouteFocusContent( + "화물 정보", + `${location.vehicleNo || location.driverId} · ${vehicleType.label}`, + `${product} · ${quantity} · ${weight} · ${route}` + ); +} + +function renderCurrentLocationPanel(location) { + const vehicleType = getVehicleType(location); + const coordinate = `${formatCoordinate(location.latitude)}, ${formatCoordinate(location.longitude)}`; + setRouteFocusContent( + "현재 위치", + `${location.vehicleNo || location.driverId} · ${vehicleType.label}`, + `${formatSpeed(location.speed)} · ${coordinate} · ${formatDateTime(location.receivedAt)}` + ); +} + function renderRouteFocusPanel(location) { + setRouteFocusEyebrow("선택 경로"); if (!location) { elements.routeFocusPanel.classList.remove("is-active"); elements.routeFocusTitle.textContent = "차량 선택"; @@ -1258,6 +1479,8 @@ function renderRouteFocusPanel(location) { } function focusLocation(location) { + state.activeDetailMode = "history"; + hideMarkerActionPanel(); loadLocationHistory(location); renderRouteFocusPanel(location); render(); @@ -1272,6 +1495,8 @@ function focusLocation(location) { async function loadLocationHistory(location) { state.selectedDriverId = location.driverId; + state.activeDetailMode = "history"; + hideMarkerActionPanel(); elements.historyDrawer.classList.remove("is-hidden"); elements.historyTitle.textContent = `${location.vehicleNo || location.driverId} 위치 이력`; elements.historySummary.textContent = "조회 중"; @@ -1321,7 +1546,9 @@ function renderHistory(location, records) { function closeHistoryDrawer() { state.selectedDriverId = ""; + state.activeDetailMode = ""; elements.historyDrawer.classList.add("is-hidden"); + hideMarkerActionPanel(); clearRouteOverlay(); clearInfoOverlay(); renderRouteFocusPanel(null); @@ -1329,6 +1556,8 @@ function closeHistoryDrawer() { } function showInfoOverlay(location) { + if (!state.map || !state.mapProvider) return; + if (shouldSuppressMapPopup()) { clearInfoOverlay(); return; diff --git a/apps/web/public/index.html b/apps/web/public/index.html index 54c63d9..552e9ba 100644 --- a/apps/web/public/index.html +++ b/apps/web/public/index.html @@ -166,6 +166,23 @@ 차량 선택

선택하면 경로와 도착 예정 시간이 표시됩니다.

+
수신 중 주의 diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css index a694005..a022ecf 100644 --- a/apps/web/public/styles.css +++ b/apps/web/public/styles.css @@ -819,6 +819,127 @@ button:hover { word-break: keep-all; } +.marker-action-panel { + position: absolute; + right: 18px; + bottom: 104px; + z-index: 6; + display: grid; + gap: 12px; + width: min(380px, calc(100% - 36px)); + padding: 14px; + border: 1px solid rgba(22, 34, 49, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.97); + box-shadow: var(--shadow-strong); + backdrop-filter: blur(12px); +} + +.marker-action-panel.is-hidden { + display: none; +} + +.marker-action-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; +} + +.marker-action-head strong { + display: block; + min-width: 0; + overflow: hidden; + color: var(--navy); + font-size: 17px; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.marker-action-head p { + margin: 5px 0 0; + color: var(--muted); + font-size: 12px; + font-weight: 800; + line-height: 1.35; + word-break: keep-all; +} + +.marker-action-head button { + min-width: 52px; + min-height: 32px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: #f6f8fb; + color: var(--muted); + font-size: 12px; + font-weight: 900; + cursor: pointer; +} + +.marker-action-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.marker-action-summary span { + min-width: 0; + padding: 8px; + border-radius: 7px; + background: #f4f7fa; +} + +.marker-action-summary b, +.marker-action-summary em { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.marker-action-summary b { + color: var(--muted); + font-size: 10px; + font-style: normal; + font-weight: 900; +} + +.marker-action-summary em { + margin-top: 3px; + color: var(--text); + font-size: 12px; + font-style: normal; + font-weight: 900; +} + +.marker-action-buttons { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.marker-action-buttons button { + min-width: 0; + min-height: 38px; + padding: 0 10px; + border: 1px solid rgba(31, 111, 235, 0.18); + border-radius: 7px; + background: #eef5ff; + color: #174ea6; + font-size: 13px; + font-weight: 950; + cursor: pointer; +} + +.marker-action-buttons button:hover, +.marker-action-head button:hover { + filter: brightness(0.98); +} + .map-legend { top: 18px; right: 86px; @@ -1289,6 +1410,12 @@ button:hover { width: calc(100% - 20px); } + .marker-action-panel { + right: 10px; + bottom: 118px; + width: calc(100% - 20px); + } + .driver-actions { grid-template-columns: 1fr; } @@ -1342,6 +1469,22 @@ button:hover { overflow: auto; } + .marker-action-panel { + top: 10px; + bottom: auto; + gap: 10px; + padding: 12px; + } + + .marker-action-head strong { + font-size: 15px; + } + + .marker-action-summary, + .marker-action-buttons { + grid-template-columns: 1fr; + } + .history-drawer { right: 8px; bottom: 8px;