diff --git a/.env.example b/.env.example index 7f6e340..6fdc0a9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.nas.example b/.env.nas.example index 6c69cec..a1cc7fa 100644 --- a/.env.nas.example +++ b/.env.nas.example @@ -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 diff --git a/README.md b/README.md index ac11da3..81bce1e 100644 --- a/README.md +++ b/README.md @@ -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= +KAKAO_REST_API_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: diff --git a/apps/server/src/server.js b/apps/server/src/server.js index f7efbd2..6b6980d 100644 --- a/apps/server/src/server.js +++ b/apps/server/src/server.js @@ -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) { diff --git a/apps/web/public/app.js b/apps/web/public/app.js index bc0a71a..736a630 100644 --- a/apps/web/public/app.js +++ b/apps/web/public/app.js @@ -5,7 +5,7 @@ const state = { infoOverlay: null, routeOverlay: null, routeDestinationOverlay: null, - markerAnimationMs: 900, + markerAnimationMs: 4800, drivers: new Map(), locations: new Map(), selectedDriverId: "", diff --git a/docker-compose.yml b/docker-compose.yml index 4ec80b9..1b2b426 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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