도로 경로 기반 시뮬레이터 개선
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
const fs = require("node:fs");
|
||||
const https = require("node:https");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
@@ -11,13 +12,19 @@ const WEB_ROOT = process.env.WEB_ROOT || path.resolve(__dirname, "..", "..", "we
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "";
|
||||
const ADMIN_AUTH_REQUIRED = parseBoolean(process.env.ADMIN_AUTH_REQUIRED, false);
|
||||
const DEFAULT_KAKAO_JAVASCRIPT_KEY = "07f4728599ceaf4299f77c772a135423";
|
||||
const DEFAULT_KAKAO_REST_API_KEY = "91e5df96769ba97fc83e9ace9a4fec1e";
|
||||
const KAKAO_REST_API_KEY = process.env.KAKAO_REST_API_KEY || DEFAULT_KAKAO_REST_API_KEY;
|
||||
const SIMULATOR_ENABLED = parseBoolean(process.env.SIMULATOR_ENABLED, true);
|
||||
const SIMULATOR_INTERVAL_MS = clampNumber(Number(process.env.SIMULATOR_INTERVAL_MS || 1000), 1000, 60000);
|
||||
const SIMULATOR_INTERVAL_MS = clampNumber(Number(process.env.SIMULATOR_INTERVAL_MS || 5000), 1000, 60000);
|
||||
const SIMULATOR_HISTORY_EVERY = clampNumber(Number(process.env.SIMULATOR_HISTORY_EVERY || 3), 1, 30);
|
||||
const SIMULATOR_SPEED_SCALE = clampNumber(Number(process.env.SIMULATOR_SPEED_SCALE || 1), 0.1, 20);
|
||||
const KAKAO_DIRECTIONS_URL = "https://apis-navi.kakaomobility.com/affiliate/v1/directions";
|
||||
const OSRM_ROUTE_URL = process.env.OSRM_ROUTE_URL || "https://router.project-osrm.org/route/v1/driving";
|
||||
|
||||
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 SIMULATOR_ROUTES_FILE = path.join(DATA_DIR, "simulator-routes.json");
|
||||
|
||||
const VEHICLE_TYPES = new Set([
|
||||
"cargo-truck",
|
||||
@@ -215,11 +222,13 @@ const SIMULATED_FLEET = [
|
||||
const sseClients = new Set();
|
||||
let simulatorTimer = null;
|
||||
let simulatorTick = 0;
|
||||
let simulatorRouteCache = {};
|
||||
|
||||
ensureDataFiles();
|
||||
|
||||
let drivers = loadDrivers();
|
||||
let latestLocations = loadLatestLocations();
|
||||
simulatorRouteCache = loadSimulatorRouteCache();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||
@@ -398,6 +407,10 @@ function ensureDataFiles() {
|
||||
if (!fs.existsSync(HISTORY_FILE)) {
|
||||
fs.writeFileSync(HISTORY_FILE, "", "utf8");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(SIMULATOR_ROUTES_FILE)) {
|
||||
writeJsonAtomic(SIMULATOR_ROUTES_FILE, {});
|
||||
}
|
||||
}
|
||||
|
||||
function loadDrivers() {
|
||||
@@ -405,6 +418,11 @@ function loadDrivers() {
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
function loadSimulatorRouteCache() {
|
||||
const parsed = readJsonFile(SIMULATOR_ROUTES_FILE, {});
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
}
|
||||
|
||||
function createDriver(body) {
|
||||
const driverId = normalizeDriverId(body.driverId) || generateDriverId();
|
||||
|
||||
@@ -488,6 +506,20 @@ function startSimulator() {
|
||||
}
|
||||
|
||||
ensureSimulatorDrivers();
|
||||
console.log(
|
||||
`CargoRadar simulator preparing ${SIMULATED_FLEET.length} vehicles ` +
|
||||
`at ${SIMULATOR_INTERVAL_MS}ms updates and ${SIMULATOR_SPEED_SCALE}x speed.`
|
||||
);
|
||||
primeSimulatorRouteCache()
|
||||
.catch((error) => {
|
||||
console.warn(`CargoRadar simulator route preload failed: ${error.message}`);
|
||||
})
|
||||
.finally(startSimulatorPublishing);
|
||||
}
|
||||
|
||||
function startSimulatorPublishing() {
|
||||
if (simulatorTimer) return;
|
||||
|
||||
publishSimulatorLocations();
|
||||
simulatorTimer = setInterval(publishSimulatorLocations, SIMULATOR_INTERVAL_MS);
|
||||
|
||||
@@ -495,7 +527,7 @@ function startSimulator() {
|
||||
simulatorTimer.unref();
|
||||
}
|
||||
|
||||
console.log(`CargoRadar simulator running with ${SIMULATED_FLEET.length} vehicles.`);
|
||||
console.log("CargoRadar simulator publishing locations.");
|
||||
}
|
||||
|
||||
function ensureSimulatorDrivers() {
|
||||
@@ -563,15 +595,10 @@ function publishSimulatorLocations() {
|
||||
}
|
||||
|
||||
function createSimulatorLocationBody(vehicle, nowMs) {
|
||||
const routeState = interpolateRoute(vehicle.route, nowMs, vehicle.loopMs, hashOffset(vehicle.driverId));
|
||||
const routeState = calculateSimulatorRouteState(vehicle, nowMs);
|
||||
const speedWave = Math.sin((nowMs + hashOffset(vehicle.vehicleNo)) / 17000) * 8;
|
||||
const speed = Math.max(18, Math.round(vehicle.speedBase + speedWave));
|
||||
const routePath = createSimulatedRoutePath(
|
||||
{ latitude: routeState.latitude, longitude: routeState.longitude },
|
||||
routeState.destinationPoint,
|
||||
vehicle.driverId
|
||||
);
|
||||
const remainingDistanceKm = roundNumber(calculatePathDistanceKm(routePath) * 1.08, 1);
|
||||
const remainingDistanceKm = roundNumber(routeState.remainingDistanceKm, 1);
|
||||
const etaMinutes = Math.max(3, Math.round((remainingDistanceKm / Math.max(speed, 24)) * 60));
|
||||
|
||||
return {
|
||||
@@ -590,8 +617,8 @@ function createSimulatorLocationBody(vehicle, nowMs) {
|
||||
destination: routeState.destination,
|
||||
destinationLatitude: routeState.destinationPoint.latitude,
|
||||
destinationLongitude: routeState.destinationPoint.longitude,
|
||||
routePath,
|
||||
routeSource: "simulated",
|
||||
routePath: routeState.remainingPath,
|
||||
routeSource: routeState.routeSource,
|
||||
remainingDistanceKm,
|
||||
etaMinutes,
|
||||
estimatedArrivalAt: new Date(nowMs + etaMinutes * 60 * 1000).toISOString(),
|
||||
@@ -601,24 +628,347 @@ function createSimulatorLocationBody(vehicle, nowMs) {
|
||||
};
|
||||
}
|
||||
|
||||
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];
|
||||
function calculateSimulatorRouteState(vehicle, nowMs) {
|
||||
const segments = buildSimulatorSegments(vehicle);
|
||||
const totalDistanceKm = segments.reduce((total, segment) => total + segment.distanceKm, 0);
|
||||
|
||||
if (!segments.length || totalDistanceKm <= 0) {
|
||||
const fallbackFrom = vehicle.route[0];
|
||||
const fallbackTo = vehicle.route[1] || fallbackFrom;
|
||||
return {
|
||||
latitude: fallbackFrom.latitude,
|
||||
longitude: fallbackFrom.longitude,
|
||||
heading: Math.round(calculateBearing(fallbackFrom, fallbackTo)),
|
||||
origin: fallbackFrom.name,
|
||||
destination: fallbackTo.name,
|
||||
destinationPoint: fallbackTo,
|
||||
remainingPath: [normalizeRoutePoint(fallbackFrom), normalizeRoutePoint(fallbackTo)].filter(Boolean),
|
||||
remainingDistanceKm: calculateDistanceKm(fallbackFrom, fallbackTo),
|
||||
routeSource: "fallback"
|
||||
};
|
||||
}
|
||||
|
||||
const kmPerMs = Math.max(vehicle.speedBase, 1) * SIMULATOR_SPEED_SCALE / 3600000;
|
||||
const offsetDistanceKm = (hashOffset(vehicle.driverId) % 10000) / 10000 * totalDistanceKm;
|
||||
let traveledDistanceKm = (nowMs * kmPerMs + offsetDistanceKm) % totalDistanceKm;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (traveledDistanceKm > segment.distanceKm) {
|
||||
traveledDistanceKm -= segment.distanceKm;
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = interpolatePathAtDistance(segment.path, traveledDistanceKm);
|
||||
const remainingPath = slicePathFromDistance(segment.path, traveledDistanceKm);
|
||||
const remainingDistanceKm = Math.max(0, segment.distanceKm - traveledDistanceKm);
|
||||
|
||||
return {
|
||||
latitude: position.point.latitude,
|
||||
longitude: position.point.longitude,
|
||||
heading: Math.round(position.heading),
|
||||
origin: segment.from.name,
|
||||
destination: segment.to.name,
|
||||
destinationPoint: segment.to,
|
||||
remainingPath,
|
||||
remainingDistanceKm,
|
||||
routeSource: segment.source
|
||||
};
|
||||
}
|
||||
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
const lastPoint = lastSegment.path[lastSegment.path.length - 1];
|
||||
const previousPoint = lastSegment.path[lastSegment.path.length - 2] || lastPoint;
|
||||
return {
|
||||
latitude: lastPoint.latitude,
|
||||
longitude: lastPoint.longitude,
|
||||
heading: Math.round(calculateBearing(previousPoint, lastPoint)),
|
||||
origin: lastSegment.from.name,
|
||||
destination: lastSegment.to.name,
|
||||
destinationPoint: lastSegment.to,
|
||||
remainingPath: [lastPoint],
|
||||
remainingDistanceKm: 0,
|
||||
routeSource: lastSegment.source
|
||||
};
|
||||
}
|
||||
|
||||
function buildSimulatorSegments(vehicle) {
|
||||
const segments = [];
|
||||
|
||||
for (let index = 0; index < vehicle.route.length; index += 1) {
|
||||
const from = vehicle.route[index];
|
||||
const to = vehicle.route[(index + 1) % vehicle.route.length];
|
||||
const cacheKey = getSimulatorSegmentKey(vehicle, index, from, to);
|
||||
const cached = simulatorRouteCache[cacheKey];
|
||||
const cachedPath = Array.isArray(cached?.path) ? normalizeRoutePath(cached.path) : [];
|
||||
const fallbackPath = createSimulatedRoutePath(from, to, `${vehicle.driverId}-${index}`);
|
||||
const path = cachedPath.length >= 2 ? cachedPath : fallbackPath;
|
||||
const distanceKm = Math.max(0.01, calculatePathDistanceKm(path));
|
||||
|
||||
segments.push({
|
||||
from,
|
||||
to,
|
||||
path,
|
||||
distanceKm,
|
||||
source: cachedPath.length >= 2 ? cached.source || "cached-route" : "fallback-route"
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function primeSimulatorRouteCache() {
|
||||
let changed = false;
|
||||
let kakaoFailureMessage = "";
|
||||
let osrmFailureMessage = "";
|
||||
let kakaoAccessBlocked = !KAKAO_REST_API_KEY;
|
||||
|
||||
for (const vehicle of SIMULATED_FLEET) {
|
||||
for (let index = 0; index < vehicle.route.length; index += 1) {
|
||||
const from = vehicle.route[index];
|
||||
const to = vehicle.route[(index + 1) % vehicle.route.length];
|
||||
const cacheKey = getSimulatorSegmentKey(vehicle, index, from, to);
|
||||
const cachedPath = Array.isArray(simulatorRouteCache[cacheKey]?.path)
|
||||
? normalizeRoutePath(simulatorRouteCache[cacheKey].path)
|
||||
: [];
|
||||
|
||||
const cachedSource = simulatorRouteCache[cacheKey]?.source;
|
||||
if (cachedPath.length >= 2 && (cachedSource === "kakao-directions" || (kakaoAccessBlocked && cachedSource === "osrm-route"))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let route = null;
|
||||
|
||||
if (!kakaoAccessBlocked) {
|
||||
try {
|
||||
route = await fetchKakaoDirections(from, to);
|
||||
} catch (error) {
|
||||
kakaoFailureMessage ||= error.message;
|
||||
if (isKakaoDirectionsAccessError(error)) {
|
||||
kakaoAccessBlocked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
route ||= await fetchOsrmDirections(from, to);
|
||||
if (route.path.length >= 2) {
|
||||
simulatorRouteCache[cacheKey] = {
|
||||
source: route.source,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
distanceMeters: route.distanceMeters,
|
||||
durationSeconds: route.durationSeconds,
|
||||
path: route.path
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
osrmFailureMessage ||= error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
writeJsonAtomic(SIMULATOR_ROUTES_FILE, simulatorRouteCache);
|
||||
console.log("CargoRadar simulator road routes cached.");
|
||||
}
|
||||
|
||||
if (kakaoFailureMessage) {
|
||||
console.warn(`CargoRadar simulator could not use Kakao Directions for every segment: ${kakaoFailureMessage}`);
|
||||
}
|
||||
|
||||
if (osrmFailureMessage) {
|
||||
console.warn(`CargoRadar simulator using internal fallback routes for uncached segments: ${osrmFailureMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isKakaoDirectionsAccessError(error) {
|
||||
return /http_401|kakao_directions_-401|ip mismatched/i.test(error.message || "");
|
||||
}
|
||||
|
||||
function getSimulatorSegmentKey(vehicle, index, from, to) {
|
||||
return [
|
||||
vehicle.driverId,
|
||||
index,
|
||||
roundNumber(from.latitude, 5),
|
||||
roundNumber(from.longitude, 5),
|
||||
roundNumber(to.latitude, 5),
|
||||
roundNumber(to.longitude, 5)
|
||||
].join(":");
|
||||
}
|
||||
|
||||
async function fetchKakaoDirections(from, to) {
|
||||
const requestUrl = new URL(KAKAO_DIRECTIONS_URL);
|
||||
requestUrl.searchParams.set("origin", `${from.longitude},${from.latitude}`);
|
||||
requestUrl.searchParams.set("destination", `${to.longitude},${to.latitude}`);
|
||||
requestUrl.searchParams.set("priority", "RECOMMEND");
|
||||
requestUrl.searchParams.set("summary", "false");
|
||||
requestUrl.searchParams.set("car_fuel", "DIESEL");
|
||||
requestUrl.searchParams.set("car_hipass", "true");
|
||||
|
||||
const payload = await requestJson(requestUrl, {
|
||||
Authorization: `KakaoAK ${KAKAO_REST_API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
});
|
||||
|
||||
if (payload.code) {
|
||||
throw new Error(`kakao_directions_${payload.code}: ${payload.msg || "request_failed"}`);
|
||||
}
|
||||
|
||||
return parseKakaoDirections(payload);
|
||||
}
|
||||
|
||||
function parseKakaoDirections(payload) {
|
||||
const route = Array.isArray(payload.routes) ? payload.routes[0] : null;
|
||||
if (!route || route.result_code !== 0) {
|
||||
throw new Error(`kakao_directions_result_${route?.result_code ?? "missing"}`);
|
||||
}
|
||||
|
||||
const path = [];
|
||||
for (const section of route.sections || []) {
|
||||
for (const road of section.roads || []) {
|
||||
const vertexes = Array.isArray(road.vertexes) ? road.vertexes : [];
|
||||
for (let index = 0; index < vertexes.length - 1; index += 2) {
|
||||
pushUniqueRoutePoint(path, {
|
||||
latitude: Number(vertexes[index + 1]),
|
||||
longitude: Number(vertexes[index])
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
destinationPoint: to
|
||||
source: "kakao-directions",
|
||||
distanceMeters: Number(route.summary?.distance) || Math.round(calculatePathDistanceKm(path) * 1000),
|
||||
durationSeconds: Number(route.summary?.duration) || 0,
|
||||
path: simplifyRoutePath(path, 240)
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchOsrmDirections(from, to) {
|
||||
const baseUrl = OSRM_ROUTE_URL.replace(/\/$/, "");
|
||||
const requestUrl = new URL(`${baseUrl}/${from.longitude},${from.latitude};${to.longitude},${to.latitude}`);
|
||||
requestUrl.searchParams.set("overview", "full");
|
||||
requestUrl.searchParams.set("geometries", "geojson");
|
||||
requestUrl.searchParams.set("steps", "false");
|
||||
|
||||
const payload = await requestJson(requestUrl, {});
|
||||
return parseOsrmDirections(payload);
|
||||
}
|
||||
|
||||
function parseOsrmDirections(payload) {
|
||||
if (payload.code !== "Ok") {
|
||||
throw new Error(`osrm_route_${payload.code || "request_failed"}: ${payload.message || "request_failed"}`);
|
||||
}
|
||||
|
||||
const route = Array.isArray(payload.routes) ? payload.routes[0] : null;
|
||||
const coordinates = Array.isArray(route?.geometry?.coordinates) ? route.geometry.coordinates : [];
|
||||
const path = coordinates.map((coordinate) => ({
|
||||
latitude: Number(coordinate[1]),
|
||||
longitude: Number(coordinate[0])
|
||||
}));
|
||||
|
||||
if (path.length < 2) {
|
||||
throw new Error("osrm_route_missing_geometry");
|
||||
}
|
||||
|
||||
return {
|
||||
source: "osrm-route",
|
||||
distanceMeters: Number(route.distance) || Math.round(calculatePathDistanceKm(path) * 1000),
|
||||
durationSeconds: Number(route.duration) || 0,
|
||||
path: simplifyRoutePath(path, 240)
|
||||
};
|
||||
}
|
||||
|
||||
function requestJson(requestUrl, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(requestUrl, { headers, timeout: 8000 }, (res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
try {
|
||||
const payload = JSON.parse(body || "{}");
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`http_${res.statusCode}: ${payload.msg || payload.error || body.slice(0, 80)}`));
|
||||
return;
|
||||
}
|
||||
resolve(payload);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error("request_timeout"));
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function pushUniqueRoutePoint(path, value) {
|
||||
const pointValue = normalizeRoutePoint(value);
|
||||
if (!pointValue) return;
|
||||
|
||||
const previous = path[path.length - 1];
|
||||
if (previous && calculateDistanceKm(previous, pointValue) < 0.005) return;
|
||||
path.push(pointValue);
|
||||
}
|
||||
|
||||
function simplifyRoutePath(path, limit) {
|
||||
const normalized = normalizeRoutePath(path);
|
||||
if (normalized.length <= limit) return normalized;
|
||||
|
||||
const result = [];
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
const sourceIndex = Math.round(index * (normalized.length - 1) / (limit - 1));
|
||||
result.push(normalized[sourceIndex]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function interpolatePathAtDistance(path, distanceKm) {
|
||||
if (path.length < 2) {
|
||||
const pointValue = path[0] || { latitude: 0, longitude: 0 };
|
||||
return { point: pointValue, heading: 0, index: 0 };
|
||||
}
|
||||
|
||||
let traveledKm = 0;
|
||||
for (let index = 1; index < path.length; index += 1) {
|
||||
const from = path[index - 1];
|
||||
const to = path[index];
|
||||
const segmentDistanceKm = calculateDistanceKm(from, to);
|
||||
|
||||
if (traveledKm + segmentDistanceKm >= distanceKm) {
|
||||
const progress = segmentDistanceKm <= 0 ? 0 : (distanceKm - traveledKm) / segmentDistanceKm;
|
||||
return {
|
||||
point: {
|
||||
latitude: roundNumber(interpolateNumber(from.latitude, to.latitude, progress), 6),
|
||||
longitude: roundNumber(interpolateNumber(from.longitude, to.longitude, progress), 6)
|
||||
},
|
||||
heading: calculateBearing(from, to),
|
||||
index
|
||||
};
|
||||
}
|
||||
|
||||
traveledKm += segmentDistanceKm;
|
||||
}
|
||||
|
||||
const last = path[path.length - 1];
|
||||
const previous = path[path.length - 2] || last;
|
||||
return {
|
||||
point: last,
|
||||
heading: calculateBearing(previous, last),
|
||||
index: path.length - 1
|
||||
};
|
||||
}
|
||||
|
||||
function slicePathFromDistance(path, distanceKm) {
|
||||
const position = interpolatePathAtDistance(path, distanceKm);
|
||||
const rest = path.slice(position.index);
|
||||
return simplifyRoutePath([position.point, ...rest], 240);
|
||||
}
|
||||
|
||||
function storeLocation(location, options = {}) {
|
||||
const appendHistory = options.appendHistory !== false;
|
||||
const persistLatest = options.persistLatest !== false;
|
||||
@@ -819,7 +1169,7 @@ function normalizeRoutePath(value) {
|
||||
return value
|
||||
.map((pointValue) => normalizeRoutePoint(pointValue))
|
||||
.filter(Boolean)
|
||||
.slice(0, 48);
|
||||
.slice(0, 240);
|
||||
}
|
||||
|
||||
function normalizeRoutePoint(value) {
|
||||
|
||||
Reference in New Issue
Block a user