전국 화물차 시뮬레이터 추가

This commit is contained in:
2026-06-06 18:37:36 +09:00
parent 2ba63fbc5b
commit 5a1f851d32
8 changed files with 643 additions and 40 deletions

View File

@@ -1,3 +1,5 @@
CARGORADAR_PORT=8080
ADMIN_TOKEN=
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
SIMULATOR_ENABLED=true
SIMULATOR_INTERVAL_MS=4000

View File

@@ -1,3 +1,5 @@
CARGORADAR_PORT=18081
ADMIN_TOKEN=change_this_admin_token
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
SIMULATOR_ENABLED=true
SIMULATOR_INTERVAL_MS=4000

View File

@@ -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:

View File

@@ -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
};

View File

@@ -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;

View File

@@ -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" />

View File

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

View File

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