From 2d10927bedd8932c3aaff8efbe4f119367ba79a5 Mon Sep 17 00:00:00 2001 From: y2keui Date: Sat, 6 Jun 2026 16:22:27 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EB=AC=BC=EC=B0=A8=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=EB=A7=88=EC=BB=A4=EC=99=80=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=ED=82=A4=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- .env.nas.example | 2 +- README.md | 4 + apps/server/src/server.js | 11 +- apps/web/public/app.js | 351 ++++++++++++++++++++++++++++++------- apps/web/public/styles.css | 102 +++++++++-- docs/SYNOLOGY.md | 4 +- 7 files changed, 393 insertions(+), 83 deletions(-) diff --git a/.env.example b/.env.example index c5c9993..4db1a26 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ CARGORADAR_PORT=8080 ADMIN_TOKEN= -KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec +KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 diff --git a/.env.nas.example b/.env.nas.example index 9267765..fe862fa 100644 --- a/.env.nas.example +++ b/.env.nas.example @@ -1,3 +1,3 @@ CARGORADAR_PORT=18081 ADMIN_TOKEN=change_this_admin_token -KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec +KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 diff --git a/README.md b/README.md index 48832cb..f5b97ff 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ $body = @{ accuracy = 12 speed = 42 heading = 90 + cargoName = "전자부품" + cargoWeight = "1.2t" + origin = "서울 상차지" + destination = "평택 물류센터" recordedAt = (Get-Date).ToUniversalTime().ToString("o") } | ConvertTo-Json diff --git a/apps/server/src/server.js b/apps/server/src/server.js index 9a47251..e9690e5 100644 --- a/apps/server/src/server.js +++ b/apps/server/src/server.js @@ -9,7 +9,7 @@ const PORT = Number(process.env.PORT || 8080); const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, "..", "data"); const WEB_ROOT = process.env.WEB_ROOT || path.resolve(__dirname, "..", "..", "web", "public"); const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ""; -const DEFAULT_KAKAO_JAVASCRIPT_KEY = "5e0f889314e6d4f26fb18ee63d09c1ec"; +const DEFAULT_KAKAO_JAVASCRIPT_KEY = "07f4728599ceaf4299f77c772a135423"; const DRIVERS_FILE = path.join(DATA_DIR, "drivers.json"); const LATEST_FILE = path.join(DATA_DIR, "latest-locations.json"); @@ -400,6 +400,10 @@ function createLocationRecord(body, driver) { speed: optionalNumber(body.speed), heading: optionalNumber(body.heading), batteryLevel: optionalNumber(body.batteryLevel), + cargoName: optionalString(body.cargoName || body.cargoItem || body.cargo || body.itemName, 80), + cargoWeight: optionalString(body.cargoWeight || body.weight, 40), + origin: optionalString(body.origin || body.departure, 80), + destination: optionalString(body.destination || body.arrival, 80), provider: body.provider ? String(body.provider) : "", recordedAt: normalizeDate(body.recordedAt), receivedAt: new Date().toISOString() @@ -426,6 +430,11 @@ function optionalNumber(value) { return Number.isFinite(parsed) ? parsed : null; } +function optionalString(value, maxLength) { + if (value === undefined || value === null) return ""; + return String(value).trim().slice(0, maxLength); +} + function normalizeDate(value) { if (!value) return new Date().toISOString(); const parsed = new Date(value); diff --git a/apps/web/public/app.js b/apps/web/public/app.js index 546b8ed..9236a32 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -1,5 +1,6 @@ const state = { map: null, + mapProvider: "", markers: new Map(), infoOverlay: null, drivers: new Map(), @@ -51,26 +52,31 @@ document.addEventListener("DOMContentLoaded", () => { async function initializeMap() { const kakaoJavaScriptKey = window.CARGORADAR_CONFIG?.kakaoJavaScriptKey || ""; - if (!kakaoJavaScriptKey) { - showMapMessage("Kakao JavaScript 키가 설정되지 않았습니다."); - return; + if (kakaoJavaScriptKey) { + try { + await loadKakaoMapsSdk(kakaoJavaScriptKey); + const center = new kakao.maps.LatLng(36.45, 127.85); + state.map = new kakao.maps.Map(document.getElementById("map"), { + center, + level: 13 + }); + 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); + hideMapMessage(); + updateMarkers({ fitBounds: state.locations.size > 0 }); + return; + } catch (error) { + console.warn("Kakao map unavailable. Falling back to OpenStreetMap.", error); + } } try { - await loadKakaoMapsSdk(kakaoJavaScriptKey); - const center = new kakao.maps.LatLng(36.45, 127.85); - state.map = new kakao.maps.Map(document.getElementById("map"), { - center, - level: 13 - }); - - state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT); - state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT); - hideMapMessage(); - updateMarkers({ fitBounds: state.locations.size > 0 }); + await initializeLeafletMap(); } catch (error) { console.error(error); - showMapMessage("Kakao 지도 키 또는 허용 도메인을 확인하세요."); + showMapMessage("지도 로딩에 실패했습니다. 네트워크 상태를 확인하세요."); } } @@ -84,12 +90,88 @@ function loadKakaoMapsSdk(appKey) { const script = document.createElement("script"); script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${encodeURIComponent(appKey)}&autoload=false`; script.async = true; - script.onload = () => kakao.maps.load(resolve); + script.onload = () => { + if (!window.kakao?.maps) { + reject(new Error("kakao_maps_not_authorized")); + return; + } + kakao.maps.load(resolve); + }; script.onerror = () => reject(new Error("kakao_maps_load_failed")); document.head.appendChild(script); }); } +async function initializeLeafletMap() { + await loadLeafletAssets(); + state.mapProvider = "leaflet"; + state.map = L.map("map", { + attributionControl: true, + zoomControl: true + }).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); + + showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중"); + window.setTimeout(hideMapMessage, 4500); + updateMarkers({ fitBounds: state.locations.size > 0 }); +} + +function loadLeafletAssets() { + if (window.L?.map) return Promise.resolve(); + + const version = "1.9.4"; + const stylesheet = loadStylesheet( + "leaflet-stylesheet", + `https://unpkg.com/leaflet@${version}/dist/leaflet.css` + ); + const script = loadScript( + "leaflet-script", + `https://unpkg.com/leaflet@${version}/dist/leaflet.js` + ); + + return Promise.all([stylesheet, script]).then(() => { + if (!window.L?.map) throw new Error("leaflet_load_failed"); + }); +} + +function loadStylesheet(id, href) { + return new Promise((resolve, reject) => { + if (document.getElementById(id)) { + resolve(); + return; + } + + const link = document.createElement("link"); + link.id = id; + link.rel = "stylesheet"; + link.href = href; + link.onload = resolve; + link.onerror = () => reject(new Error(`${id}_load_failed`)); + document.head.appendChild(link); + }); +} + +function loadScript(id, src) { + return new Promise((resolve, reject) => { + if (document.getElementById(id)) { + resolve(); + return; + } + + const script = document.createElement("script"); + script.id = id; + script.src = src; + script.async = true; + script.onload = resolve; + script.onerror = () => reject(new Error(`${id}_load_failed`)); + document.head.appendChild(script); + }); +} + function bindActions() { elements.refreshButton.addEventListener("click", refreshLatestLocations); elements.demoButton.addEventListener("click", postDemoLocation); @@ -180,6 +262,10 @@ async function postDemoLocation() { accuracy: 12, speed: 42, heading: Math.round((now / 1000) % 360), + cargoName: "전자부품", + cargoWeight: "1.2t", + origin: "서울 상차지", + destination: "평택 물류센터", provider: "demo", recordedAt: new Date().toISOString() }) @@ -225,8 +311,8 @@ function upsertLocation(location, options = {}) { loadLocationHistory(location); } - if (options.focus && state.map && window.kakao?.maps) { - state.map.setCenter(new kakao.maps.LatLng(location.latitude, location.longitude)); + if (options.focus) { + centerMapOnLocation(location); } } @@ -465,13 +551,13 @@ async function copyToken(token) { } function updateMarkers({ fitBounds = false } = {}) { - if (!state.map || !window.kakao?.maps) return; + if (!state.map || !state.mapProvider) return; const activeIds = new Set(state.locations.keys()); - for (const [driverId, markerOverlay] of state.markers) { + for (const [driverId, marker] of state.markers) { if (!activeIds.has(driverId)) { - markerOverlay.setMap(null); + removeMarker(marker); state.markers.delete(driverId); } } @@ -481,54 +567,149 @@ function updateMarkers({ fitBounds = false } = {}) { } if (fitBounds && state.markers.size > 0) { + fitMapToLocations(); + } +} + +function updateMarker(location) { + if (!state.map || !state.mapProvider) return; + + if (state.mapProvider === "leaflet") { + updateLeafletMarker(location); + return; + } + + updateKakaoMarker(location); +} + +function updateKakaoMarker(location) { + const position = new kakao.maps.LatLng(location.latitude, location.longitude); + const status = getLocationStatus(location); + const markerElement = createTruckMarkerElement(location, status); + + let marker = state.markers.get(location.driverId); + + if (!marker) { + marker = { + provider: "kakao", + overlay: new kakao.maps.CustomOverlay({ + position, + content: markerElement, + yAnchor: 1 + }) + }; + marker.overlay.setMap(state.map); + state.markers.set(location.driverId, marker); + } else { + marker.overlay.setPosition(position); + marker.overlay.setContent(markerElement); + } +} + +function updateLeafletMarker(location) { + const status = getLocationStatus(location); + const position = [location.latitude, location.longitude]; + const icon = L.divIcon({ + className: `leaflet-truck-marker truck-marker ${status.key}`, + html: createTruckMarkerHtml(location, status), + iconSize: [58, 34], + iconAnchor: [29, 34], + popupAnchor: [0, -30] + }); + + let marker = state.markers.get(location.driverId); + + if (!marker) { + marker = { + provider: "leaflet", + marker: L.marker(position, { icon, title: location.vehicleNo || location.driverId }) + }; + marker.marker.on("click", () => focusLocation(location)); + marker.marker.addTo(state.map); + state.markers.set(location.driverId, marker); + } else { + marker.marker.setLatLng(position); + marker.marker.setIcon(icon); + marker.marker.off("click"); + marker.marker.on("click", () => focusLocation(location)); + } +} + +function createTruckMarkerElement(location, status) { + const markerElement = document.createElement("button"); + markerElement.className = `truck-marker ${status.key}`; + markerElement.type = "button"; + markerElement.title = location.vehicleNo || location.driverId; + markerElement.setAttribute("aria-label", `${location.vehicleNo || location.driverId} 위치 보기`); + markerElement.innerHTML = createTruckMarkerHtml(location, status); + markerElement.addEventListener("click", () => focusLocation(location)); + return markerElement; +} + +function createTruckMarkerHtml(location) { + const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4); + return ` + ${escapeHtml(label)} + + `; +} + +function removeMarker(marker) { + if (marker.provider === "kakao") { + marker.overlay.setMap(null); + return; + } + + if (marker.provider === "leaflet") { + marker.marker.remove(); + } +} + +function fitMapToLocations() { + if (state.mapProvider === "kakao") { const bounds = new kakao.maps.LatLngBounds(); for (const location of state.locations.values()) { bounds.extend(new kakao.maps.LatLng(location.latitude, location.longitude)); } state.map.setBounds(bounds); + return; + } + + if (state.mapProvider === "leaflet") { + const bounds = L.latLngBounds([...state.locations.values()].map((location) => { + return [location.latitude, location.longitude]; + })); + state.map.fitBounds(bounds, { + maxZoom: 14, + padding: [40, 40] + }); } } -function updateMarker(location) { - if (!state.map || !window.kakao?.maps) return; +function centerMapOnLocation(location, options = {}) { + if (!state.map || !state.mapProvider) return; - const position = new kakao.maps.LatLng(location.latitude, location.longitude); - const status = getLocationStatus(location); - const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4); + if (state.mapProvider === "kakao") { + const position = new kakao.maps.LatLng(location.latitude, location.longitude); + state.map.setCenter(position); + if (options.zoom && state.map.getLevel() > 4) { + state.map.setLevel(4); + } + return; + } - const markerElement = document.createElement("button"); - markerElement.className = `kakao-marker ${status.key}`; - markerElement.type = "button"; - markerElement.textContent = label; - markerElement.title = location.vehicleNo || location.driverId; - markerElement.addEventListener("click", () => focusLocation(location)); - - let markerOverlay = state.markers.get(location.driverId); - - if (!markerOverlay) { - markerOverlay = new kakao.maps.CustomOverlay({ - position, - content: markerElement, - yAnchor: 1 - }); - markerOverlay.setMap(state.map); - state.markers.set(location.driverId, markerOverlay); - } else { - markerOverlay.setPosition(position); - markerOverlay.setContent(markerElement); + if (state.mapProvider === "leaflet") { + const nextZoom = options.zoom ? Math.max(state.map.getZoom(), 14) : state.map.getZoom(); + state.map.setView([location.latitude, location.longitude], nextZoom, { animate: true }); } } function focusLocation(location) { loadLocationHistory(location); - if (!state.map || !window.kakao?.maps) return; + if (!state.map || !state.mapProvider) return; - const position = new kakao.maps.LatLng(location.latitude, location.longitude); - state.map.setCenter(position); - if (state.map.getLevel() > 4) { - state.map.setLevel(4); - } + centerMapOnLocation(location, { zoom: true }); showInfoOverlay(location); } @@ -588,15 +769,24 @@ function closeHistoryDrawer() { function showInfoOverlay(location) { const status = getLocationStatus(location); - const position = new kakao.maps.LatLng(location.latitude, location.longitude); const content = createPopupElement(location, status); - if (!state.infoOverlay) { - state.infoOverlay = new kakao.maps.CustomOverlay({ - yAnchor: 1.25 - }); + if (state.mapProvider === "leaflet") { + state.infoOverlay = L.popup({ + closeButton: true, + offset: [0, -24] + }) + .setLatLng([location.latitude, location.longitude]) + .setContent(content) + .openOn(state.map); + return; } + const position = new kakao.maps.LatLng(location.latitude, location.longitude); + + if (!state.infoOverlay) { + state.infoOverlay = new kakao.maps.CustomOverlay({ yAnchor: 1.35 }); + } state.infoOverlay.setContent(content); state.infoOverlay.setPosition(position); state.infoOverlay.setMap(state.map); @@ -613,13 +803,20 @@ function createPopupElement(location, status) { const driver = document.createElement("div"); driver.textContent = `${location.driverName || location.driverId} · ${status.label}`; + const cargo = document.createElement("div"); + cargo.className = "popup-cargo"; + cargo.textContent = `화물: ${getCargoSummary(location)}`; + + const destination = document.createElement("div"); + destination.textContent = getRouteSummary(location); + const receivedAt = document.createElement("div"); - receivedAt.textContent = formatDateTime(location.receivedAt); + receivedAt.textContent = `수신: ${formatDateTime(location.receivedAt)}`; - const coordinate = document.createElement("div"); - coordinate.textContent = `${formatCoordinate(location.latitude)}, ${formatCoordinate(location.longitude)}`; + const movement = document.createElement("div"); + movement.textContent = `속도: ${formatSpeed(location.speed)}`; - container.append(title, driver, receivedAt, coordinate); + container.append(title, driver, cargo, destination, movement, receivedAt); return container; } @@ -706,10 +903,42 @@ function formatTime(value) { }).format(date); } +function formatSpeed(value) { + if (value === null || value === undefined || value === "") return "-"; + const speed = Number(value); + if (!Number.isFinite(speed)) return "-"; + return `${Math.round(speed)} km/h`; +} + function formatCoordinate(value) { return Number(value).toFixed(5); } +function getCargoSummary(location) { + const cargoName = firstString(location.cargoName, location.cargoItem, location.cargo, location.itemName); + const cargoWeight = firstString(location.cargoWeight, location.weight); + return [cargoName, cargoWeight].filter(Boolean).join(" · ") || "미등록"; +} + +function getRouteSummary(location) { + const origin = firstString(location.origin, location.departure); + const destination = firstString(location.destination, location.arrival); + + if (origin && destination) return `구간: ${origin} -> ${destination}`; + if (destination) return `도착지: ${destination}`; + if (origin) return `상차지: ${origin}`; + return "도착지: 미등록"; +} + +function firstString(...values) { + for (const value of values) { + if (value === undefined || value === null) continue; + const text = String(value).trim(); + if (text) return text; + } + return ""; +} + function escapeHtml(value) { return String(value) .replaceAll("&", "&") diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css index 45bc0c3..1df45f5 100644 --- a/apps/web/public/styles.css +++ b/apps/web/public/styles.css @@ -498,31 +498,94 @@ button:hover { font-weight: 800; } -.kakao-marker { - display: flex; - align-items: center; - justify-content: center; +.truck-marker { + --truck-color: var(--blue); + --truck-cab: #1556b8; + position: relative; + display: inline-flex; + align-items: flex-end; width: max-content; - min-width: 42px; - min-height: 30px; - padding: 0 9px; - border: 2px solid #ffffff; - border-radius: 999px; - background: var(--blue); + min-width: 56px; + min-height: 34px; + border: 0; + padding: 0 0 7px; + background: transparent; color: #ffffff; - box-shadow: 0 8px 18px rgba(16, 32, 48, 0.25); - font-size: 11px; - font-weight: 900; + filter: drop-shadow(0 8px 13px rgba(16, 32, 48, 0.28)); font-family: inherit; cursor: pointer; } -.kakao-marker.stale { - background: var(--amber); +.truck-marker.stale { + --truck-color: var(--amber); + --truck-cab: #8f4307; } -.kakao-marker.offline { - background: var(--red); +.truck-marker.offline { + --truck-color: var(--red); + --truck-cab: #8f1d15; +} + +.truck-marker::before, +.truck-marker::after { + content: ""; + position: absolute; + bottom: 1px; + width: 7px; + height: 7px; + border: 2px solid #ffffff; + border-radius: 50%; + background: #17212b; +} + +.truck-marker::before { + left: 10px; +} + +.truck-marker::after { + right: 11px; +} + +.truck-marker__trailer { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 38px; + height: 22px; + padding: 0 7px; + border: 2px solid #ffffff; + border-right: 0; + border-radius: 5px 0 0 5px; + background: var(--truck-color); + font-size: 11px; + font-weight: 900; + line-height: 1; + white-space: nowrap; +} + +.truck-marker__cab { + position: relative; + width: 18px; + height: 19px; + border: 2px solid #ffffff; + border-radius: 0 6px 5px 0; + background: var(--truck-cab); +} + +.truck-marker__cab::before { + content: ""; + position: absolute; + top: 3px; + right: 3px; + width: 7px; + height: 5px; + border-radius: 1px; + background: rgba(255, 255, 255, 0.72); +} + +.leaflet-truck-marker { + border: 0; + background: transparent; } .kakao-popup { @@ -543,6 +606,11 @@ button:hover { font-weight: 900; } +.popup-cargo { + margin-top: 6px; + font-weight: 900; +} + @media (max-width: 860px) { .app-shell { grid-template-rows: auto minmax(0, 1fr); diff --git a/docs/SYNOLOGY.md b/docs/SYNOLOGY.md index ab68d37..0d51a07 100644 --- a/docs/SYNOLOGY.md +++ b/docs/SYNOLOGY.md @@ -44,7 +44,7 @@ tested with `curl -k` while the certificate is being prepared. ```text CARGORADAR_PORT=8080 ADMIN_TOKEN= -KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec +KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 ``` For NAS deployment, start from `.env.nas.example` instead. The recommended NAS @@ -53,7 +53,7 @@ host port is `18081` because `18080` is already forwarded for `opendaw`. ```text CARGORADAR_PORT=18081 ADMIN_TOKEN=change_this_admin_token -KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec +KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 ``` `ADMIN_TOKEN` can stay blank during local MVP testing. For NAS exposure, set it.