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