From b2562e91454c19bd3da3defadf6cfc95a1a12a74 Mon Sep 17 00:00:00 2001 From: y2keui Date: Sat, 6 Jun 2026 19:32:28 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EC=A0=9C=20=EC=B2=AB=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/public/app.js | 179 ++++++++++++- apps/web/public/index.html | 88 ++++++- apps/web/public/styles.css | 505 +++++++++++++++++++++++++++++++++++-- 3 files changed, 736 insertions(+), 36 deletions(-) diff --git a/apps/web/public/app.js b/apps/web/public/app.js index 9a8feb4..37917d4 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -9,6 +9,8 @@ const state = { drivers: new Map(), locations: new Map(), selectedDriverId: "", + vehicleSearch: "", + vehicleStatusFilter: "all", eventSource: null, activeView: "locations", adminToken: new URLSearchParams(window.location.search).get("adminToken") || @@ -18,6 +20,7 @@ const state = { const elements = { connectionStatus: document.getElementById("connectionStatus"), + systemClock: document.getElementById("systemClock"), refreshButton: document.getElementById("refreshButton"), demoButton: document.getElementById("demoButton"), locationsTab: document.getElementById("locationsTab"), @@ -30,6 +33,19 @@ const elements = { activeCount: document.getElementById("activeCount"), staleCount: document.getElementById("staleCount"), lastUpdated: document.getElementById("lastUpdated"), + opsHeadline: document.getElementById("opsHeadline"), + opsSubline: document.getElementById("opsSubline"), + averageEta: document.getElementById("averageEta"), + cargoTypeCount: document.getElementById("cargoTypeCount"), + routePreviewCount: document.getElementById("routePreviewCount"), + vehicleSearchInput: document.getElementById("vehicleSearchInput"), + statusFilterButtons: [...document.querySelectorAll("[data-status-filter]")], + mapActiveCount: document.getElementById("mapActiveCount"), + mapEtaSummary: document.getElementById("mapEtaSummary"), + mapUpdatedAt: document.getElementById("mapUpdatedAt"), + routeFocusPanel: document.getElementById("routeFocusPanel"), + routeFocusTitle: document.getElementById("routeFocusTitle"), + routeFocusMeta: document.getElementById("routeFocusMeta"), driverForm: document.getElementById("driverForm"), driverNameInput: document.getElementById("driverNameInput"), vehicleNoInput: document.getElementById("vehicleNoInput"), @@ -84,6 +100,7 @@ const VEHICLE_TYPES = { }; document.addEventListener("DOMContentLoaded", () => { + startSystemClock(); initializeMap(); bindActions(); refreshDrivers(); @@ -221,6 +238,30 @@ function bindActions() { elements.driversTab.addEventListener("click", () => setActiveView("drivers")); elements.driverForm.addEventListener("submit", submitDriverForm); elements.historyCloseButton.addEventListener("click", closeHistoryDrawer); + elements.vehicleSearchInput.addEventListener("input", () => { + state.vehicleSearch = elements.vehicleSearchInput.value.trim(); + render(); + }); + for (const button of elements.statusFilterButtons) { + button.addEventListener("click", () => { + state.vehicleStatusFilter = button.dataset.statusFilter || "all"; + render(); + }); + } +} + +function startSystemClock() { + const updateClock = () => { + const now = new Date(); + elements.systemClock.dateTime = now.toISOString(); + elements.systemClock.textContent = new Intl.DateTimeFormat("ko-KR", { + hour: "2-digit", + minute: "2-digit" + }).format(now); + }; + + updateClock(); + window.setInterval(updateClock, 30000); } function setActiveView(view) { @@ -343,8 +384,10 @@ function replaceLocations(locations) { if (selectedLocation) { renderRoutePreview(selectedLocation, { fit: false }); showInfoOverlay(selectedLocation); + renderRouteFocusPanel(selectedLocation); } else { clearRouteOverlay(); + renderRouteFocusPanel(null); } } } @@ -366,6 +409,7 @@ function upsertLocation(location, options = {}) { loadLocationHistory(location); renderRoutePreview(location, { fit: false }); showInfoOverlay(location); + renderRouteFocusPanel(location); } if (options.focus) { @@ -392,23 +436,89 @@ function render() { elements.totalCount.textContent = String(counts.total); elements.activeCount.textContent = String(counts.active); elements.staleCount.textContent = String(counts.stale); - elements.lastUpdated.textContent = locations[0] ? formatDateTime(locations[0].receivedAt) : "-"; + elements.lastUpdated.textContent = locations[0] ? `${locations.length}대 표시 · ${formatDateTime(locations[0].receivedAt)}` : "수신 대기"; + renderOperationsSummary(locations, counts); + renderMapStatus(locations, counts); + syncStatusFilterButtons(); elements.vehicleList.replaceChildren(); - if (!locations.length) { + const visibleLocations = locations.filter(matchesVehicleFilters); + + if (!visibleLocations.length) { const empty = document.createElement("div"); empty.className = "empty-state"; - empty.textContent = "수신된 차량 위치가 없습니다."; + empty.textContent = locations.length ? "조건에 맞는 차량이 없습니다." : "수신된 차량 위치가 없습니다."; elements.vehicleList.append(empty); return; } - for (const location of locations) { + for (const location of visibleLocations) { elements.vehicleList.append(createVehicleItem(location)); } } +function renderOperationsSummary(locations, counts) { + const activeText = counts.active > 0 ? `${counts.active}대 운행중` : "운행 대기"; + const staleText = counts.stale > 0 ? `주의 ${counts.stale}대 확인 필요` : "모든 차량 정상 수신"; + const averageEtaMinutes = calculateAverageEtaMinutes(locations); + const cargoTypes = new Set(locations.map(getCargoName).filter(Boolean)); + const routeCount = locations.filter((location) => getRoutePath(location).length >= 2).length; + + elements.opsHeadline.textContent = `전국 ${activeText}`; + elements.opsSubline.textContent = locations.length + ? `${staleText} · ${locations.length}대 실시간 관제` + : "시뮬레이터 또는 기사 앱의 첫 위치를 기다리고 있습니다."; + elements.averageEta.textContent = averageEtaMinutes === null ? "-" : formatDuration(averageEtaMinutes).replace("약 ", ""); + elements.cargoTypeCount.textContent = cargoTypes.size ? `${cargoTypes.size}종` : "-"; + elements.routePreviewCount.textContent = routeCount ? `${routeCount}건` : "-"; +} + +function renderMapStatus(locations, counts) { + const latestLocation = locations[0]; + const averageEtaMinutes = calculateAverageEtaMinutes(locations); + + elements.mapActiveCount.textContent = String(counts.active); + elements.mapEtaSummary.textContent = averageEtaMinutes === null ? "-" : formatDuration(averageEtaMinutes).replace("약 ", ""); + elements.mapUpdatedAt.textContent = latestLocation ? formatTime(latestLocation.receivedAt) : "-"; +} + +function syncStatusFilterButtons() { + for (const button of elements.statusFilterButtons) { + button.classList.toggle("is-active", button.dataset.statusFilter === state.vehicleStatusFilter); + } +} + +function matchesVehicleFilters(location) { + const status = getLocationStatus(location); + if (state.vehicleStatusFilter === "active" && status.key !== "active") return false; + if (state.vehicleStatusFilter === "stale" && status.key === "active") return false; + + if (!state.vehicleSearch) return true; + + const vehicleType = getVehicleType(location); + const haystack = [ + location.vehicleNo, + location.driverName, + location.driverId, + vehicleType.label, + getCargoSummary(location), + getRouteSummary(location), + getRouteProgressSummary(location) + ].join(" ").toLowerCase(); + + return haystack.includes(state.vehicleSearch.toLowerCase()); +} + +function calculateAverageEtaMinutes(locations) { + const etaValues = locations + .map((location) => getRouteMetrics(location)?.etaMinutes) + .filter((value) => Number.isFinite(value)); + + if (!etaValues.length) return null; + return Math.round(etaValues.reduce((total, value) => total + value, 0) / etaValues.length); +} + function renderDrivers() { const drivers = [...state.drivers.values()].sort((left, right) => { return String(left.vehicleNo || left.driverId).localeCompare(String(right.vehicleNo || right.driverId), "ko"); @@ -435,6 +545,7 @@ function createVehicleItem(location) { const vehicleType = getVehicleType(location); const item = document.createElement("article"); item.className = "vehicle-item"; + item.classList.toggle("is-selected", state.selectedDriverId === location.driverId); item.tabIndex = 0; const main = document.createElement("div"); @@ -460,17 +571,34 @@ function createVehicleItem(location) { meta.className = "vehicle-meta"; meta.textContent = [ location.driverName || location.driverId, - getCargoSummary(location), - getRouteSummary(location), - getRouteProgressSummary(location), formatDateTime(location.receivedAt) ].filter(Boolean).join(" · "); + const routeLine = document.createElement("div"); + routeLine.className = "vehicle-route-line"; + routeLine.textContent = getRouteSummary(location); + + const cargoLine = document.createElement("div"); + cargoLine.className = "vehicle-cargo-line"; + cargoLine.textContent = getCargoSummary(location); + + const progress = document.createElement("div"); + progress.className = "vehicle-progress"; + const progressBar = document.createElement("span"); + progressBar.style.width = `${getRouteProgressPercent(location)}%`; + progress.append(progressBar); + const speed = document.createElement("div"); speed.className = "speed"; - speed.textContent = location.speed === null || location.speed === undefined ? "-" : `${Math.round(location.speed)} km/h`; + const speedValue = document.createElement("strong"); + speedValue.textContent = location.speed === null || location.speed === undefined ? "-" : `${Math.round(location.speed)}`; + const speedUnit = document.createElement("span"); + speedUnit.textContent = "km/h"; + const etaValue = document.createElement("small"); + etaValue.textContent = getRouteProgressSummary(location) || "ETA 대기"; + speed.append(speedValue, speedUnit, etaValue); - main.append(title, meta); + main.append(title, meta, routeLine, cargoLine, progress); item.append(main, speed); item.addEventListener("click", () => focusLocation(location)); @@ -979,8 +1107,31 @@ function createDestinationMarkerHtml(location) { `; } +function renderRouteFocusPanel(location) { + if (!location) { + elements.routeFocusPanel.classList.remove("is-active"); + elements.routeFocusTitle.textContent = "차량을 선택하세요"; + elements.routeFocusMeta.textContent = "목적지 경로와 예상 도착 시간이 지도 위에 표시됩니다."; + return; + } + + const metrics = getRouteMetrics(location); + const vehicleType = getVehicleType(location); + const cargo = getCargoSummary(location); + const destination = getDestinationName(location) || "목적지 미등록"; + const progress = metrics + ? `${formatDistance(metrics.remainingDistanceKm)} 남음 · ${formatDuration(metrics.etaMinutes)} · ${formatEtaTime(metrics.estimatedArrivalAt)} 도착` + : "경로 데이터 대기"; + + elements.routeFocusPanel.classList.add("is-active"); + elements.routeFocusTitle.textContent = `${location.vehicleNo || location.driverId} · ${vehicleType.label}`; + elements.routeFocusMeta.textContent = `${cargo} · ${destination} · ${progress}`; +} + function focusLocation(location) { loadLocationHistory(location); + renderRouteFocusPanel(location); + render(); if (!state.map || !state.mapProvider) return; @@ -1043,6 +1194,8 @@ function closeHistoryDrawer() { state.selectedDriverId = ""; elements.historyDrawer.classList.add("is-hidden"); clearRouteOverlay(); + renderRouteFocusPanel(null); + render(); } function showInfoOverlay(location) { @@ -1270,6 +1423,14 @@ function getRouteProgressSummary(location) { return `남은 ${formatDistance(metrics.remainingDistanceKm)} · ETA ${formatEtaTime(metrics.estimatedArrivalAt)}`; } +function getRouteProgressPercent(location) { + const metrics = getRouteMetrics(location); + if (!metrics) return 14; + + const urgency = 100 - Math.min(96, Math.max(0, metrics.etaMinutes / 180 * 100)); + return Math.max(12, Math.min(94, Math.round(urgency))); +} + function getRouteMetrics(location) { const routePath = getRoutePath(location); if (routePath.length < 2) return null; diff --git a/apps/web/public/index.html b/apps/web/public/index.html index fdf5360..bbff03a 100644 --- a/apps/web/public/index.html +++ b/apps/web/public/index.html @@ -9,19 +9,65 @@
-
- CargoRadar - Control +
+ +
+ CargoRadar + Live freight control +
+
+
+ Demo control tower +
연결 대기 - - + +