도로 경로 기반 시뮬레이터 개선

This commit is contained in:
2026-06-06 20:43:05 +09:00
parent e5ae236b8a
commit 3b21cfb9a9
6 changed files with 402 additions and 33 deletions

View File

@@ -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) {