From 5a1f851d3296e018868562191266c69575ed913b Mon Sep 17 00:00:00 2001 From: y2keui Date: Sat, 6 Jun 2026 18:37:36 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EA=B5=AD=20=ED=99=94=EB=AC=BC?= =?UTF-8?q?=EC=B0=A8=20=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + .env.nas.example | 2 + README.md | 28 +++ apps/server/src/server.js | 391 ++++++++++++++++++++++++++++++++++++- apps/web/public/app.js | 89 +++++++-- apps/web/public/index.html | 12 ++ apps/web/public/styles.css | 153 +++++++++++++-- docs/SYNOLOGY.md | 6 + 8 files changed, 643 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 4db1a26..1975ad0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ CARGORADAR_PORT=8080 ADMIN_TOKEN= KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 +SIMULATOR_ENABLED=true +SIMULATOR_INTERVAL_MS=4000 diff --git a/.env.nas.example b/.env.nas.example index fe862fa..52e6b7f 100644 --- a/.env.nas.example +++ b/.env.nas.example @@ -1,3 +1,5 @@ CARGORADAR_PORT=18081 ADMIN_TOKEN=change_this_admin_token KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 +SIMULATOR_ENABLED=true +SIMULATOR_INTERVAL_MS=4000 diff --git a/README.md b/README.md index f5b97ff..d926cd3 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ http://localhost:8080 The web screen has a `샘플 위치` button that posts a demo location with the default `demo-token`. +By default the server also runs a test simulator with 10 vehicles moving around +Korea. Disable it when real driver phones are connected: + +```text +SIMULATOR_ENABLED=false +``` + ## Run With Docker ```powershell @@ -47,6 +54,10 @@ history survive container restarts. For NAS, use `.env.nas.example` as the starting point and prefer port `18081` behind Synology Reverse Proxy. +During early testing, keep `SIMULATOR_ENABLED=true` to show moving demo +vehicles without Android phones. The simulator publishes fresh locations every +`SIMULATOR_INTERVAL_MS` milliseconds. + ## Maintenance Scripts Check server status: @@ -114,6 +125,23 @@ The default driver is: Change the token before real use. +## Test Simulator + +The built-in simulator creates 10 test vehicles with fixed device tokens and +routes across Seoul, Incheon, Daejeon, Busan, Gwangju, Ulsan, Gangwon, Jeju, +and other regional hubs. It includes several freight vehicle types: + +- `tractor-trailer`: 트레일러 +- `cargo-truck`: 카고트럭 +- `box-truck`: 탑차 +- `van`: 용달차 +- `refrigerated`: 냉동탑차 +- `container`: 컨테이너 +- `wingbody`: 윙바디 + +Each simulator tick updates latest positions through the same SSE stream used +by real Android uploads, so the control screen behaves like a live fleet map. + ## API Driver management: diff --git a/apps/server/src/server.js b/apps/server/src/server.js index e9690e5..79f24f6 100644 --- a/apps/server/src/server.js +++ b/apps/server/src/server.js @@ -10,12 +10,200 @@ 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 = "07f4728599ceaf4299f77c772a135423"; +const SIMULATOR_ENABLED = parseBoolean(process.env.SIMULATOR_ENABLED, true); +const SIMULATOR_INTERVAL_MS = clampNumber(Number(process.env.SIMULATOR_INTERVAL_MS || 4000), 1000, 60000); +const SIMULATOR_HISTORY_EVERY = clampNumber(Number(process.env.SIMULATOR_HISTORY_EVERY || 3), 1, 30); const DRIVERS_FILE = path.join(DATA_DIR, "drivers.json"); const LATEST_FILE = path.join(DATA_DIR, "latest-locations.json"); const HISTORY_FILE = path.join(DATA_DIR, "locations.jsonl"); +const VEHICLE_TYPES = new Set([ + "cargo-truck", + "tractor-trailer", + "box-truck", + "van", + "refrigerated", + "container", + "wingbody" +]); + +const SIMULATED_FLEET = [ + { + driverId: "sim-01", + name: "강민준", + vehicleNo: "서울 91바 1024", + token: "sim-token-01", + vehicleType: "tractor-trailer", + cargoName: "자동차 부품", + cargoWeight: "18t", + speedBase: 76, + loopMs: 16 * 60 * 1000, + route: [ + point("인천항", 37.4563, 126.7052), + point("평택 물류단지", 36.9921, 127.1128), + point("대전 허브", 36.3504, 127.3845), + point("부산신항", 35.0812, 128.8291) + ] + }, + { + driverId: "sim-02", + name: "이서준", + vehicleNo: "부산 82사 7731", + token: "sim-token-02", + vehicleType: "container", + cargoName: "수출 컨테이너", + cargoWeight: "22t", + speedBase: 72, + loopMs: 15 * 60 * 1000, + route: [ + point("부산항", 35.1028, 129.0403), + point("대구 물류센터", 35.8714, 128.6014), + point("대전 터미널", 36.3504, 127.3845), + point("서울 남부센터", 37.4813, 127.0367) + ] + }, + { + driverId: "sim-03", + name: "박도윤", + vehicleNo: "경기 87아 4588", + token: "sim-token-03", + vehicleType: "cargo-truck", + cargoName: "건축 자재", + cargoWeight: "8t", + speedBase: 68, + loopMs: 13 * 60 * 1000, + route: [ + point("남양주 창고", 37.6360, 127.2165), + point("원주 하차지", 37.3422, 127.9202), + point("강릉 공사현장", 37.7519, 128.8761), + point("이천 집하장", 37.2792, 127.4425) + ] + }, + { + driverId: "sim-04", + name: "최하준", + vehicleNo: "인천 94자 2190", + token: "sim-token-04", + vehicleType: "van", + cargoName: "소형 택배", + cargoWeight: "0.9t", + speedBase: 54, + loopMs: 9 * 60 * 1000, + route: [ + point("서울 성수", 37.5446, 127.0557), + point("수원 권선", 37.2636, 127.0286), + point("천안 서북", 36.8151, 127.1139), + point("대전 유성", 36.3622, 127.3563) + ] + }, + { + driverId: "sim-05", + name: "정지호", + vehicleNo: "전남 86바 6405", + token: "sim-token-05", + vehicleType: "refrigerated", + cargoName: "냉장 식품", + cargoWeight: "3.5t", + speedBase: 62, + loopMs: 12 * 60 * 1000, + route: [ + point("평택 냉장창고", 36.9948, 127.0889), + point("전주 물류센터", 35.8242, 127.1480), + point("광주 도매시장", 35.1595, 126.8526), + point("목포 항만", 34.8118, 126.3922) + ] + }, + { + driverId: "sim-06", + name: "오시우", + vehicleNo: "광주 80다 3317", + token: "sim-token-06", + vehicleType: "wingbody", + cargoName: "생활용품 팔레트", + cargoWeight: "5t", + speedBase: 66, + loopMs: 11 * 60 * 1000, + route: [ + point("광주 광산", 35.1395, 126.7937), + point("전주 완산", 35.8160, 127.1530), + point("논산 집하장", 36.1871, 127.0987), + point("대전 허브", 36.3504, 127.3845) + ] + }, + { + driverId: "sim-07", + name: "한유준", + vehicleNo: "울산 89사 5022", + token: "sim-token-07", + vehicleType: "box-truck", + cargoName: "전자제품", + cargoWeight: "2.5t", + speedBase: 61, + loopMs: 10 * 60 * 1000, + route: [ + point("울산 공단", 35.5384, 129.3114), + point("포항 남구", 36.0190, 129.3435), + point("경주 외동", 35.8401, 129.2124), + point("대구 성서", 35.8530, 128.5066) + ] + }, + { + driverId: "sim-08", + name: "문태오", + vehicleNo: "경남 92자 8816", + token: "sim-token-08", + vehicleType: "tractor-trailer", + cargoName: "철강 코일", + cargoWeight: "20t", + speedBase: 58, + loopMs: 14 * 60 * 1000, + route: [ + point("여수 산단", 34.7604, 127.6622), + point("순천 물류장", 34.9506, 127.4872), + point("진주 하치장", 35.1800, 128.1076), + point("창원 공단", 35.2279, 128.6811) + ] + }, + { + driverId: "sim-09", + name: "서은우", + vehicleNo: "제주 87허 1208", + token: "sim-token-09", + vehicleType: "van", + cargoName: "호텔 린넨", + cargoWeight: "0.7t", + speedBase: 46, + loopMs: 8 * 60 * 1000, + route: [ + point("제주항", 33.5176, 126.5260), + point("제주공항", 33.5071, 126.4930), + point("서귀포 중문", 33.2541, 126.4123), + point("성산 물류점", 33.4582, 126.9425) + ] + }, + { + driverId: "sim-10", + name: "유도현", + vehicleNo: "충북 83바 7344", + token: "sim-token-10", + vehicleType: "cargo-truck", + cargoName: "농산물", + cargoWeight: "4.5t", + speedBase: 57, + loopMs: 12 * 60 * 1000, + route: [ + point("청주 집하장", 36.6424, 127.4890), + point("문경 산지센터", 36.5866, 128.1868), + point("안동 물류장", 36.5684, 128.7294), + point("경주 하차지", 35.8562, 129.2247) + ] + } +]; + const sseClients = new Set(); +let simulatorTimer = null; +let simulatorTick = 0; ensureDataFiles(); @@ -137,11 +325,7 @@ const server = http.createServer(async (req, res) => { } const location = createLocationRecord(body, driver); - latestLocations[location.driverId] = location; - - appendJsonLine(HISTORY_FILE, location); - writeJsonAtomic(LATEST_FILE, latestLocations); - broadcastEvent("location.updated", location); + storeLocation(location); sendJson(res, 201, { location }); return; @@ -163,12 +347,17 @@ const server = http.createServer(async (req, res) => { server.listen(PORT, HOST, () => { console.log(`CargoRadar server listening on http://${HOST}:${PORT}`); console.log(`Serving web files from ${WEB_ROOT}`); + startSimulator(); }); process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); function shutdown() { + if (simulatorTimer) { + clearInterval(simulatorTimer); + } + for (const client of sseClients) { client.end(); } @@ -223,8 +412,10 @@ function createDriver(body) { name, vehicleNo, phone: String(body.phone || "").trim(), + vehicleType: normalizeVehicleType(body.vehicleType), token: String(body.token || "").trim() || generateDeviceToken(), enabled: body.enabled === undefined ? true : Boolean(body.enabled), + simulated: Boolean(body.simulated), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; @@ -253,6 +444,10 @@ function updateDriver(driverId, body) { driver.phone = String(body.phone || "").trim(); } + if (body.vehicleType !== undefined) { + driver.vehicleType = normalizeVehicleType(body.vehicleType); + } + if (body.enabled !== undefined) { driver.enabled = Boolean(body.enabled); } @@ -275,6 +470,144 @@ function loadLatestLocations() { return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; } +function startSimulator() { + if (!SIMULATOR_ENABLED) { + console.log("CargoRadar simulator disabled."); + return; + } + + ensureSimulatorDrivers(); + publishSimulatorLocations(); + simulatorTimer = setInterval(publishSimulatorLocations, SIMULATOR_INTERVAL_MS); + + if (typeof simulatorTimer.unref === "function") { + simulatorTimer.unref(); + } + + console.log(`CargoRadar simulator running with ${SIMULATED_FLEET.length} vehicles.`); +} + +function ensureSimulatorDrivers() { + let changed = false; + + for (const vehicle of SIMULATED_FLEET) { + const existing = drivers.find((driver) => driver.driverId === vehicle.driverId); + const next = { + driverId: vehicle.driverId, + name: vehicle.name, + vehicleNo: vehicle.vehicleNo, + phone: "", + token: vehicle.token, + vehicleType: normalizeVehicleType(vehicle.vehicleType), + enabled: true, + simulated: true + }; + + if (!existing) { + drivers.push({ + ...next, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }); + changed = true; + continue; + } + + let driverChanged = false; + + for (const [key, value] of Object.entries(next)) { + if (existing[key] !== value) { + existing[key] = value; + driverChanged = true; + } + } + + if (driverChanged) { + existing.updatedAt = new Date().toISOString(); + changed = true; + } + } + + if (changed) { + writeJsonAtomic(DRIVERS_FILE, drivers); + broadcastEvent("drivers.updated", { drivers: drivers.map(toDriverResponse).sort(compareDrivers) }); + } +} + +function publishSimulatorLocations() { + simulatorTick += 1; + const nowMs = Date.now(); + const appendHistory = simulatorTick % SIMULATOR_HISTORY_EVERY === 1; + + for (const vehicle of SIMULATED_FLEET) { + const driver = drivers.find((candidate) => candidate.driverId === vehicle.driverId); + if (!driver || driver.enabled === false) continue; + + const body = createSimulatorLocationBody(vehicle, nowMs); + const location = createLocationRecord(body, driver); + storeLocation(location, { appendHistory, persistLatest: false }); + } + + writeJsonAtomic(LATEST_FILE, latestLocations); +} + +function createSimulatorLocationBody(vehicle, nowMs) { + const routeState = interpolateRoute(vehicle.route, nowMs, vehicle.loopMs, hashOffset(vehicle.driverId)); + const speedWave = Math.sin((nowMs + hashOffset(vehicle.vehicleNo)) / 17000) * 8; + + return { + driverId: vehicle.driverId, + vehicleNo: vehicle.vehicleNo, + vehicleType: vehicle.vehicleType, + latitude: routeState.latitude, + longitude: routeState.longitude, + accuracy: 8, + speed: Math.max(18, Math.round(vehicle.speedBase + speedWave)), + heading: routeState.heading, + cargoName: vehicle.cargoName, + cargoWeight: vehicle.cargoWeight, + origin: routeState.origin, + destination: routeState.destination, + provider: "simulator", + simulated: true, + recordedAt: new Date(nowMs).toISOString() + }; +} + +function interpolateRoute(route, nowMs, loopMs, offsetMs) { + const progress = ((nowMs + offsetMs) % loopMs) / loopMs; + const segmentPosition = progress * route.length; + const segmentIndex = Math.floor(segmentPosition) % route.length; + const localProgress = smoothStep(segmentPosition - segmentIndex); + const from = route[segmentIndex]; + const to = route[(segmentIndex + 1) % route.length]; + + return { + latitude: interpolateNumber(from.latitude, to.latitude, localProgress), + longitude: interpolateNumber(from.longitude, to.longitude, localProgress), + heading: Math.round(calculateBearing(from, to)), + origin: from.name, + destination: to.name + }; +} + +function storeLocation(location, options = {}) { + const appendHistory = options.appendHistory !== false; + const persistLatest = options.persistLatest !== false; + + latestLocations[location.driverId] = location; + + if (appendHistory) { + appendJsonLine(HISTORY_FILE, location); + } + + if (persistLatest) { + writeJsonAtomic(LATEST_FILE, latestLocations); + } + + broadcastEvent("location.updated", location); +} + function readJsonFile(filePath, fallback) { try { return JSON.parse(fs.readFileSync(filePath, "utf8")); @@ -394,6 +727,7 @@ function createLocationRecord(body, driver) { driverId: driver.driverId, driverName: driver.name || driver.driverId, vehicleNo: String(body.vehicleNo || driver.vehicleNo || ""), + vehicleType: normalizeVehicleType(body.vehicleType || driver.vehicleType), latitude, longitude, accuracy: optionalNumber(body.accuracy), @@ -405,11 +739,56 @@ function createLocationRecord(body, driver) { origin: optionalString(body.origin || body.departure, 80), destination: optionalString(body.destination || body.arrival, 80), provider: body.provider ? String(body.provider) : "", + simulated: Boolean(body.simulated || driver.simulated), recordedAt: normalizeDate(body.recordedAt), receivedAt: new Date().toISOString() }; } +function normalizeVehicleType(value) { + const vehicleType = String(value || "cargo-truck").trim().toLowerCase(); + return VEHICLE_TYPES.has(vehicleType) ? vehicleType : "cargo-truck"; +} + +function point(name, latitude, longitude) { + return { name, latitude, longitude }; +} + +function smoothStep(value) { + return value * value * (3 - 2 * value); +} + +function interpolateNumber(from, to, progress) { + return from + (to - from) * progress; +} + +function calculateBearing(from, to) { + const fromLatitude = toRadians(from.latitude); + const toLatitude = toRadians(to.latitude); + const deltaLongitude = toRadians(to.longitude - from.longitude); + const y = Math.sin(deltaLongitude) * Math.cos(toLatitude); + const x = Math.cos(fromLatitude) * Math.sin(toLatitude) - + Math.sin(fromLatitude) * Math.cos(toLatitude) * Math.cos(deltaLongitude); + return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; +} + +function toRadians(value) { + return value * Math.PI / 180; +} + +function hashOffset(value) { + let hash = 0; + for (const character of String(value)) { + hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + } + return hash; +} + +function parseBoolean(value, defaultValue) { + if (value === undefined || value === null || value === "") return defaultValue; + return !["0", "false", "no", "off"].includes(String(value).trim().toLowerCase()); +} + function validationError(publicMessage) { const error = new Error(publicMessage); error.statusCode = 400; @@ -504,8 +883,10 @@ function toDriverResponse(driver) { name: driver.name || driver.driverId, vehicleNo: driver.vehicleNo || "", phone: driver.phone || "", + vehicleType: normalizeVehicleType(driver.vehicleType), token: driver.token || "", enabled: driver.enabled !== false, + simulated: Boolean(driver.simulated), createdAt: driver.createdAt || null, updatedAt: driver.updatedAt || null }; diff --git a/apps/web/public/app.js b/apps/web/public/app.js index 9236a32..da63aea 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -30,6 +30,7 @@ const elements = { driverForm: document.getElementById("driverForm"), driverNameInput: document.getElementById("driverNameInput"), vehicleNoInput: document.getElementById("vehicleNoInput"), + vehicleTypeInput: document.getElementById("vehicleTypeInput"), phoneInput: document.getElementById("phoneInput"), driverIdInput: document.getElementById("driverIdInput"), driverCount: document.getElementById("driverCount"), @@ -41,6 +42,44 @@ const elements = { historyList: document.getElementById("historyList") }; +const VEHICLE_TYPES = { + "cargo-truck": { + label: "카고트럭", + shortLabel: "카고", + className: "type-cargo" + }, + "tractor-trailer": { + label: "트레일러", + shortLabel: "트레", + className: "type-trailer" + }, + "box-truck": { + label: "탑차", + shortLabel: "탑차", + className: "type-box" + }, + "van": { + label: "용달차", + shortLabel: "용달", + className: "type-van" + }, + "refrigerated": { + label: "냉동탑차", + shortLabel: "냉동", + className: "type-cold" + }, + "container": { + label: "컨테이너", + shortLabel: "컨", + className: "type-container" + }, + "wingbody": { + label: "윙바디", + shortLabel: "윙", + className: "type-wing" + } +}; + document.addEventListener("DOMContentLoaded", () => { initializeMap(); bindActions(); @@ -232,7 +271,8 @@ function connectEvents() { replaceLocations(payload.locations || []); }); state.eventSource.addEventListener("location.updated", (event) => { - upsertLocation(JSON.parse(event.data), { focus: true }); + const location = JSON.parse(event.data); + upsertLocation(location, { focus: state.selectedDriverId === location.driverId }); }); state.eventSource.addEventListener("drivers.updated", (event) => { const payload = JSON.parse(event.data); @@ -257,6 +297,7 @@ async function postDemoLocation() { body: JSON.stringify({ driverId: driver.driverId, vehicleNo: driver.vehicleNo, + vehicleType: driver.vehicleType || "cargo-truck", latitude, longitude, accuracy: 12, @@ -375,6 +416,7 @@ function renderDrivers() { function createVehicleItem(location) { const status = getLocationStatus(location); + const vehicleType = getVehicleType(location); const item = document.createElement("article"); item.className = "vehicle-item"; item.tabIndex = 0; @@ -389,14 +431,18 @@ function createVehicleItem(location) { badge.className = `badge ${status.key}`; badge.textContent = status.label; + const typeBadge = document.createElement("span"); + typeBadge.className = `vehicle-type-chip ${vehicleType.className}`; + typeBadge.textContent = vehicleType.label; + const vehicleNo = document.createElement("strong"); vehicleNo.textContent = location.vehicleNo || location.driverId; - title.append(badge, vehicleNo); + title.append(badge, typeBadge, vehicleNo); const meta = document.createElement("div"); meta.className = "vehicle-meta"; - meta.textContent = `${location.driverName || location.driverId} · ${formatDateTime(location.receivedAt)} · ${formatCoordinate(location.latitude)}, ${formatCoordinate(location.longitude)}`; + meta.textContent = `${location.driverName || location.driverId} · ${getCargoSummary(location)} · ${getRouteSummary(location)} · ${formatDateTime(location.receivedAt)}`; const speed = document.createElement("div"); speed.className = "speed"; @@ -417,6 +463,7 @@ function createVehicleItem(location) { } function createDriverItem(driver) { + const vehicleType = getVehicleType(driver); const item = document.createElement("article"); item.className = "driver-item"; @@ -431,7 +478,7 @@ function createDriverItem(driver) { const meta = document.createElement("div"); meta.className = "vehicle-meta"; - meta.textContent = [driver.driverId, driver.phone].filter(Boolean).join(" · "); + meta.textContent = [vehicleType.label, driver.driverId, driver.phone].filter(Boolean).join(" · "); title.append(name, meta); @@ -483,6 +530,7 @@ async function submitDriverForm(event) { const payload = { name: elements.driverNameInput.value.trim(), vehicleNo: elements.vehicleNoInput.value.trim(), + vehicleType: elements.vehicleTypeInput.value, phone: elements.phoneInput.value.trim(), driverId: elements.driverIdInput.value.trim() }; @@ -608,12 +656,13 @@ function updateKakaoMarker(location) { function updateLeafletMarker(location) { const status = getLocationStatus(location); + const vehicleType = getVehicleType(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], + className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className}`, + html: createTruckMarkerHtml(location, vehicleType), + iconSize: [74, 38], + iconAnchor: [37, 38], popupAnchor: [0, -30] }); @@ -636,20 +685,24 @@ function updateLeafletMarker(location) { } function createTruckMarkerElement(location, status) { + const vehicleType = getVehicleType(location); const markerElement = document.createElement("button"); - markerElement.className = `truck-marker ${status.key}`; + markerElement.className = `truck-marker ${status.key} ${vehicleType.className}`; markerElement.type = "button"; - markerElement.title = location.vehicleNo || location.driverId; - markerElement.setAttribute("aria-label", `${location.vehicleNo || location.driverId} 위치 보기`); - markerElement.innerHTML = createTruckMarkerHtml(location, status); + markerElement.title = `${vehicleType.label} ${location.vehicleNo || location.driverId}`; + markerElement.setAttribute("aria-label", `${vehicleType.label} ${location.vehicleNo || location.driverId} 위치 보기`); + markerElement.innerHTML = createTruckMarkerHtml(location, vehicleType); markerElement.addEventListener("click", () => focusLocation(location)); return markerElement; } -function createTruckMarkerHtml(location) { +function createTruckMarkerHtml(location, vehicleType = getVehicleType(location)) { const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4); return ` - ${escapeHtml(label)} + + ${escapeHtml(vehicleType.shortLabel)} + ${escapeHtml(label)} + `; } @@ -793,6 +846,7 @@ function showInfoOverlay(location) { } function createPopupElement(location, status) { + const vehicleType = getVehicleType(location); const container = document.createElement("div"); container.className = "kakao-popup"; @@ -801,7 +855,7 @@ function createPopupElement(location, status) { title.textContent = location.vehicleNo || location.driverId; const driver = document.createElement("div"); - driver.textContent = `${location.driverName || location.driverId} · ${status.label}`; + driver.textContent = `${vehicleType.label} · ${location.driverName || location.driverId} · ${status.label}`; const cargo = document.createElement("div"); cargo.className = "popup-cargo"; @@ -872,6 +926,11 @@ function hideMapMessage() { elements.mapMessage.classList.add("is-hidden"); } +function getVehicleType(source) { + const key = String(source.vehicleType || "cargo-truck").trim(); + return VEHICLE_TYPES[key] || VEHICLE_TYPES["cargo-truck"]; +} + function getLocationStatus(location) { const receivedAt = new Date(location.receivedAt).getTime(); const ageMs = Date.now() - receivedAt; diff --git a/apps/web/public/index.html b/apps/web/public/index.html index fc1d096..fdf5360 100644 --- a/apps/web/public/index.html +++ b/apps/web/public/index.html @@ -65,6 +65,18 @@ 차량번호 +