전국 화물차 시뮬레이터 추가
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user