화물차 지도 마커와 카카오 키 전환

This commit is contained in:
2026-06-06 16:22:27 +09:00
parent 84e308a04d
commit 2d10927bed
7 changed files with 393 additions and 83 deletions

View File

@@ -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: '&copy; <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("&", "&amp;")