화물차 지도 마커와 카카오 키 전환
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
CARGORADAR_PORT=8080
|
CARGORADAR_PORT=8080
|
||||||
ADMIN_TOKEN=
|
ADMIN_TOKEN=
|
||||||
KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec
|
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
CARGORADAR_PORT=18081
|
CARGORADAR_PORT=18081
|
||||||
ADMIN_TOKEN=change_this_admin_token
|
ADMIN_TOKEN=change_this_admin_token
|
||||||
KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec
|
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ $body = @{
|
|||||||
accuracy = 12
|
accuracy = 12
|
||||||
speed = 42
|
speed = 42
|
||||||
heading = 90
|
heading = 90
|
||||||
|
cargoName = "전자부품"
|
||||||
|
cargoWeight = "1.2t"
|
||||||
|
origin = "서울 상차지"
|
||||||
|
destination = "평택 물류센터"
|
||||||
recordedAt = (Get-Date).ToUniversalTime().ToString("o")
|
recordedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||||
} | ConvertTo-Json
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const PORT = Number(process.env.PORT || 8080);
|
|||||||
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, "..", "data");
|
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, "..", "data");
|
||||||
const WEB_ROOT = process.env.WEB_ROOT || path.resolve(__dirname, "..", "..", "web", "public");
|
const WEB_ROOT = process.env.WEB_ROOT || path.resolve(__dirname, "..", "..", "web", "public");
|
||||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "";
|
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 DRIVERS_FILE = path.join(DATA_DIR, "drivers.json");
|
||||||
const LATEST_FILE = path.join(DATA_DIR, "latest-locations.json");
|
const LATEST_FILE = path.join(DATA_DIR, "latest-locations.json");
|
||||||
@@ -400,6 +400,10 @@ function createLocationRecord(body, driver) {
|
|||||||
speed: optionalNumber(body.speed),
|
speed: optionalNumber(body.speed),
|
||||||
heading: optionalNumber(body.heading),
|
heading: optionalNumber(body.heading),
|
||||||
batteryLevel: optionalNumber(body.batteryLevel),
|
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) : "",
|
provider: body.provider ? String(body.provider) : "",
|
||||||
recordedAt: normalizeDate(body.recordedAt),
|
recordedAt: normalizeDate(body.recordedAt),
|
||||||
receivedAt: new Date().toISOString()
|
receivedAt: new Date().toISOString()
|
||||||
@@ -426,6 +430,11 @@ function optionalNumber(value) {
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
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) {
|
function normalizeDate(value) {
|
||||||
if (!value) return new Date().toISOString();
|
if (!value) return new Date().toISOString();
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const state = {
|
const state = {
|
||||||
map: null,
|
map: null,
|
||||||
|
mapProvider: "",
|
||||||
markers: new Map(),
|
markers: new Map(),
|
||||||
infoOverlay: null,
|
infoOverlay: null,
|
||||||
drivers: new Map(),
|
drivers: new Map(),
|
||||||
@@ -51,11 +52,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
async function initializeMap() {
|
async function initializeMap() {
|
||||||
const kakaoJavaScriptKey = window.CARGORADAR_CONFIG?.kakaoJavaScriptKey || "";
|
const kakaoJavaScriptKey = window.CARGORADAR_CONFIG?.kakaoJavaScriptKey || "";
|
||||||
|
|
||||||
if (!kakaoJavaScriptKey) {
|
if (kakaoJavaScriptKey) {
|
||||||
showMapMessage("Kakao JavaScript 키가 설정되지 않았습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadKakaoMapsSdk(kakaoJavaScriptKey);
|
await loadKakaoMapsSdk(kakaoJavaScriptKey);
|
||||||
const center = new kakao.maps.LatLng(36.45, 127.85);
|
const center = new kakao.maps.LatLng(36.45, 127.85);
|
||||||
@@ -63,14 +60,23 @@ async function initializeMap() {
|
|||||||
center,
|
center,
|
||||||
level: 13
|
level: 13
|
||||||
});
|
});
|
||||||
|
state.mapProvider = "kakao";
|
||||||
|
|
||||||
state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT);
|
state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT);
|
||||||
state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT);
|
state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT);
|
||||||
hideMapMessage();
|
hideMapMessage();
|
||||||
updateMarkers({ fitBounds: state.locations.size > 0 });
|
updateMarkers({ fitBounds: state.locations.size > 0 });
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Kakao map unavailable. Falling back to OpenStreetMap.", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initializeLeafletMap();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
showMapMessage("Kakao 지도 키 또는 허용 도메인을 확인하세요.");
|
showMapMessage("지도 로딩에 실패했습니다. 네트워크 상태를 확인하세요.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +90,88 @@ function loadKakaoMapsSdk(appKey) {
|
|||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${encodeURIComponent(appKey)}&autoload=false`;
|
script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${encodeURIComponent(appKey)}&autoload=false`;
|
||||||
script.async = true;
|
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"));
|
script.onerror = () => reject(new Error("kakao_maps_load_failed"));
|
||||||
document.head.appendChild(script);
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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() {
|
function bindActions() {
|
||||||
elements.refreshButton.addEventListener("click", refreshLatestLocations);
|
elements.refreshButton.addEventListener("click", refreshLatestLocations);
|
||||||
elements.demoButton.addEventListener("click", postDemoLocation);
|
elements.demoButton.addEventListener("click", postDemoLocation);
|
||||||
@@ -180,6 +262,10 @@ async function postDemoLocation() {
|
|||||||
accuracy: 12,
|
accuracy: 12,
|
||||||
speed: 42,
|
speed: 42,
|
||||||
heading: Math.round((now / 1000) % 360),
|
heading: Math.round((now / 1000) % 360),
|
||||||
|
cargoName: "전자부품",
|
||||||
|
cargoWeight: "1.2t",
|
||||||
|
origin: "서울 상차지",
|
||||||
|
destination: "평택 물류센터",
|
||||||
provider: "demo",
|
provider: "demo",
|
||||||
recordedAt: new Date().toISOString()
|
recordedAt: new Date().toISOString()
|
||||||
})
|
})
|
||||||
@@ -225,8 +311,8 @@ function upsertLocation(location, options = {}) {
|
|||||||
loadLocationHistory(location);
|
loadLocationHistory(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.focus && state.map && window.kakao?.maps) {
|
if (options.focus) {
|
||||||
state.map.setCenter(new kakao.maps.LatLng(location.latitude, location.longitude));
|
centerMapOnLocation(location);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,13 +551,13 @@ async function copyToken(token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateMarkers({ fitBounds = false } = {}) {
|
function updateMarkers({ fitBounds = false } = {}) {
|
||||||
if (!state.map || !window.kakao?.maps) return;
|
if (!state.map || !state.mapProvider) return;
|
||||||
|
|
||||||
const activeIds = new Set(state.locations.keys());
|
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)) {
|
if (!activeIds.has(driverId)) {
|
||||||
markerOverlay.setMap(null);
|
removeMarker(marker);
|
||||||
state.markers.delete(driverId);
|
state.markers.delete(driverId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -481,54 +567,149 @@ function updateMarkers({ fitBounds = false } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fitBounds && state.markers.size > 0) {
|
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 `
|
||||||
|
<span class="truck-marker__trailer">${escapeHtml(label)}</span>
|
||||||
|
<span class="truck-marker__cab"></span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
const bounds = new kakao.maps.LatLngBounds();
|
||||||
for (const location of state.locations.values()) {
|
for (const location of state.locations.values()) {
|
||||||
bounds.extend(new kakao.maps.LatLng(location.latitude, location.longitude));
|
bounds.extend(new kakao.maps.LatLng(location.latitude, location.longitude));
|
||||||
}
|
}
|
||||||
state.map.setBounds(bounds);
|
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) {
|
function centerMapOnLocation(location, options = {}) {
|
||||||
if (!state.map || !window.kakao?.maps) return;
|
if (!state.map || !state.mapProvider) return;
|
||||||
|
|
||||||
|
if (state.mapProvider === "kakao") {
|
||||||
const position = new kakao.maps.LatLng(location.latitude, location.longitude);
|
const position = new kakao.maps.LatLng(location.latitude, location.longitude);
|
||||||
const status = getLocationStatus(location);
|
state.map.setCenter(position);
|
||||||
const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4);
|
if (options.zoom && state.map.getLevel() > 4) {
|
||||||
|
state.map.setLevel(4);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const markerElement = document.createElement("button");
|
if (state.mapProvider === "leaflet") {
|
||||||
markerElement.className = `kakao-marker ${status.key}`;
|
const nextZoom = options.zoom ? Math.max(state.map.getZoom(), 14) : state.map.getZoom();
|
||||||
markerElement.type = "button";
|
state.map.setView([location.latitude, location.longitude], nextZoom, { animate: true });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusLocation(location) {
|
function focusLocation(location) {
|
||||||
loadLocationHistory(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);
|
centerMapOnLocation(location, { zoom: true });
|
||||||
state.map.setCenter(position);
|
|
||||||
if (state.map.getLevel() > 4) {
|
|
||||||
state.map.setLevel(4);
|
|
||||||
}
|
|
||||||
showInfoOverlay(location);
|
showInfoOverlay(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,15 +769,24 @@ function closeHistoryDrawer() {
|
|||||||
|
|
||||||
function showInfoOverlay(location) {
|
function showInfoOverlay(location) {
|
||||||
const status = getLocationStatus(location);
|
const status = getLocationStatus(location);
|
||||||
const position = new kakao.maps.LatLng(location.latitude, location.longitude);
|
|
||||||
const content = createPopupElement(location, status);
|
const content = createPopupElement(location, status);
|
||||||
|
|
||||||
if (!state.infoOverlay) {
|
if (state.mapProvider === "leaflet") {
|
||||||
state.infoOverlay = new kakao.maps.CustomOverlay({
|
state.infoOverlay = L.popup({
|
||||||
yAnchor: 1.25
|
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.setContent(content);
|
||||||
state.infoOverlay.setPosition(position);
|
state.infoOverlay.setPosition(position);
|
||||||
state.infoOverlay.setMap(state.map);
|
state.infoOverlay.setMap(state.map);
|
||||||
@@ -613,13 +803,20 @@ function createPopupElement(location, status) {
|
|||||||
const driver = document.createElement("div");
|
const driver = document.createElement("div");
|
||||||
driver.textContent = `${location.driverName || location.driverId} · ${status.label}`;
|
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");
|
const receivedAt = document.createElement("div");
|
||||||
receivedAt.textContent = formatDateTime(location.receivedAt);
|
receivedAt.textContent = `수신: ${formatDateTime(location.receivedAt)}`;
|
||||||
|
|
||||||
const coordinate = document.createElement("div");
|
const movement = document.createElement("div");
|
||||||
coordinate.textContent = `${formatCoordinate(location.latitude)}, ${formatCoordinate(location.longitude)}`;
|
movement.textContent = `속도: ${formatSpeed(location.speed)}`;
|
||||||
|
|
||||||
container.append(title, driver, receivedAt, coordinate);
|
container.append(title, driver, cargo, destination, movement, receivedAt);
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,10 +903,42 @@ function formatTime(value) {
|
|||||||
}).format(date);
|
}).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) {
|
function formatCoordinate(value) {
|
||||||
return Number(value).toFixed(5);
|
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) {
|
function escapeHtml(value) {
|
||||||
return String(value)
|
return String(value)
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
|
|||||||
@@ -498,31 +498,94 @@ button:hover {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kakao-marker {
|
.truck-marker {
|
||||||
display: flex;
|
--truck-color: var(--blue);
|
||||||
align-items: center;
|
--truck-cab: #1556b8;
|
||||||
justify-content: center;
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 42px;
|
min-width: 56px;
|
||||||
min-height: 30px;
|
min-height: 34px;
|
||||||
padding: 0 9px;
|
border: 0;
|
||||||
border: 2px solid #ffffff;
|
padding: 0 0 7px;
|
||||||
border-radius: 999px;
|
background: transparent;
|
||||||
background: var(--blue);
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
box-shadow: 0 8px 18px rgba(16, 32, 48, 0.25);
|
filter: drop-shadow(0 8px 13px rgba(16, 32, 48, 0.28));
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 900;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kakao-marker.stale {
|
.truck-marker.stale {
|
||||||
background: var(--amber);
|
--truck-color: var(--amber);
|
||||||
|
--truck-cab: #8f4307;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kakao-marker.offline {
|
.truck-marker.offline {
|
||||||
background: var(--red);
|
--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 {
|
.kakao-popup {
|
||||||
@@ -543,6 +606,11 @@ button:hover {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.popup-cargo {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ tested with `curl -k` while the certificate is being prepared.
|
|||||||
```text
|
```text
|
||||||
CARGORADAR_PORT=8080
|
CARGORADAR_PORT=8080
|
||||||
ADMIN_TOKEN=
|
ADMIN_TOKEN=
|
||||||
KAKAO_JAVASCRIPT_KEY=5e0f889314e6d4f26fb18ee63d09c1ec
|
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||||
```
|
```
|
||||||
|
|
||||||
For NAS deployment, start from `.env.nas.example` instead. The recommended NAS
|
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
|
```text
|
||||||
CARGORADAR_PORT=18081
|
CARGORADAR_PORT=18081
|
||||||
ADMIN_TOKEN=change_this_admin_token
|
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.
|
`ADMIN_TOKEN` can stay blank during local MVP testing. For NAS exposure, set it.
|
||||||
|
|||||||
Reference in New Issue
Block a user