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

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_TOKEN=
ADMIN_AUTH_REQUIRED=false ADMIN_AUTH_REQUIRED=false
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
KAKAO_REST_API_KEY=91e5df96769ba97fc83e9ace9a4fec1e
OSRM_ROUTE_URL=https://router.project-osrm.org/route/v1/driving
SIMULATOR_ENABLED=true 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_TOKEN=change_this_admin_token
ADMIN_AUTH_REQUIRED=false ADMIN_AUTH_REQUIRED=false
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423 KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
KAKAO_REST_API_KEY=91e5df96769ba97fc83e9ace9a4fec1e
OSRM_ROUTE_URL=https://router.project-osrm.org/route/v1/driving
SIMULATOR_ENABLED=true 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 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`. default `demo-token`.
By default the server also runs a test simulator with 10 vehicles moving around 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 During early testing, keep `SIMULATOR_ENABLED=true` to show moving demo
vehicles without Android phones. The simulator publishes fresh locations every vehicles without Android phones. The simulator publishes fresh locations every
`SIMULATOR_INTERVAL_MS` milliseconds. The web map interpolates between fresh `SIMULATOR_INTERVAL_MS` milliseconds, 5000 ms by default. It follows cached
positions so vehicles move smoothly instead of jumping between points. 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 ## Maintenance Scripts
@@ -86,10 +89,14 @@ override it in `.env`:
```text ```text
KAKAO_JAVASCRIPT_KEY=<override_key> 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 The REST API key is used only by the server-side simulator to cache road route
screen. 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 In Kakao Developers, register every web origin that will open the control
screen. Useful early entries are: screen. Useful early entries are:

View File

@@ -1,4 +1,5 @@
const fs = require("node:fs"); const fs = require("node:fs");
const https = require("node:https");
const http = require("node:http"); const http = require("node:http");
const path = require("node:path"); const path = require("node:path");
const { randomUUID } = require("node:crypto"); 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_TOKEN = process.env.ADMIN_TOKEN || "";
const ADMIN_AUTH_REQUIRED = parseBoolean(process.env.ADMIN_AUTH_REQUIRED, false); const ADMIN_AUTH_REQUIRED = parseBoolean(process.env.ADMIN_AUTH_REQUIRED, false);
const DEFAULT_KAKAO_JAVASCRIPT_KEY = "07f4728599ceaf4299f77c772a135423"; 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_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_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 DRIVERS_FILE = path.join(DATA_DIR, "drivers.json");
const LATEST_FILE = path.join(DATA_DIR, "latest-locations.json"); const LATEST_FILE = path.join(DATA_DIR, "latest-locations.json");
const HISTORY_FILE = path.join(DATA_DIR, "locations.jsonl"); 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([ const VEHICLE_TYPES = new Set([
"cargo-truck", "cargo-truck",
@@ -215,11 +222,13 @@ const SIMULATED_FLEET = [
const sseClients = new Set(); const sseClients = new Set();
let simulatorTimer = null; let simulatorTimer = null;
let simulatorTick = 0; let simulatorTick = 0;
let simulatorRouteCache = {};
ensureDataFiles(); ensureDataFiles();
let drivers = loadDrivers(); let drivers = loadDrivers();
let latestLocations = loadLatestLocations(); let latestLocations = loadLatestLocations();
simulatorRouteCache = loadSimulatorRouteCache();
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
@@ -398,6 +407,10 @@ function ensureDataFiles() {
if (!fs.existsSync(HISTORY_FILE)) { if (!fs.existsSync(HISTORY_FILE)) {
fs.writeFileSync(HISTORY_FILE, "", "utf8"); fs.writeFileSync(HISTORY_FILE, "", "utf8");
} }
if (!fs.existsSync(SIMULATOR_ROUTES_FILE)) {
writeJsonAtomic(SIMULATOR_ROUTES_FILE, {});
}
} }
function loadDrivers() { function loadDrivers() {
@@ -405,6 +418,11 @@ function loadDrivers() {
return Array.isArray(parsed) ? parsed : []; 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) { function createDriver(body) {
const driverId = normalizeDriverId(body.driverId) || generateDriverId(); const driverId = normalizeDriverId(body.driverId) || generateDriverId();
@@ -488,6 +506,20 @@ function startSimulator() {
} }
ensureSimulatorDrivers(); 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(); publishSimulatorLocations();
simulatorTimer = setInterval(publishSimulatorLocations, SIMULATOR_INTERVAL_MS); simulatorTimer = setInterval(publishSimulatorLocations, SIMULATOR_INTERVAL_MS);
@@ -495,7 +527,7 @@ function startSimulator() {
simulatorTimer.unref(); simulatorTimer.unref();
} }
console.log(`CargoRadar simulator running with ${SIMULATED_FLEET.length} vehicles.`); console.log("CargoRadar simulator publishing locations.");
} }
function ensureSimulatorDrivers() { function ensureSimulatorDrivers() {
@@ -563,15 +595,10 @@ function publishSimulatorLocations() {
} }
function createSimulatorLocationBody(vehicle, nowMs) { 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 speedWave = Math.sin((nowMs + hashOffset(vehicle.vehicleNo)) / 17000) * 8;
const speed = Math.max(18, Math.round(vehicle.speedBase + speedWave)); const speed = Math.max(18, Math.round(vehicle.speedBase + speedWave));
const routePath = createSimulatedRoutePath( const remainingDistanceKm = roundNumber(routeState.remainingDistanceKm, 1);
{ latitude: routeState.latitude, longitude: routeState.longitude },
routeState.destinationPoint,
vehicle.driverId
);
const remainingDistanceKm = roundNumber(calculatePathDistanceKm(routePath) * 1.08, 1);
const etaMinutes = Math.max(3, Math.round((remainingDistanceKm / Math.max(speed, 24)) * 60)); const etaMinutes = Math.max(3, Math.round((remainingDistanceKm / Math.max(speed, 24)) * 60));
return { return {
@@ -590,8 +617,8 @@ function createSimulatorLocationBody(vehicle, nowMs) {
destination: routeState.destination, destination: routeState.destination,
destinationLatitude: routeState.destinationPoint.latitude, destinationLatitude: routeState.destinationPoint.latitude,
destinationLongitude: routeState.destinationPoint.longitude, destinationLongitude: routeState.destinationPoint.longitude,
routePath, routePath: routeState.remainingPath,
routeSource: "simulated", routeSource: routeState.routeSource,
remainingDistanceKm, remainingDistanceKm,
etaMinutes, etaMinutes,
estimatedArrivalAt: new Date(nowMs + etaMinutes * 60 * 1000).toISOString(), estimatedArrivalAt: new Date(nowMs + etaMinutes * 60 * 1000).toISOString(),
@@ -601,24 +628,347 @@ function createSimulatorLocationBody(vehicle, nowMs) {
}; };
} }
function interpolateRoute(route, nowMs, loopMs, offsetMs) { function calculateSimulatorRouteState(vehicle, nowMs) {
const progress = ((nowMs + offsetMs) % loopMs) / loopMs; const segments = buildSimulatorSegments(vehicle);
const segmentPosition = progress * route.length; const totalDistanceKm = segments.reduce((total, segment) => total + segment.distanceKm, 0);
const segmentIndex = Math.floor(segmentPosition) % route.length;
const localProgress = smoothStep(segmentPosition - segmentIndex); if (!segments.length || totalDistanceKm <= 0) {
const from = route[segmentIndex]; const fallbackFrom = vehicle.route[0];
const to = route[(segmentIndex + 1) % route.length]; 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 { return {
latitude: interpolateNumber(from.latitude, to.latitude, localProgress), source: "kakao-directions",
longitude: interpolateNumber(from.longitude, to.longitude, localProgress), distanceMeters: Number(route.summary?.distance) || Math.round(calculatePathDistanceKm(path) * 1000),
heading: Math.round(calculateBearing(from, to)), durationSeconds: Number(route.summary?.duration) || 0,
origin: from.name, path: simplifyRoutePath(path, 240)
destination: to.name,
destinationPoint: to
}; };
} }
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 = {}) { function storeLocation(location, options = {}) {
const appendHistory = options.appendHistory !== false; const appendHistory = options.appendHistory !== false;
const persistLatest = options.persistLatest !== false; const persistLatest = options.persistLatest !== false;
@@ -819,7 +1169,7 @@ function normalizeRoutePath(value) {
return value return value
.map((pointValue) => normalizeRoutePoint(pointValue)) .map((pointValue) => normalizeRoutePoint(pointValue))
.filter(Boolean) .filter(Boolean)
.slice(0, 48); .slice(0, 240);
} }
function normalizeRoutePoint(value) { function normalizeRoutePoint(value) {

View File

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

View File

@@ -11,7 +11,13 @@ services:
DATA_DIR: /data DATA_DIR: /data
WEB_ROOT: /app/apps/web/public WEB_ROOT: /app/apps/web/public
ADMIN_TOKEN: ${ADMIN_TOKEN:-} ADMIN_TOKEN: ${ADMIN_TOKEN:-}
ADMIN_AUTH_REQUIRED: ${ADMIN_AUTH_REQUIRED:-false}
KAKAO_JAVASCRIPT_KEY: ${KAKAO_JAVASCRIPT_KEY:-} 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: volumes:
- ./runtime-data:/data - ./runtime-data:/data
- ./apps/server/src:/app/apps/server/src:ro - ./apps/server/src:/app/apps/server/src:ro