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 @@
차량번호
+