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

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

@@ -2,5 +2,8 @@ CARGORADAR_PORT=8080
ADMIN_TOKEN=
ADMIN_AUTH_REQUIRED=false
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
KAKAO_REST_API_KEY=91e5df96769ba97fc83e9ace9a4fec1e
OSRM_ROUTE_URL=https://router.project-osrm.org/route/v1/driving
SIMULATOR_ENABLED=true
SIMULATOR_INTERVAL_MS=1000
SIMULATOR_INTERVAL_MS=5000
SIMULATOR_SPEED_SCALE=1

View File

@@ -2,5 +2,8 @@ CARGORADAR_PORT=18081
ADMIN_TOKEN=change_this_admin_token
ADMIN_AUTH_REQUIRED=false
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
KAKAO_REST_API_KEY=91e5df96769ba97fc83e9ace9a4fec1e
OSRM_ROUTE_URL=https://router.project-osrm.org/route/v1/driving
SIMULATOR_ENABLED=true
SIMULATOR_INTERVAL_MS=1000
SIMULATOR_INTERVAL_MS=5000
SIMULATOR_SPEED_SCALE=1

View File

@@ -24,7 +24,7 @@ Open:
http://localhost:8080
```
The web screen has a `샘플 위치` button that posts a demo location with the
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
@@ -56,8 +56,11 @@ 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. The web map interpolates between fresh
positions so vehicles move smoothly instead of jumping between points.
`SIMULATOR_INTERVAL_MS` milliseconds, 5000 ms by default. It follows cached
Kakao Mobility road routes when `KAKAO_REST_API_KEY` is valid, uses OSRM road
routes as a demo fallback, and only then falls back to an internal route.
`SIMULATOR_SPEED_SCALE` defaults to 1 so the map movement stays close to the
displayed vehicle speed.
## Maintenance Scripts
@@ -86,10 +89,14 @@ override it in `.env`:
```text
KAKAO_JAVASCRIPT_KEY=<override_key>
KAKAO_REST_API_KEY=<directions_key>
OSRM_ROUTE_URL=https://router.project-osrm.org/route/v1/driving
```
The REST API key and native app key are not needed for the current web map
screen.
The REST API key is used only by the server-side simulator to cache road route
geometry from Kakao Mobility Directions. If the key has an IP allowlist, add the
server/NAS public IP before expecting Kakao-based simulation. Until then, the
server can still use OSRM road geometry for demo movement.
In Kakao Developers, register every web origin that will open the control
screen. Useful early entries are:

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

View File

@@ -5,7 +5,7 @@ const state = {
infoOverlay: null,
routeOverlay: null,
routeDestinationOverlay: null,
markerAnimationMs: 900,
markerAnimationMs: 4800,
drivers: new Map(),
locations: new Map(),
selectedDriverId: "",

View File

@@ -11,7 +11,13 @@ services:
DATA_DIR: /data
WEB_ROOT: /app/apps/web/public
ADMIN_TOKEN: ${ADMIN_TOKEN:-}
ADMIN_AUTH_REQUIRED: ${ADMIN_AUTH_REQUIRED:-false}
KAKAO_JAVASCRIPT_KEY: ${KAKAO_JAVASCRIPT_KEY:-}
KAKAO_REST_API_KEY: ${KAKAO_REST_API_KEY:-}
OSRM_ROUTE_URL: ${OSRM_ROUTE_URL:-https://router.project-osrm.org/route/v1/driving}
SIMULATOR_ENABLED: ${SIMULATOR_ENABLED:-true}
SIMULATOR_INTERVAL_MS: ${SIMULATOR_INTERVAL_MS:-5000}
SIMULATOR_SPEED_SCALE: ${SIMULATOR_SPEED_SCALE:-1}
volumes:
- ./runtime-data:/data
- ./apps/server/src:/app/apps/server/src:ro