전국 화물차 시뮬레이터 추가

This commit is contained in:
2026-06-06 18:37:36 +09:00
parent 2ba63fbc5b
commit 5a1f851d32
8 changed files with 643 additions and 40 deletions

View File

@@ -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
};