화물차 지도 마커와 카카오 키 전환
This commit is contained in:
@@ -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: '© <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() {
|
||||
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 `
|
||||
<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();
|
||||
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("&", "&")
|
||||
|
||||
Reference in New Issue
Block a user