diff --git a/.env.example b/.env.example
index c5c9993..4db1a26 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,3 @@
CARGORADAR_PORT=8080
ADMIN_TOKEN=
-KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec
+KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
diff --git a/.env.nas.example b/.env.nas.example
index 9267765..fe862fa 100644
--- a/.env.nas.example
+++ b/.env.nas.example
@@ -1,3 +1,3 @@
CARGORADAR_PORT=18081
ADMIN_TOKEN=change_this_admin_token
-KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec
+KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
diff --git a/README.md b/README.md
index 48832cb..f5b97ff 100644
--- a/README.md
+++ b/README.md
@@ -136,6 +136,10 @@ $body = @{
accuracy = 12
speed = 42
heading = 90
+ cargoName = "전자부품"
+ cargoWeight = "1.2t"
+ origin = "서울 상차지"
+ destination = "평택 물류센터"
recordedAt = (Get-Date).ToUniversalTime().ToString("o")
} | ConvertTo-Json
diff --git a/apps/server/src/server.js b/apps/server/src/server.js
index 9a47251..e9690e5 100644
--- a/apps/server/src/server.js
+++ b/apps/server/src/server.js
@@ -9,7 +9,7 @@ const PORT = Number(process.env.PORT || 8080);
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 = "5e0f889314e6d4f26fb18ee63d09c1ec";
+const DEFAULT_KAKAO_JAVASCRIPT_KEY = "07f4728599ceaf4299f77c772a135423";
const DRIVERS_FILE = path.join(DATA_DIR, "drivers.json");
const LATEST_FILE = path.join(DATA_DIR, "latest-locations.json");
@@ -400,6 +400,10 @@ function createLocationRecord(body, driver) {
speed: optionalNumber(body.speed),
heading: optionalNumber(body.heading),
batteryLevel: optionalNumber(body.batteryLevel),
+ cargoName: optionalString(body.cargoName || body.cargoItem || body.cargo || body.itemName, 80),
+ cargoWeight: optionalString(body.cargoWeight || body.weight, 40),
+ origin: optionalString(body.origin || body.departure, 80),
+ destination: optionalString(body.destination || body.arrival, 80),
provider: body.provider ? String(body.provider) : "",
recordedAt: normalizeDate(body.recordedAt),
receivedAt: new Date().toISOString()
@@ -426,6 +430,11 @@ function optionalNumber(value) {
return Number.isFinite(parsed) ? parsed : null;
}
+function optionalString(value, maxLength) {
+ if (value === undefined || value === null) return "";
+ return String(value).trim().slice(0, maxLength);
+}
+
function normalizeDate(value) {
if (!value) return new Date().toISOString();
const parsed = new Date(value);
diff --git a/apps/web/public/app.js b/apps/web/public/app.js
index 546b8ed..9236a32 100644
--- a/apps/web/public/app.js
+++ b/apps/web/public/app.js
@@ -1,5 +1,6 @@
const state = {
map: null,
+ mapProvider: "",
markers: new Map(),
infoOverlay: null,
drivers: new Map(),
@@ -51,26 +52,31 @@ document.addEventListener("DOMContentLoaded", () => {
async function initializeMap() {
const kakaoJavaScriptKey = window.CARGORADAR_CONFIG?.kakaoJavaScriptKey || "";
- if (!kakaoJavaScriptKey) {
- showMapMessage("Kakao JavaScript 키가 설정되지 않았습니다.");
- return;
+ if (kakaoJavaScriptKey) {
+ try {
+ await loadKakaoMapsSdk(kakaoJavaScriptKey);
+ const center = new kakao.maps.LatLng(36.45, 127.85);
+ state.map = new kakao.maps.Map(document.getElementById("map"), {
+ center,
+ level: 13
+ });
+ state.mapProvider = "kakao";
+
+ state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT);
+ state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT);
+ hideMapMessage();
+ updateMarkers({ fitBounds: state.locations.size > 0 });
+ return;
+ } catch (error) {
+ console.warn("Kakao map unavailable. Falling back to OpenStreetMap.", error);
+ }
}
try {
- await loadKakaoMapsSdk(kakaoJavaScriptKey);
- const center = new kakao.maps.LatLng(36.45, 127.85);
- state.map = new kakao.maps.Map(document.getElementById("map"), {
- center,
- level: 13
- });
-
- state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT);
- state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT);
- hideMapMessage();
- updateMarkers({ fitBounds: state.locations.size > 0 });
+ await initializeLeafletMap();
} catch (error) {
console.error(error);
- showMapMessage("Kakao 지도 키 또는 허용 도메인을 확인하세요.");
+ showMapMessage("지도 로딩에 실패했습니다. 네트워크 상태를 확인하세요.");
}
}
@@ -84,12 +90,88 @@ function loadKakaoMapsSdk(appKey) {
const script = document.createElement("script");
script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${encodeURIComponent(appKey)}&autoload=false`;
script.async = true;
- script.onload = () => kakao.maps.load(resolve);
+ script.onload = () => {
+ if (!window.kakao?.maps) {
+ reject(new Error("kakao_maps_not_authorized"));
+ return;
+ }
+ kakao.maps.load(resolve);
+ };
script.onerror = () => reject(new Error("kakao_maps_load_failed"));
document.head.appendChild(script);
});
}
+async function initializeLeafletMap() {
+ await loadLeafletAssets();
+ state.mapProvider = "leaflet";
+ state.map = L.map("map", {
+ attributionControl: true,
+ zoomControl: true
+ }).setView([36.45, 127.85], 7);
+
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ maxZoom: 19,
+ attribution: '© OpenStreetMap contributors'
+ }).addTo(state.map);
+
+ showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중");
+ window.setTimeout(hideMapMessage, 4500);
+ updateMarkers({ fitBounds: state.locations.size > 0 });
+}
+
+function loadLeafletAssets() {
+ if (window.L?.map) return Promise.resolve();
+
+ const version = "1.9.4";
+ const stylesheet = loadStylesheet(
+ "leaflet-stylesheet",
+ `https://unpkg.com/leaflet@${version}/dist/leaflet.css`
+ );
+ const script = loadScript(
+ "leaflet-script",
+ `https://unpkg.com/leaflet@${version}/dist/leaflet.js`
+ );
+
+ return Promise.all([stylesheet, script]).then(() => {
+ if (!window.L?.map) throw new Error("leaflet_load_failed");
+ });
+}
+
+function loadStylesheet(id, href) {
+ return new Promise((resolve, reject) => {
+ if (document.getElementById(id)) {
+ resolve();
+ return;
+ }
+
+ const link = document.createElement("link");
+ link.id = id;
+ link.rel = "stylesheet";
+ link.href = href;
+ link.onload = resolve;
+ link.onerror = () => reject(new Error(`${id}_load_failed`));
+ document.head.appendChild(link);
+ });
+}
+
+function loadScript(id, src) {
+ return new Promise((resolve, reject) => {
+ if (document.getElementById(id)) {
+ resolve();
+ return;
+ }
+
+ const script = document.createElement("script");
+ script.id = id;
+ script.src = src;
+ script.async = true;
+ script.onload = resolve;
+ script.onerror = () => reject(new Error(`${id}_load_failed`));
+ document.head.appendChild(script);
+ });
+}
+
function bindActions() {
elements.refreshButton.addEventListener("click", refreshLatestLocations);
elements.demoButton.addEventListener("click", postDemoLocation);
@@ -180,6 +262,10 @@ async function postDemoLocation() {
accuracy: 12,
speed: 42,
heading: Math.round((now / 1000) % 360),
+ cargoName: "전자부품",
+ cargoWeight: "1.2t",
+ origin: "서울 상차지",
+ destination: "평택 물류센터",
provider: "demo",
recordedAt: new Date().toISOString()
})
@@ -225,8 +311,8 @@ function upsertLocation(location, options = {}) {
loadLocationHistory(location);
}
- if (options.focus && state.map && window.kakao?.maps) {
- state.map.setCenter(new kakao.maps.LatLng(location.latitude, location.longitude));
+ if (options.focus) {
+ centerMapOnLocation(location);
}
}
@@ -465,13 +551,13 @@ async function copyToken(token) {
}
function updateMarkers({ fitBounds = false } = {}) {
- if (!state.map || !window.kakao?.maps) return;
+ if (!state.map || !state.mapProvider) return;
const activeIds = new Set(state.locations.keys());
- for (const [driverId, markerOverlay] of state.markers) {
+ for (const [driverId, marker] of state.markers) {
if (!activeIds.has(driverId)) {
- markerOverlay.setMap(null);
+ removeMarker(marker);
state.markers.delete(driverId);
}
}
@@ -481,54 +567,149 @@ function updateMarkers({ fitBounds = false } = {}) {
}
if (fitBounds && state.markers.size > 0) {
+ fitMapToLocations();
+ }
+}
+
+function updateMarker(location) {
+ if (!state.map || !state.mapProvider) return;
+
+ if (state.mapProvider === "leaflet") {
+ updateLeafletMarker(location);
+ return;
+ }
+
+ updateKakaoMarker(location);
+}
+
+function updateKakaoMarker(location) {
+ const position = new kakao.maps.LatLng(location.latitude, location.longitude);
+ const status = getLocationStatus(location);
+ const markerElement = createTruckMarkerElement(location, status);
+
+ let marker = state.markers.get(location.driverId);
+
+ if (!marker) {
+ marker = {
+ provider: "kakao",
+ overlay: new kakao.maps.CustomOverlay({
+ position,
+ content: markerElement,
+ yAnchor: 1
+ })
+ };
+ marker.overlay.setMap(state.map);
+ state.markers.set(location.driverId, marker);
+ } else {
+ marker.overlay.setPosition(position);
+ marker.overlay.setContent(markerElement);
+ }
+}
+
+function updateLeafletMarker(location) {
+ const status = getLocationStatus(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],
+ popupAnchor: [0, -30]
+ });
+
+ let marker = state.markers.get(location.driverId);
+
+ if (!marker) {
+ marker = {
+ provider: "leaflet",
+ marker: L.marker(position, { icon, title: location.vehicleNo || location.driverId })
+ };
+ marker.marker.on("click", () => focusLocation(location));
+ marker.marker.addTo(state.map);
+ state.markers.set(location.driverId, marker);
+ } else {
+ marker.marker.setLatLng(position);
+ marker.marker.setIcon(icon);
+ marker.marker.off("click");
+ marker.marker.on("click", () => focusLocation(location));
+ }
+}
+
+function createTruckMarkerElement(location, status) {
+ const markerElement = document.createElement("button");
+ markerElement.className = `truck-marker ${status.key}`;
+ markerElement.type = "button";
+ markerElement.title = location.vehicleNo || location.driverId;
+ markerElement.setAttribute("aria-label", `${location.vehicleNo || location.driverId} 위치 보기`);
+ markerElement.innerHTML = createTruckMarkerHtml(location, status);
+ markerElement.addEventListener("click", () => focusLocation(location));
+ return markerElement;
+}
+
+function createTruckMarkerHtml(location) {
+ const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4);
+ return `
+ ${escapeHtml(label)}
+
+ `;
+}
+
+function removeMarker(marker) {
+ if (marker.provider === "kakao") {
+ marker.overlay.setMap(null);
+ return;
+ }
+
+ if (marker.provider === "leaflet") {
+ marker.marker.remove();
+ }
+}
+
+function fitMapToLocations() {
+ if (state.mapProvider === "kakao") {
const bounds = new kakao.maps.LatLngBounds();
for (const location of state.locations.values()) {
bounds.extend(new kakao.maps.LatLng(location.latitude, location.longitude));
}
state.map.setBounds(bounds);
+ return;
+ }
+
+ if (state.mapProvider === "leaflet") {
+ const bounds = L.latLngBounds([...state.locations.values()].map((location) => {
+ return [location.latitude, location.longitude];
+ }));
+ state.map.fitBounds(bounds, {
+ maxZoom: 14,
+ padding: [40, 40]
+ });
}
}
-function updateMarker(location) {
- if (!state.map || !window.kakao?.maps) return;
+function centerMapOnLocation(location, options = {}) {
+ if (!state.map || !state.mapProvider) return;
- const position = new kakao.maps.LatLng(location.latitude, location.longitude);
- const status = getLocationStatus(location);
- const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4);
+ if (state.mapProvider === "kakao") {
+ const position = new kakao.maps.LatLng(location.latitude, location.longitude);
+ state.map.setCenter(position);
+ if (options.zoom && state.map.getLevel() > 4) {
+ state.map.setLevel(4);
+ }
+ return;
+ }
- const markerElement = document.createElement("button");
- markerElement.className = `kakao-marker ${status.key}`;
- markerElement.type = "button";
- markerElement.textContent = label;
- markerElement.title = location.vehicleNo || location.driverId;
- markerElement.addEventListener("click", () => focusLocation(location));
-
- let markerOverlay = state.markers.get(location.driverId);
-
- if (!markerOverlay) {
- markerOverlay = new kakao.maps.CustomOverlay({
- position,
- content: markerElement,
- yAnchor: 1
- });
- markerOverlay.setMap(state.map);
- state.markers.set(location.driverId, markerOverlay);
- } else {
- markerOverlay.setPosition(position);
- markerOverlay.setContent(markerElement);
+ if (state.mapProvider === "leaflet") {
+ const nextZoom = options.zoom ? Math.max(state.map.getZoom(), 14) : state.map.getZoom();
+ state.map.setView([location.latitude, location.longitude], nextZoom, { animate: true });
}
}
function focusLocation(location) {
loadLocationHistory(location);
- if (!state.map || !window.kakao?.maps) return;
+ if (!state.map || !state.mapProvider) return;
- const position = new kakao.maps.LatLng(location.latitude, location.longitude);
- state.map.setCenter(position);
- if (state.map.getLevel() > 4) {
- state.map.setLevel(4);
- }
+ centerMapOnLocation(location, { zoom: true });
showInfoOverlay(location);
}
@@ -588,15 +769,24 @@ function closeHistoryDrawer() {
function showInfoOverlay(location) {
const status = getLocationStatus(location);
- const position = new kakao.maps.LatLng(location.latitude, location.longitude);
const content = createPopupElement(location, status);
- if (!state.infoOverlay) {
- state.infoOverlay = new kakao.maps.CustomOverlay({
- yAnchor: 1.25
- });
+ if (state.mapProvider === "leaflet") {
+ state.infoOverlay = L.popup({
+ closeButton: true,
+ offset: [0, -24]
+ })
+ .setLatLng([location.latitude, location.longitude])
+ .setContent(content)
+ .openOn(state.map);
+ return;
}
+ const position = new kakao.maps.LatLng(location.latitude, location.longitude);
+
+ if (!state.infoOverlay) {
+ state.infoOverlay = new kakao.maps.CustomOverlay({ yAnchor: 1.35 });
+ }
state.infoOverlay.setContent(content);
state.infoOverlay.setPosition(position);
state.infoOverlay.setMap(state.map);
@@ -613,13 +803,20 @@ function createPopupElement(location, status) {
const driver = document.createElement("div");
driver.textContent = `${location.driverName || location.driverId} · ${status.label}`;
+ const cargo = document.createElement("div");
+ cargo.className = "popup-cargo";
+ cargo.textContent = `화물: ${getCargoSummary(location)}`;
+
+ const destination = document.createElement("div");
+ destination.textContent = getRouteSummary(location);
+
const receivedAt = document.createElement("div");
- receivedAt.textContent = formatDateTime(location.receivedAt);
+ receivedAt.textContent = `수신: ${formatDateTime(location.receivedAt)}`;
- const coordinate = document.createElement("div");
- coordinate.textContent = `${formatCoordinate(location.latitude)}, ${formatCoordinate(location.longitude)}`;
+ const movement = document.createElement("div");
+ movement.textContent = `속도: ${formatSpeed(location.speed)}`;
- container.append(title, driver, receivedAt, coordinate);
+ container.append(title, driver, cargo, destination, movement, receivedAt);
return container;
}
@@ -706,10 +903,42 @@ function formatTime(value) {
}).format(date);
}
+function formatSpeed(value) {
+ if (value === null || value === undefined || value === "") return "-";
+ const speed = Number(value);
+ if (!Number.isFinite(speed)) return "-";
+ return `${Math.round(speed)} km/h`;
+}
+
function formatCoordinate(value) {
return Number(value).toFixed(5);
}
+function getCargoSummary(location) {
+ const cargoName = firstString(location.cargoName, location.cargoItem, location.cargo, location.itemName);
+ const cargoWeight = firstString(location.cargoWeight, location.weight);
+ return [cargoName, cargoWeight].filter(Boolean).join(" · ") || "미등록";
+}
+
+function getRouteSummary(location) {
+ const origin = firstString(location.origin, location.departure);
+ const destination = firstString(location.destination, location.arrival);
+
+ if (origin && destination) return `구간: ${origin} -> ${destination}`;
+ if (destination) return `도착지: ${destination}`;
+ if (origin) return `상차지: ${origin}`;
+ return "도착지: 미등록";
+}
+
+function firstString(...values) {
+ for (const value of values) {
+ if (value === undefined || value === null) continue;
+ const text = String(value).trim();
+ if (text) return text;
+ }
+ return "";
+}
+
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css
index 45bc0c3..1df45f5 100644
--- a/apps/web/public/styles.css
+++ b/apps/web/public/styles.css
@@ -498,31 +498,94 @@ button:hover {
font-weight: 800;
}
-.kakao-marker {
- display: flex;
- align-items: center;
- justify-content: center;
+.truck-marker {
+ --truck-color: var(--blue);
+ --truck-cab: #1556b8;
+ position: relative;
+ display: inline-flex;
+ align-items: flex-end;
width: max-content;
- min-width: 42px;
- min-height: 30px;
- padding: 0 9px;
- border: 2px solid #ffffff;
- border-radius: 999px;
- background: var(--blue);
+ min-width: 56px;
+ min-height: 34px;
+ border: 0;
+ padding: 0 0 7px;
+ background: transparent;
color: #ffffff;
- box-shadow: 0 8px 18px rgba(16, 32, 48, 0.25);
- font-size: 11px;
- font-weight: 900;
+ filter: drop-shadow(0 8px 13px rgba(16, 32, 48, 0.28));
font-family: inherit;
cursor: pointer;
}
-.kakao-marker.stale {
- background: var(--amber);
+.truck-marker.stale {
+ --truck-color: var(--amber);
+ --truck-cab: #8f4307;
}
-.kakao-marker.offline {
- background: var(--red);
+.truck-marker.offline {
+ --truck-color: var(--red);
+ --truck-cab: #8f1d15;
+}
+
+.truck-marker::before,
+.truck-marker::after {
+ content: "";
+ position: absolute;
+ bottom: 1px;
+ width: 7px;
+ height: 7px;
+ border: 2px solid #ffffff;
+ border-radius: 50%;
+ background: #17212b;
+}
+
+.truck-marker::before {
+ left: 10px;
+}
+
+.truck-marker::after {
+ right: 11px;
+}
+
+.truck-marker__trailer {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 38px;
+ height: 22px;
+ padding: 0 7px;
+ border: 2px solid #ffffff;
+ border-right: 0;
+ border-radius: 5px 0 0 5px;
+ background: var(--truck-color);
+ font-size: 11px;
+ font-weight: 900;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.truck-marker__cab {
+ position: relative;
+ width: 18px;
+ height: 19px;
+ border: 2px solid #ffffff;
+ border-radius: 0 6px 5px 0;
+ background: var(--truck-cab);
+}
+
+.truck-marker__cab::before {
+ content: "";
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ width: 7px;
+ height: 5px;
+ border-radius: 1px;
+ background: rgba(255, 255, 255, 0.72);
+}
+
+.leaflet-truck-marker {
+ border: 0;
+ background: transparent;
}
.kakao-popup {
@@ -543,6 +606,11 @@ button:hover {
font-weight: 900;
}
+.popup-cargo {
+ margin-top: 6px;
+ font-weight: 900;
+}
+
@media (max-width: 860px) {
.app-shell {
grid-template-rows: auto minmax(0, 1fr);
diff --git a/docs/SYNOLOGY.md b/docs/SYNOLOGY.md
index ab68d37..0d51a07 100644
--- a/docs/SYNOLOGY.md
+++ b/docs/SYNOLOGY.md
@@ -44,7 +44,7 @@ tested with `curl -k` while the certificate is being prepared.
```text
CARGORADAR_PORT=8080
ADMIN_TOKEN=
-KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec
+KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
```
For NAS deployment, start from `.env.nas.example` instead. The recommended NAS
@@ -53,7 +53,7 @@ host port is `18081` because `18080` is already forwarded for `opendaw`.
```text
CARGORADAR_PORT=18081
ADMIN_TOKEN=change_this_admin_token
-KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec
+KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
```
`ADMIN_TOKEN` can stay blank during local MVP testing. For NAS exposure, set it.