전국 화물차 시뮬레이터 추가
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
CARGORADAR_PORT=8080
|
||||
ADMIN_TOKEN=
|
||||
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||
SIMULATOR_ENABLED=true
|
||||
SIMULATOR_INTERVAL_MS=4000
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
CARGORADAR_PORT=18081
|
||||
ADMIN_TOKEN=change_this_admin_token
|
||||
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||
SIMULATOR_ENABLED=true
|
||||
SIMULATOR_INTERVAL_MS=4000
|
||||
|
||||
28
README.md
28
README.md
@@ -27,6 +27,13 @@ http://localhost:8080
|
||||
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
|
||||
Korea. Disable it when real driver phones are connected:
|
||||
|
||||
```text
|
||||
SIMULATOR_ENABLED=false
|
||||
```
|
||||
|
||||
## Run With Docker
|
||||
|
||||
```powershell
|
||||
@@ -47,6 +54,10 @@ history survive container restarts.
|
||||
For NAS, use `.env.nas.example` as the starting point and prefer port `18081`
|
||||
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.
|
||||
|
||||
## Maintenance Scripts
|
||||
|
||||
Check server status:
|
||||
@@ -114,6 +125,23 @@ The default driver is:
|
||||
|
||||
Change the token before real use.
|
||||
|
||||
## Test Simulator
|
||||
|
||||
The built-in simulator creates 10 test vehicles with fixed device tokens and
|
||||
routes across Seoul, Incheon, Daejeon, Busan, Gwangju, Ulsan, Gangwon, Jeju,
|
||||
and other regional hubs. It includes several freight vehicle types:
|
||||
|
||||
- `tractor-trailer`: 트레일러
|
||||
- `cargo-truck`: 카고트럭
|
||||
- `box-truck`: 탑차
|
||||
- `van`: 용달차
|
||||
- `refrigerated`: 냉동탑차
|
||||
- `container`: 컨테이너
|
||||
- `wingbody`: 윙바디
|
||||
|
||||
Each simulator tick updates latest positions through the same SSE stream used
|
||||
by real Android uploads, so the control screen behaves like a live fleet map.
|
||||
|
||||
## API
|
||||
|
||||
Driver management:
|
||||
|
||||
@@ -10,12 +10,200 @@ const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, "..", "data");
|
||||
const WEB_ROOT = process.env.WEB_ROOT || path.resolve(__dirname, "..", "..", "web", "public");
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "";
|
||||
const DEFAULT_KAKAO_JAVASCRIPT_KEY = "07f4728599ceaf4299f77c772a135423";
|
||||
const SIMULATOR_ENABLED = parseBoolean(process.env.SIMULATOR_ENABLED, true);
|
||||
const SIMULATOR_INTERVAL_MS = clampNumber(Number(process.env.SIMULATOR_INTERVAL_MS || 4000), 1000, 60000);
|
||||
const SIMULATOR_HISTORY_EVERY = clampNumber(Number(process.env.SIMULATOR_HISTORY_EVERY || 3), 1, 30);
|
||||
|
||||
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 VEHICLE_TYPES = new Set([
|
||||
"cargo-truck",
|
||||
"tractor-trailer",
|
||||
"box-truck",
|
||||
"van",
|
||||
"refrigerated",
|
||||
"container",
|
||||
"wingbody"
|
||||
]);
|
||||
|
||||
const SIMULATED_FLEET = [
|
||||
{
|
||||
driverId: "sim-01",
|
||||
name: "강민준",
|
||||
vehicleNo: "서울 91바 1024",
|
||||
token: "sim-token-01",
|
||||
vehicleType: "tractor-trailer",
|
||||
cargoName: "자동차 부품",
|
||||
cargoWeight: "18t",
|
||||
speedBase: 76,
|
||||
loopMs: 16 * 60 * 1000,
|
||||
route: [
|
||||
point("인천항", 37.4563, 126.7052),
|
||||
point("평택 물류단지", 36.9921, 127.1128),
|
||||
point("대전 허브", 36.3504, 127.3845),
|
||||
point("부산신항", 35.0812, 128.8291)
|
||||
]
|
||||
},
|
||||
{
|
||||
driverId: "sim-02",
|
||||
name: "이서준",
|
||||
vehicleNo: "부산 82사 7731",
|
||||
token: "sim-token-02",
|
||||
vehicleType: "container",
|
||||
cargoName: "수출 컨테이너",
|
||||
cargoWeight: "22t",
|
||||
speedBase: 72,
|
||||
loopMs: 15 * 60 * 1000,
|
||||
route: [
|
||||
point("부산항", 35.1028, 129.0403),
|
||||
point("대구 물류센터", 35.8714, 128.6014),
|
||||
point("대전 터미널", 36.3504, 127.3845),
|
||||
point("서울 남부센터", 37.4813, 127.0367)
|
||||
]
|
||||
},
|
||||
{
|
||||
driverId: "sim-03",
|
||||
name: "박도윤",
|
||||
vehicleNo: "경기 87아 4588",
|
||||
token: "sim-token-03",
|
||||
vehicleType: "cargo-truck",
|
||||
cargoName: "건축 자재",
|
||||
cargoWeight: "8t",
|
||||
speedBase: 68,
|
||||
loopMs: 13 * 60 * 1000,
|
||||
route: [
|
||||
point("남양주 창고", 37.6360, 127.2165),
|
||||
point("원주 하차지", 37.3422, 127.9202),
|
||||
point("강릉 공사현장", 37.7519, 128.8761),
|
||||
point("이천 집하장", 37.2792, 127.4425)
|
||||
]
|
||||
},
|
||||
{
|
||||
driverId: "sim-04",
|
||||
name: "최하준",
|
||||
vehicleNo: "인천 94자 2190",
|
||||
token: "sim-token-04",
|
||||
vehicleType: "van",
|
||||
cargoName: "소형 택배",
|
||||
cargoWeight: "0.9t",
|
||||
speedBase: 54,
|
||||
loopMs: 9 * 60 * 1000,
|
||||
route: [
|
||||
point("서울 성수", 37.5446, 127.0557),
|
||||
point("수원 권선", 37.2636, 127.0286),
|
||||
point("천안 서북", 36.8151, 127.1139),
|
||||
point("대전 유성", 36.3622, 127.3563)
|
||||
]
|
||||
},
|
||||
{
|
||||
driverId: "sim-05",
|
||||
name: "정지호",
|
||||
vehicleNo: "전남 86바 6405",
|
||||
token: "sim-token-05",
|
||||
vehicleType: "refrigerated",
|
||||
cargoName: "냉장 식품",
|
||||
cargoWeight: "3.5t",
|
||||
speedBase: 62,
|
||||
loopMs: 12 * 60 * 1000,
|
||||
route: [
|
||||
point("평택 냉장창고", 36.9948, 127.0889),
|
||||
point("전주 물류센터", 35.8242, 127.1480),
|
||||
point("광주 도매시장", 35.1595, 126.8526),
|
||||
point("목포 항만", 34.8118, 126.3922)
|
||||
]
|
||||
},
|
||||
{
|
||||
driverId: "sim-06",
|
||||
name: "오시우",
|
||||
vehicleNo: "광주 80다 3317",
|
||||
token: "sim-token-06",
|
||||
vehicleType: "wingbody",
|
||||
cargoName: "생활용품 팔레트",
|
||||
cargoWeight: "5t",
|
||||
speedBase: 66,
|
||||
loopMs: 11 * 60 * 1000,
|
||||
route: [
|
||||
point("광주 광산", 35.1395, 126.7937),
|
||||
point("전주 완산", 35.8160, 127.1530),
|
||||
point("논산 집하장", 36.1871, 127.0987),
|
||||
point("대전 허브", 36.3504, 127.3845)
|
||||
]
|
||||
},
|
||||
{
|
||||
driverId: "sim-07",
|
||||
name: "한유준",
|
||||
vehicleNo: "울산 89사 5022",
|
||||
token: "sim-token-07",
|
||||
vehicleType: "box-truck",
|
||||
cargoName: "전자제품",
|
||||
cargoWeight: "2.5t",
|
||||
speedBase: 61,
|
||||
loopMs: 10 * 60 * 1000,
|
||||
route: [
|
||||
point("울산 공단", 35.5384, 129.3114),
|
||||
point("포항 남구", 36.0190, 129.3435),
|
||||
point("경주 외동", 35.8401, 129.2124),
|
||||
point("대구 성서", 35.8530, 128.5066)
|
||||
]
|
||||
},
|
||||
{
|
||||
driverId: "sim-08",
|
||||
name: "문태오",
|
||||
vehicleNo: "경남 92자 8816",
|
||||
token: "sim-token-08",
|
||||
vehicleType: "tractor-trailer",
|
||||
cargoName: "철강 코일",
|
||||
cargoWeight: "20t",
|
||||
speedBase: 58,
|
||||
loopMs: 14 * 60 * 1000,
|
||||
route: [
|
||||
point("여수 산단", 34.7604, 127.6622),
|
||||
point("순천 물류장", 34.9506, 127.4872),
|
||||
point("진주 하치장", 35.1800, 128.1076),
|
||||
point("창원 공단", 35.2279, 128.6811)
|
||||
]
|
||||
},
|
||||
{
|
||||
driverId: "sim-09",
|
||||
name: "서은우",
|
||||
vehicleNo: "제주 87허 1208",
|
||||
token: "sim-token-09",
|
||||
vehicleType: "van",
|
||||
cargoName: "호텔 린넨",
|
||||
cargoWeight: "0.7t",
|
||||
speedBase: 46,
|
||||
loopMs: 8 * 60 * 1000,
|
||||
route: [
|
||||
point("제주항", 33.5176, 126.5260),
|
||||
point("제주공항", 33.5071, 126.4930),
|
||||
point("서귀포 중문", 33.2541, 126.4123),
|
||||
point("성산 물류점", 33.4582, 126.9425)
|
||||
]
|
||||
},
|
||||
{
|
||||
driverId: "sim-10",
|
||||
name: "유도현",
|
||||
vehicleNo: "충북 83바 7344",
|
||||
token: "sim-token-10",
|
||||
vehicleType: "cargo-truck",
|
||||
cargoName: "농산물",
|
||||
cargoWeight: "4.5t",
|
||||
speedBase: 57,
|
||||
loopMs: 12 * 60 * 1000,
|
||||
route: [
|
||||
point("청주 집하장", 36.6424, 127.4890),
|
||||
point("문경 산지센터", 36.5866, 128.1868),
|
||||
point("안동 물류장", 36.5684, 128.7294),
|
||||
point("경주 하차지", 35.8562, 129.2247)
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const sseClients = new Set();
|
||||
let simulatorTimer = null;
|
||||
let simulatorTick = 0;
|
||||
|
||||
ensureDataFiles();
|
||||
|
||||
@@ -137,11 +325,7 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
const location = createLocationRecord(body, driver);
|
||||
latestLocations[location.driverId] = location;
|
||||
|
||||
appendJsonLine(HISTORY_FILE, location);
|
||||
writeJsonAtomic(LATEST_FILE, latestLocations);
|
||||
broadcastEvent("location.updated", location);
|
||||
storeLocation(location);
|
||||
|
||||
sendJson(res, 201, { location });
|
||||
return;
|
||||
@@ -163,12 +347,17 @@ const server = http.createServer(async (req, res) => {
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`CargoRadar server listening on http://${HOST}:${PORT}`);
|
||||
console.log(`Serving web files from ${WEB_ROOT}`);
|
||||
startSimulator();
|
||||
});
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
function shutdown() {
|
||||
if (simulatorTimer) {
|
||||
clearInterval(simulatorTimer);
|
||||
}
|
||||
|
||||
for (const client of sseClients) {
|
||||
client.end();
|
||||
}
|
||||
@@ -223,8 +412,10 @@ function createDriver(body) {
|
||||
name,
|
||||
vehicleNo,
|
||||
phone: String(body.phone || "").trim(),
|
||||
vehicleType: normalizeVehicleType(body.vehicleType),
|
||||
token: String(body.token || "").trim() || generateDeviceToken(),
|
||||
enabled: body.enabled === undefined ? true : Boolean(body.enabled),
|
||||
simulated: Boolean(body.simulated),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
@@ -253,6 +444,10 @@ function updateDriver(driverId, body) {
|
||||
driver.phone = String(body.phone || "").trim();
|
||||
}
|
||||
|
||||
if (body.vehicleType !== undefined) {
|
||||
driver.vehicleType = normalizeVehicleType(body.vehicleType);
|
||||
}
|
||||
|
||||
if (body.enabled !== undefined) {
|
||||
driver.enabled = Boolean(body.enabled);
|
||||
}
|
||||
@@ -275,6 +470,144 @@ function loadLatestLocations() {
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
}
|
||||
|
||||
function startSimulator() {
|
||||
if (!SIMULATOR_ENABLED) {
|
||||
console.log("CargoRadar simulator disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
ensureSimulatorDrivers();
|
||||
publishSimulatorLocations();
|
||||
simulatorTimer = setInterval(publishSimulatorLocations, SIMULATOR_INTERVAL_MS);
|
||||
|
||||
if (typeof simulatorTimer.unref === "function") {
|
||||
simulatorTimer.unref();
|
||||
}
|
||||
|
||||
console.log(`CargoRadar simulator running with ${SIMULATED_FLEET.length} vehicles.`);
|
||||
}
|
||||
|
||||
function ensureSimulatorDrivers() {
|
||||
let changed = false;
|
||||
|
||||
for (const vehicle of SIMULATED_FLEET) {
|
||||
const existing = drivers.find((driver) => driver.driverId === vehicle.driverId);
|
||||
const next = {
|
||||
driverId: vehicle.driverId,
|
||||
name: vehicle.name,
|
||||
vehicleNo: vehicle.vehicleNo,
|
||||
phone: "",
|
||||
token: vehicle.token,
|
||||
vehicleType: normalizeVehicleType(vehicle.vehicleType),
|
||||
enabled: true,
|
||||
simulated: true
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
drivers.push({
|
||||
...next,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let driverChanged = false;
|
||||
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
if (existing[key] !== value) {
|
||||
existing[key] = value;
|
||||
driverChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (driverChanged) {
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
writeJsonAtomic(DRIVERS_FILE, drivers);
|
||||
broadcastEvent("drivers.updated", { drivers: drivers.map(toDriverResponse).sort(compareDrivers) });
|
||||
}
|
||||
}
|
||||
|
||||
function publishSimulatorLocations() {
|
||||
simulatorTick += 1;
|
||||
const nowMs = Date.now();
|
||||
const appendHistory = simulatorTick % SIMULATOR_HISTORY_EVERY === 1;
|
||||
|
||||
for (const vehicle of SIMULATED_FLEET) {
|
||||
const driver = drivers.find((candidate) => candidate.driverId === vehicle.driverId);
|
||||
if (!driver || driver.enabled === false) continue;
|
||||
|
||||
const body = createSimulatorLocationBody(vehicle, nowMs);
|
||||
const location = createLocationRecord(body, driver);
|
||||
storeLocation(location, { appendHistory, persistLatest: false });
|
||||
}
|
||||
|
||||
writeJsonAtomic(LATEST_FILE, latestLocations);
|
||||
}
|
||||
|
||||
function createSimulatorLocationBody(vehicle, nowMs) {
|
||||
const routeState = interpolateRoute(vehicle.route, nowMs, vehicle.loopMs, hashOffset(vehicle.driverId));
|
||||
const speedWave = Math.sin((nowMs + hashOffset(vehicle.vehicleNo)) / 17000) * 8;
|
||||
|
||||
return {
|
||||
driverId: vehicle.driverId,
|
||||
vehicleNo: vehicle.vehicleNo,
|
||||
vehicleType: vehicle.vehicleType,
|
||||
latitude: routeState.latitude,
|
||||
longitude: routeState.longitude,
|
||||
accuracy: 8,
|
||||
speed: Math.max(18, Math.round(vehicle.speedBase + speedWave)),
|
||||
heading: routeState.heading,
|
||||
cargoName: vehicle.cargoName,
|
||||
cargoWeight: vehicle.cargoWeight,
|
||||
origin: routeState.origin,
|
||||
destination: routeState.destination,
|
||||
provider: "simulator",
|
||||
simulated: true,
|
||||
recordedAt: new Date(nowMs).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
function storeLocation(location, options = {}) {
|
||||
const appendHistory = options.appendHistory !== false;
|
||||
const persistLatest = options.persistLatest !== false;
|
||||
|
||||
latestLocations[location.driverId] = location;
|
||||
|
||||
if (appendHistory) {
|
||||
appendJsonLine(HISTORY_FILE, location);
|
||||
}
|
||||
|
||||
if (persistLatest) {
|
||||
writeJsonAtomic(LATEST_FILE, latestLocations);
|
||||
}
|
||||
|
||||
broadcastEvent("location.updated", location);
|
||||
}
|
||||
|
||||
function readJsonFile(filePath, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
@@ -394,6 +727,7 @@ function createLocationRecord(body, driver) {
|
||||
driverId: driver.driverId,
|
||||
driverName: driver.name || driver.driverId,
|
||||
vehicleNo: String(body.vehicleNo || driver.vehicleNo || ""),
|
||||
vehicleType: normalizeVehicleType(body.vehicleType || driver.vehicleType),
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: optionalNumber(body.accuracy),
|
||||
@@ -405,11 +739,56 @@ function createLocationRecord(body, driver) {
|
||||
origin: optionalString(body.origin || body.departure, 80),
|
||||
destination: optionalString(body.destination || body.arrival, 80),
|
||||
provider: body.provider ? String(body.provider) : "",
|
||||
simulated: Boolean(body.simulated || driver.simulated),
|
||||
recordedAt: normalizeDate(body.recordedAt),
|
||||
receivedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVehicleType(value) {
|
||||
const vehicleType = String(value || "cargo-truck").trim().toLowerCase();
|
||||
return VEHICLE_TYPES.has(vehicleType) ? vehicleType : "cargo-truck";
|
||||
}
|
||||
|
||||
function point(name, latitude, longitude) {
|
||||
return { name, latitude, longitude };
|
||||
}
|
||||
|
||||
function smoothStep(value) {
|
||||
return value * value * (3 - 2 * value);
|
||||
}
|
||||
|
||||
function interpolateNumber(from, to, progress) {
|
||||
return from + (to - from) * progress;
|
||||
}
|
||||
|
||||
function calculateBearing(from, to) {
|
||||
const fromLatitude = toRadians(from.latitude);
|
||||
const toLatitude = toRadians(to.latitude);
|
||||
const deltaLongitude = toRadians(to.longitude - from.longitude);
|
||||
const y = Math.sin(deltaLongitude) * Math.cos(toLatitude);
|
||||
const x = Math.cos(fromLatitude) * Math.sin(toLatitude) -
|
||||
Math.sin(fromLatitude) * Math.cos(toLatitude) * Math.cos(deltaLongitude);
|
||||
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
||||
}
|
||||
|
||||
function toRadians(value) {
|
||||
return value * Math.PI / 180;
|
||||
}
|
||||
|
||||
function hashOffset(value) {
|
||||
let hash = 0;
|
||||
for (const character of String(value)) {
|
||||
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function parseBoolean(value, defaultValue) {
|
||||
if (value === undefined || value === null || value === "") return defaultValue;
|
||||
return !["0", "false", "no", "off"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function validationError(publicMessage) {
|
||||
const error = new Error(publicMessage);
|
||||
error.statusCode = 400;
|
||||
@@ -504,8 +883,10 @@ function toDriverResponse(driver) {
|
||||
name: driver.name || driver.driverId,
|
||||
vehicleNo: driver.vehicleNo || "",
|
||||
phone: driver.phone || "",
|
||||
vehicleType: normalizeVehicleType(driver.vehicleType),
|
||||
token: driver.token || "",
|
||||
enabled: driver.enabled !== false,
|
||||
simulated: Boolean(driver.simulated),
|
||||
createdAt: driver.createdAt || null,
|
||||
updatedAt: driver.updatedAt || null
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ const elements = {
|
||||
driverForm: document.getElementById("driverForm"),
|
||||
driverNameInput: document.getElementById("driverNameInput"),
|
||||
vehicleNoInput: document.getElementById("vehicleNoInput"),
|
||||
vehicleTypeInput: document.getElementById("vehicleTypeInput"),
|
||||
phoneInput: document.getElementById("phoneInput"),
|
||||
driverIdInput: document.getElementById("driverIdInput"),
|
||||
driverCount: document.getElementById("driverCount"),
|
||||
@@ -41,6 +42,44 @@ const elements = {
|
||||
historyList: document.getElementById("historyList")
|
||||
};
|
||||
|
||||
const VEHICLE_TYPES = {
|
||||
"cargo-truck": {
|
||||
label: "카고트럭",
|
||||
shortLabel: "카고",
|
||||
className: "type-cargo"
|
||||
},
|
||||
"tractor-trailer": {
|
||||
label: "트레일러",
|
||||
shortLabel: "트레",
|
||||
className: "type-trailer"
|
||||
},
|
||||
"box-truck": {
|
||||
label: "탑차",
|
||||
shortLabel: "탑차",
|
||||
className: "type-box"
|
||||
},
|
||||
"van": {
|
||||
label: "용달차",
|
||||
shortLabel: "용달",
|
||||
className: "type-van"
|
||||
},
|
||||
"refrigerated": {
|
||||
label: "냉동탑차",
|
||||
shortLabel: "냉동",
|
||||
className: "type-cold"
|
||||
},
|
||||
"container": {
|
||||
label: "컨테이너",
|
||||
shortLabel: "컨",
|
||||
className: "type-container"
|
||||
},
|
||||
"wingbody": {
|
||||
label: "윙바디",
|
||||
shortLabel: "윙",
|
||||
className: "type-wing"
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initializeMap();
|
||||
bindActions();
|
||||
@@ -232,7 +271,8 @@ function connectEvents() {
|
||||
replaceLocations(payload.locations || []);
|
||||
});
|
||||
state.eventSource.addEventListener("location.updated", (event) => {
|
||||
upsertLocation(JSON.parse(event.data), { focus: true });
|
||||
const location = JSON.parse(event.data);
|
||||
upsertLocation(location, { focus: state.selectedDriverId === location.driverId });
|
||||
});
|
||||
state.eventSource.addEventListener("drivers.updated", (event) => {
|
||||
const payload = JSON.parse(event.data);
|
||||
@@ -257,6 +297,7 @@ async function postDemoLocation() {
|
||||
body: JSON.stringify({
|
||||
driverId: driver.driverId,
|
||||
vehicleNo: driver.vehicleNo,
|
||||
vehicleType: driver.vehicleType || "cargo-truck",
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: 12,
|
||||
@@ -375,6 +416,7 @@ function renderDrivers() {
|
||||
|
||||
function createVehicleItem(location) {
|
||||
const status = getLocationStatus(location);
|
||||
const vehicleType = getVehicleType(location);
|
||||
const item = document.createElement("article");
|
||||
item.className = "vehicle-item";
|
||||
item.tabIndex = 0;
|
||||
@@ -389,14 +431,18 @@ function createVehicleItem(location) {
|
||||
badge.className = `badge ${status.key}`;
|
||||
badge.textContent = status.label;
|
||||
|
||||
const typeBadge = document.createElement("span");
|
||||
typeBadge.className = `vehicle-type-chip ${vehicleType.className}`;
|
||||
typeBadge.textContent = vehicleType.label;
|
||||
|
||||
const vehicleNo = document.createElement("strong");
|
||||
vehicleNo.textContent = location.vehicleNo || location.driverId;
|
||||
|
||||
title.append(badge, vehicleNo);
|
||||
title.append(badge, typeBadge, vehicleNo);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "vehicle-meta";
|
||||
meta.textContent = `${location.driverName || location.driverId} · ${formatDateTime(location.receivedAt)} · ${formatCoordinate(location.latitude)}, ${formatCoordinate(location.longitude)}`;
|
||||
meta.textContent = `${location.driverName || location.driverId} · ${getCargoSummary(location)} · ${getRouteSummary(location)} · ${formatDateTime(location.receivedAt)}`;
|
||||
|
||||
const speed = document.createElement("div");
|
||||
speed.className = "speed";
|
||||
@@ -417,6 +463,7 @@ function createVehicleItem(location) {
|
||||
}
|
||||
|
||||
function createDriverItem(driver) {
|
||||
const vehicleType = getVehicleType(driver);
|
||||
const item = document.createElement("article");
|
||||
item.className = "driver-item";
|
||||
|
||||
@@ -431,7 +478,7 @@ function createDriverItem(driver) {
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "vehicle-meta";
|
||||
meta.textContent = [driver.driverId, driver.phone].filter(Boolean).join(" · ");
|
||||
meta.textContent = [vehicleType.label, driver.driverId, driver.phone].filter(Boolean).join(" · ");
|
||||
|
||||
title.append(name, meta);
|
||||
|
||||
@@ -483,6 +530,7 @@ async function submitDriverForm(event) {
|
||||
const payload = {
|
||||
name: elements.driverNameInput.value.trim(),
|
||||
vehicleNo: elements.vehicleNoInput.value.trim(),
|
||||
vehicleType: elements.vehicleTypeInput.value,
|
||||
phone: elements.phoneInput.value.trim(),
|
||||
driverId: elements.driverIdInput.value.trim()
|
||||
};
|
||||
@@ -608,12 +656,13 @@ function updateKakaoMarker(location) {
|
||||
|
||||
function updateLeafletMarker(location) {
|
||||
const status = getLocationStatus(location);
|
||||
const vehicleType = getVehicleType(location);
|
||||
const position = [location.latitude, location.longitude];
|
||||
const icon = L.divIcon({
|
||||
className: `leaflet-truck-marker truck-marker ${status.key}`,
|
||||
html: createTruckMarkerHtml(location, status),
|
||||
iconSize: [58, 34],
|
||||
iconAnchor: [29, 34],
|
||||
className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className}`,
|
||||
html: createTruckMarkerHtml(location, vehicleType),
|
||||
iconSize: [74, 38],
|
||||
iconAnchor: [37, 38],
|
||||
popupAnchor: [0, -30]
|
||||
});
|
||||
|
||||
@@ -636,20 +685,24 @@ function updateLeafletMarker(location) {
|
||||
}
|
||||
|
||||
function createTruckMarkerElement(location, status) {
|
||||
const vehicleType = getVehicleType(location);
|
||||
const markerElement = document.createElement("button");
|
||||
markerElement.className = `truck-marker ${status.key}`;
|
||||
markerElement.className = `truck-marker ${status.key} ${vehicleType.className}`;
|
||||
markerElement.type = "button";
|
||||
markerElement.title = location.vehicleNo || location.driverId;
|
||||
markerElement.setAttribute("aria-label", `${location.vehicleNo || location.driverId} 위치 보기`);
|
||||
markerElement.innerHTML = createTruckMarkerHtml(location, status);
|
||||
markerElement.title = `${vehicleType.label} ${location.vehicleNo || location.driverId}`;
|
||||
markerElement.setAttribute("aria-label", `${vehicleType.label} ${location.vehicleNo || location.driverId} 위치 보기`);
|
||||
markerElement.innerHTML = createTruckMarkerHtml(location, vehicleType);
|
||||
markerElement.addEventListener("click", () => focusLocation(location));
|
||||
return markerElement;
|
||||
}
|
||||
|
||||
function createTruckMarkerHtml(location) {
|
||||
function createTruckMarkerHtml(location, vehicleType = getVehicleType(location)) {
|
||||
const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4);
|
||||
return `
|
||||
<span class="truck-marker__trailer">${escapeHtml(label)}</span>
|
||||
<span class="truck-marker__body">
|
||||
<span class="truck-marker__type">${escapeHtml(vehicleType.shortLabel)}</span>
|
||||
<span class="truck-marker__plate">${escapeHtml(label)}</span>
|
||||
</span>
|
||||
<span class="truck-marker__cab"></span>
|
||||
`;
|
||||
}
|
||||
@@ -793,6 +846,7 @@ function showInfoOverlay(location) {
|
||||
}
|
||||
|
||||
function createPopupElement(location, status) {
|
||||
const vehicleType = getVehicleType(location);
|
||||
const container = document.createElement("div");
|
||||
container.className = "kakao-popup";
|
||||
|
||||
@@ -801,7 +855,7 @@ function createPopupElement(location, status) {
|
||||
title.textContent = location.vehicleNo || location.driverId;
|
||||
|
||||
const driver = document.createElement("div");
|
||||
driver.textContent = `${location.driverName || location.driverId} · ${status.label}`;
|
||||
driver.textContent = `${vehicleType.label} · ${location.driverName || location.driverId} · ${status.label}`;
|
||||
|
||||
const cargo = document.createElement("div");
|
||||
cargo.className = "popup-cargo";
|
||||
@@ -872,6 +926,11 @@ function hideMapMessage() {
|
||||
elements.mapMessage.classList.add("is-hidden");
|
||||
}
|
||||
|
||||
function getVehicleType(source) {
|
||||
const key = String(source.vehicleType || "cargo-truck").trim();
|
||||
return VEHICLE_TYPES[key] || VEHICLE_TYPES["cargo-truck"];
|
||||
}
|
||||
|
||||
function getLocationStatus(location) {
|
||||
const receivedAt = new Date(location.receivedAt).getTime();
|
||||
const ageMs = Date.now() - receivedAt;
|
||||
|
||||
@@ -65,6 +65,18 @@
|
||||
<span>차량번호</span>
|
||||
<input id="vehicleNoInput" name="vehicleNo" required autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>차량 유형</span>
|
||||
<select id="vehicleTypeInput" name="vehicleType">
|
||||
<option value="cargo-truck">카고트럭</option>
|
||||
<option value="tractor-trailer">트레일러</option>
|
||||
<option value="box-truck">탑차</option>
|
||||
<option value="van">용달차</option>
|
||||
<option value="refrigerated">냉동탑차</option>
|
||||
<option value="container">컨테이너</option>
|
||||
<option value="wingbody">윙바디</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>전화번호</span>
|
||||
<input id="phoneInput" name="phone" autocomplete="off" />
|
||||
|
||||
@@ -236,8 +236,7 @@ button:hover {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-form label:nth-child(3),
|
||||
.driver-form label:nth-child(4),
|
||||
.driver-form label:nth-child(n + 3),
|
||||
.driver-form button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -248,7 +247,8 @@ button:hover {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.driver-form input {
|
||||
.driver-form input,
|
||||
.driver-form select {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -259,7 +259,8 @@ button:hover {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.driver-form input:focus {
|
||||
.driver-form input:focus,
|
||||
.driver-form select:focus {
|
||||
border-color: var(--blue);
|
||||
outline: 2px solid rgba(31, 111, 235, 0.14);
|
||||
}
|
||||
@@ -388,6 +389,34 @@ button:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.vehicle-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 5px;
|
||||
background: #e8eef6;
|
||||
color: #2c3b4f;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.vehicle-type-chip.type-trailer,
|
||||
.vehicle-type-chip.type-container {
|
||||
background: rgba(44, 62, 80, 0.1);
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.vehicle-type-chip.type-van {
|
||||
background: rgba(2, 132, 199, 0.12);
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.vehicle-type-chip.type-cold {
|
||||
background: rgba(14, 116, 144, 0.12);
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.speed {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
@@ -501,19 +530,80 @@ button:hover {
|
||||
.truck-marker {
|
||||
--truck-color: var(--blue);
|
||||
--truck-cab: #1556b8;
|
||||
--truck-accent: rgba(255, 255, 255, 0.2);
|
||||
--body-width: 42px;
|
||||
--body-height: 24px;
|
||||
--cab-width: 19px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
width: max-content;
|
||||
min-width: 56px;
|
||||
min-height: 34px;
|
||||
min-width: calc(var(--body-width) + var(--cab-width));
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
padding: 0 0 7px;
|
||||
padding: 0 0 8px;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
filter: drop-shadow(0 8px 13px rgba(16, 32, 48, 0.28));
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: filter 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.truck-marker:hover {
|
||||
filter: drop-shadow(0 10px 16px rgba(16, 32, 48, 0.34));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.truck-marker.stale {
|
||||
--truck-color: var(--amber);
|
||||
--truck-cab: #8f4307;
|
||||
}
|
||||
|
||||
.truck-marker.offline {
|
||||
--truck-color: var(--red);
|
||||
--truck-cab: #8f1d15;
|
||||
}
|
||||
|
||||
.truck-marker.type-trailer {
|
||||
--truck-color: #334155;
|
||||
--truck-cab: #0f172a;
|
||||
--body-width: 62px;
|
||||
--body-height: 24px;
|
||||
}
|
||||
|
||||
.truck-marker.type-container {
|
||||
--truck-color: #475569;
|
||||
--truck-cab: #1e293b;
|
||||
--body-width: 58px;
|
||||
--body-height: 24px;
|
||||
--truck-accent: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.18) 0 2px,
|
||||
transparent 2px 8px
|
||||
);
|
||||
}
|
||||
|
||||
.truck-marker.type-box,
|
||||
.truck-marker.type-wing {
|
||||
--body-width: 48px;
|
||||
--body-height: 25px;
|
||||
}
|
||||
|
||||
.truck-marker.type-van {
|
||||
--truck-color: #0284c7;
|
||||
--truck-cab: #0369a1;
|
||||
--body-width: 34px;
|
||||
--body-height: 21px;
|
||||
--cab-width: 22px;
|
||||
}
|
||||
|
||||
.truck-marker.type-cold {
|
||||
--truck-color: #0e7490;
|
||||
--truck-cab: #155e75;
|
||||
--body-width: 47px;
|
||||
--body-height: 25px;
|
||||
--truck-accent: linear-gradient(135deg, rgba(255, 255, 255, 0.28), transparent 45%);
|
||||
}
|
||||
|
||||
.truck-marker.stale {
|
||||
@@ -539,34 +629,57 @@ button:hover {
|
||||
}
|
||||
|
||||
.truck-marker::before {
|
||||
left: 10px;
|
||||
left: 11px;
|
||||
}
|
||||
|
||||
.truck-marker::after {
|
||||
right: 11px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.truck-marker__trailer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 38px;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
.truck-marker__body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
place-items: center;
|
||||
width: var(--body-width);
|
||||
height: var(--body-height);
|
||||
border: 2px solid #ffffff;
|
||||
border-right: 0;
|
||||
border-radius: 5px 0 0 5px;
|
||||
background: var(--truck-color);
|
||||
font-size: 11px;
|
||||
background:
|
||||
var(--truck-accent),
|
||||
var(--truck-color);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.truck-marker.type-van .truck-marker__body {
|
||||
grid-template-rows: 1fr;
|
||||
border-radius: 9px 0 0 7px;
|
||||
}
|
||||
|
||||
.truck-marker.type-van .truck-marker__type {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.truck-marker__type {
|
||||
align-self: end;
|
||||
font-size: 9px;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.truck-marker__plate {
|
||||
align-self: start;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.truck-marker__cab {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 19px;
|
||||
width: var(--cab-width);
|
||||
height: calc(var(--body-height) - 3px);
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 0 6px 5px 0;
|
||||
background: var(--truck-cab);
|
||||
|
||||
@@ -45,6 +45,8 @@ tested with `curl -k` while the certificate is being prepared.
|
||||
CARGORADAR_PORT=8080
|
||||
ADMIN_TOKEN=
|
||||
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||
SIMULATOR_ENABLED=true
|
||||
SIMULATOR_INTERVAL_MS=4000
|
||||
```
|
||||
|
||||
For NAS deployment, start from `.env.nas.example` instead. The recommended NAS
|
||||
@@ -54,9 +56,13 @@ host port is `18081` because `18080` is already forwarded for `opendaw`.
|
||||
CARGORADAR_PORT=18081
|
||||
ADMIN_TOKEN=change_this_admin_token
|
||||
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||
SIMULATOR_ENABLED=true
|
||||
SIMULATOR_INTERVAL_MS=4000
|
||||
```
|
||||
|
||||
`ADMIN_TOKEN` can stay blank during local MVP testing. For NAS exposure, set it.
|
||||
Set `SIMULATOR_ENABLED=false` when real Android phones are ready to provide
|
||||
driver locations.
|
||||
If opening the app directly by port, use:
|
||||
|
||||
```text
|
||||
|
||||
Reference in New Issue
Block a user