UNPKG

vworld-react-3d

Version:

VWorld 3.0 API React Component - 한국 공공데이터 3D 지도 컴포넌트

1,248 lines (1,242 loc) 54.6 kB
import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { useRef, useState, useEffect } from 'react'; /** * 좌표를 CameraPosition으로 변환 */ const createCameraPosition = (coord, direction) => { return { coord, direction, }; }; /** * 위도, 경도, 고도로 VWorldCoord 생성 */ const createCoord = (longitude, latitude, altitude) => { return { x: longitude, y: latitude, z: altitude, }; }; /** * 방향 각도로 VWorldDirection 생성 */ const createDirection = (heading, pitch, roll) => { return { heading: heading % 360, pitch: Math.max(-90, Math.min(90, pitch)), roll: roll % 360, }; }; /** * 광화문 중심의 카메라 위치 생성 */ const getGwanghwamunCameraPosition = (altitude = 1000000) => { return createCameraPosition(createCoord(126.977, 37.571, altitude), // 경도, 위도 순서 (광화문 좌표) createDirection(0, -40, 0)); }; /** * 서울시청 중심의 카메라 위치 생성 */ const getSeoulCityHallCameraPosition = (altitude = 1500000) => { return createCameraPosition(createCoord(126.977, 37.566, altitude), // 경도, 위도 순서 (광화문 좌표) createDirection(0, 45, 0)); }; /** * 부산시청 중심의 카메라 위치 생성 */ const getBusanCityHallCameraPosition = (altitude = 1200000) => { return createCameraPosition(createCoord(129.075, 35.179, altitude), // 경도, 위도 순서 (부산시청) createDirection(0, -90, 0)); }; /** * 대구시청 중심의 카메라 위치 생성 */ const getDaeguCityHallCameraPosition = (altitude = 1200000) => { return createCameraPosition(createCoord(128.601, 35.871, altitude), // 경도, 위도 순서 (대구시청) createDirection(0, -90, 0)); }; /** * 인천시청 중심의 카메라 위치 생성 */ const getIncheonCityHallCameraPosition = (altitude = 1200000) => { return createCameraPosition(createCoord(126.705, 37.456, altitude), // 경도, 위도 순서 (인천시청) createDirection(0, -90, 0)); }; /** * 광주시청 중심의 카메라 위치 생성 */ const getGwangjuCityHallCameraPosition = (altitude = 1200000) => { return createCameraPosition(createCoord(126.852, 35.159, altitude), // 경도, 위도 순서 (광주시청) createDirection(0, -90, 0)); }; /** * 대전시청 중심의 카메라 위치 생성 */ const getDaejeonCityHallCameraPosition = (altitude = 1200000) => { return createCameraPosition(createCoord(127.385, 36.35, altitude), // 경도, 위도 순서 (대전시청) createDirection(0, -90, 0)); }; /** * 울산시청 중심의 카메라 위치 생성 */ const getUlsanCityHallCameraPosition = (altitude = 1200000) => { return createCameraPosition(createCoord(129.311, 35.538, altitude), // 경도, 위도 순서 (울산시청) createDirection(0, -90, 0)); }; /** * 고도별 설명 */ const getAltitudeDescription = (altitude) => { if (altitude <= 100000) return "건물 상세"; // 100km 이하 if (altitude <= 300000) return "거리 상세"; // 300km 이하 if (altitude <= 500000) return "동네 상세"; // 500km 이하 if (altitude <= 800000) return "구역"; // 800km 이하 if (altitude <= 1200000) return "시/군"; // 1.2km 이하 if (altitude <= 2000000) return "도/시"; // 2km 이하 if (altitude <= 5000000) return "지역"; // 5km 이하 if (altitude <= 10000000) return "국가"; // 10km 이하 return "세계"; // 10km 이상 }; /** * 줌 레벨을 고도로 변환 */ const zoomToAltitude = (zoomLevel) => { const baseAltitude = 2000000; const zoomFactor = Math.pow(2, 10 - zoomLevel); return Math.max(1000, baseAltitude / zoomFactor); }; /** * 고도를 줌 레벨로 변환 */ const altitudeToZoom = (altitude) => { const baseAltitude = 2000000; const zoomFactor = baseAltitude / altitude; return Math.max(1, Math.min(20, 10 - Math.log2(zoomFactor))); }; /** * 줌 레벨별 설명 */ const getZoomDescription = (zoomLevel) => { if (zoomLevel >= 18) return "건물 상세"; if (zoomLevel >= 16) return "거리 상세"; if (zoomLevel >= 14) return "동네 상세"; if (zoomLevel >= 12) return "구역"; if (zoomLevel >= 10) return "시/군"; if (zoomLevel >= 8) return "도/시"; if (zoomLevel >= 6) return "지역"; if (zoomLevel >= 4) return "국가"; return "세계"; }; /** * 좌표를 WGS84 형식으로 변환 */ const coordToWGS84 = (coord) => { return `${coord.x.toFixed(6)},${coord.y.toFixed(6)},${coord.z.toFixed(2)}`; }; /** * WGS84 문자열을 좌표로 변환 */ const wgs84ToCoord = (wgs84String) => { const [x, y, z] = wgs84String.split(",").map(Number); return createCoord(x, y, z); }; /** * 고도 차이 계산 (미터 단위) */ const calculateAltitudeDifference = (coord1, coord2) => { return coord2.z - coord1.z; }; /** * 경사도 계산 (도 단위) */ const calculateSlope = (coord1, coord2) => { const distance = calculateDistance(coord1, coord2); const altitudeDiff = calculateAltitudeDifference(coord1, coord2); return (Math.atan(altitudeDiff / distance) * 180) / Math.PI; }; /** * 좌표를 UTM 좌표계로 변환 */ const coordToUTM = (coord) => { const lat = coord.y; const lon = coord.x; // UTM 변환 로직 (간단한 버전) const zone = Math.floor((lon + 180) / 6) + 1; const easting = (lon + 180) * 111320 * Math.cos((lat * Math.PI) / 180); const northing = lat * 110540; return { zone, easting, northing }; }; /** * UTM 좌표를 WGS84로 변환 */ const utmToCoord = (zone, easting, northing) => { const lon = easting / (111320 * Math.cos(((northing / 110540) * Math.PI) / 180)) - 180 + (zone - 1) * 6; const lat = northing / 110540; return createCoord(lon, lat, 0); }; /** * 좌표를 TM 좌표계로 변환 (한국 중부원점) */ const coordToTM = (coord) => { // 한국 중부원점 변환 (간단한 버전) const lat = coord.y; const lon = coord.x; const x = (lon - 127.5) * 111320; const y = (lat - 36.0) * 110540; return { x, y }; }; /** * TM 좌표를 WGS84로 변환 */ const tmToCoord = (x, y) => { const lon = x / 111320 + 127.5; const lat = y / 110540 + 36.0; return createCoord(lon, lat, 0); }; /** * 애니메이션 이징 함수들 */ const easingFunctions = { linear: (t) => t, easeInQuad: (t) => t * t, easeOutQuad: (t) => t * (2 - t), easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), easeInCubic: (t) => t * t * t, easeOutCubic: (t) => --t * t * t + 1, easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, }; /** * 애니메이션 인터폴레이션 */ const interpolate = (start, end, progress, easing = "easeInOutCubic") => { const easedProgress = easingFunctions[easing](progress); return start + (end - start) * easedProgress; }; /** * 카메라 위치 애니메이션 */ const interpolateCameraPosition = (start, end, progress, easing = "easeInOutCubic") => { return { coord: { x: interpolate(start.coord.x, end.coord.x, progress, easing), y: interpolate(start.coord.y, end.coord.y, progress, easing), z: interpolate(start.coord.z, end.coord.z, progress, easing), }, direction: { heading: interpolate(start.direction.heading, end.direction.heading, progress, easing), pitch: interpolate(start.direction.pitch, end.direction.pitch, progress, easing), roll: interpolate(start.direction.roll, end.direction.roll, progress, easing), }, }; }; /** * 좌표 간 거리 계산 (미터 단위) */ const calculateDistance = (coord1, coord2) => { const R = 6371000; // 지구 반지름 (미터) const lat1 = (coord1.y * Math.PI) / 180; const lat2 = (coord2.y * Math.PI) / 180; const deltaLat = ((coord2.y - coord1.y) * Math.PI) / 180; const deltaLon = ((coord2.x - coord1.x) * Math.PI) / 180; const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; }; const VWorldMap = ({ config, options = {}, initCameraPosition, cameraPosition, zoom, onMapReady, onError, onCameraChange, onZoomChange, onMapClick, onMapDoubleClick, onMouseMove, onWheel, onMapLoad, className = "", style = {}, width = "100%", height = "600px", }) => { const mapDiv = useRef(null); const mapInstanceRef = useRef(null); const [scriptLoaded, setScriptLoaded] = useState(false); const [mapInitialized, setMapInitialized] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // VWorld 3.0 API 초기화 (공식 예제 기반) useEffect(() => { const initializeVWorld = async () => { try { // 이미 초기화되어 있는지 확인 if (window.vw) { setScriptLoaded(true); setLoading(false); return; } console.log("VWorld 3.0 API 초기화 시작"); // jQuery 먼저 로드 if (!window.$) { await new Promise((resolve, reject) => { const jqueryScript = document.createElement("script"); jqueryScript.type = "text/javascript"; jqueryScript.src = "https://code.jquery.com/jquery-3.6.0.min.js"; jqueryScript.onload = () => { console.log("jQuery 로드 완료"); resolve(); }; jqueryScript.onerror = () => { console.error("jQuery 로드 실패"); reject(new Error("Failed to load jQuery")); }; document.head.appendChild(jqueryScript); }); } // VWorld 3.0 API 전역 변수 설정 (공식 예제 기반) window.v_protocol = "https://"; window.vworldUrl = "https://map.vworld.kr"; window.vworld2DCache = "https://2d.vworld.kr/2DCache"; window.vworldBaseMapUrl = "https://cdn.vworld.kr/2d"; window.vworldStyledMapUrl = "https://2d.vworld.kr/stmap"; window.vworldVectorKey = "483E0418-2F46-3223-80A1-F66D16A24685"; window.vworldApiKey = config.apiKey; window.vworldNoCss = "n"; window.vworldIsValid = "false"; window.vworld3DUrl = "/js/webglMapInit.js.do"; // 스크립트 로드 순서 (공식 예제 기반) const scripts = [ "https://map.vworld.kr/js/ws3dmap/WS3DRelease3/WSViewerStartup.js", "https://map.vworld.kr/js/ws3dmap/WS3DRelease3/VWViewerStartup.v30.min.js?ver=2025041502", "https://map.vworld.kr/js/ws3dmap/WS3DRelease3/vw.ol3WebGL.v30.js?ver=2025041101", ]; // 순차적으로 스크립트 로드 for (let i = 0; i < scripts.length; i++) { await new Promise((resolve, reject) => { const script = document.createElement("script"); script.type = "text/javascript"; script.src = scripts[i]; script.onload = () => { resolve(); }; script.onerror = () => { console.error(`스크립트 로드 실패: ${scripts[i]}`); reject(new Error(`Failed to load script: ${scripts[i]}`)); }; document.head.appendChild(script); }); } // 모든 스크립트 로드 완료 후 vw 객체 확인 setTimeout(() => { if (window.vw) { console.log("VWorld 3.0 API 초기화 완료"); setScriptLoaded(true); setLoading(false); } else { console.log("vw 객체가 아직 로드되지 않음, 추가 대기..."); setTimeout(() => { if (window.vw) { console.log("VWorld 3.0 API 초기화 완료 (지연)"); setScriptLoaded(true); setLoading(false); } else { throw new Error("vw 객체를 찾을 수 없습니다"); } }, 3000); } }, 1000); } catch (err) { const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류"; console.error("VWorld 3.0 API 초기화 오류:", errorMessage); setError(errorMessage); setLoading(false); onError?.(errorMessage); } }; initializeVWorld(); }, [config.apiKey, onError]); // 맵 초기화 (공식 예제 기반) useEffect(() => { if (!scriptLoaded || !mapDiv.current || mapInitialized) return; // 이미 전역 맵 인스턴스가 있으면 재사용 if (window.vworldMapInstance) { console.log("기존 맵 인스턴스 재사용"); mapInstanceRef.current = window.vworldMapInstance; setMapInitialized(true); onMapReady?.(window.vworldMapInstance); onMapLoad?.(); return; } // vw 객체가 로드되었는지 확인 if (!window.vw) { console.log("vw 객체가 아직 로드되지 않음"); return; } // jQuery가 로드되었는지 확인 if (!window.$) { console.log("jQuery가 아직 로드되지 않음"); return; } const initializeMap = () => { try { console.log("VWorld 3D 맵 초기화 시작"); // vw 객체의 구조 확인 if (!window.vw || typeof window.vw.Map !== "function") { throw new Error("vw.Map이 함수가 아닙니다"); } // 2. 맵 인스턴스 생성 const mapInstance = window.vw?.Map ? new window.vw.Map() : undefined; if (!mapInstance) throw new Error("vw.Map 생성 실패"); mapInstanceRef.current = mapInstance; // 3. 초기 위치 결정 (파라미터에서 가져옴) const userInitPosition = initCameraPosition || options.initPosition || getGwanghwamunCameraPosition(500); console.log("초기 위치 설정:", userInitPosition); // 4. 맵 옵션 설정 (공식 API 기반) const mapOptions = { mapId: "vmap", initPosition: new window.vw.CameraPosition(new window.vw.CoordZ(userInitPosition.coord.x, userInitPosition.coord.y, userInitPosition.coord.z), new window.vw.Direction(userInitPosition.direction.heading || 0, userInitPosition.direction.pitch || -90, userInitPosition.direction.roll || 0)), logo: true, navigation: true, autoRotate: false, showGrid: false, showAxis: false, backgroundColor: "#000000", terrain: true, buildings: true, roads: true, water: true, vegetation: true, ...Object.fromEntries(Object.entries(options).filter(([key]) => key !== "initPosition")), }; console.log("맵 옵션:", mapOptions); // 4. 맵 설정 및 시작 mapInstance.setOption(mapOptions); mapInstance.start(); // 전역 인스턴스 저장 window.vworldMapInstance = mapInstance; mapInstanceRef.current = mapInstance; // 디버깅: 사용 가능한 메서드 확인 console.log("맵 인스턴스 메서드:", Object.getOwnPropertyNames(mapInstance)); console.log("맵 인스턴스 프로토타입:", Object.getOwnPropertyNames(Object.getPrototypeOf(mapInstance))); // 5. 현재 위치 확인 (위치 설정 없이) setTimeout(() => { try { const mapInst = mapInstanceRef.current; if (!mapInst) return; // 현재 위치 확인 if (typeof mapInst.getCurrentPosition === "function") { const currentPos = mapInst.getCurrentPosition(); console.log("현재 카메라 위치:", currentPos); } } catch (error) { console.warn("카메라 위치 확인 실패:", error); } }, 1000); // 이벤트 리스너 등록 if (onCameraChange && typeof mapInstanceRef.current?.on === "function") { mapInstanceRef.current.on("camerachange", (event) => { if (event && typeof event === "object" && "camera" in event) { const cameraEvent = event; onCameraChange(cameraEvent.camera); } }); } if (onMapClick && typeof mapInstanceRef.current?.on === "function") { mapInstanceRef.current.on("click", (event) => { if (event && typeof event === "object" && "coord" in event) { const clickEvent = event; onMapClick(clickEvent.coord); } }); } console.log("VWorld 3D 맵 초기화 완료"); setMapInitialized(true); onMapReady?.(mapInstance); onMapLoad?.(); } catch (error) { const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; console.error("맵 초기화 오류:", errorMessage); setError(errorMessage); onError?.(errorMessage); // 오류 발생 시 전역 인스턴스 정리 delete window.vworldMapInstance; } }; // 약간의 지연 후 초기화 const timer = setTimeout(initializeMap, 1000); return () => clearTimeout(timer); }, [ scriptLoaded, mapInitialized, options, onMapReady, onError, onCameraChange, onMapClick, onMapLoad, initCameraPosition, ]); // 동적 카메라 위치 제어 useEffect(() => { if (!mapInstanceRef.current || !mapInitialized || !cameraPosition) return; try { const mapInstance = mapInstanceRef.current; if (typeof mapInstance.moveTo === "function") { mapInstance.moveTo(cameraPosition); } else if (typeof mapInstance.setCamera === "function") { mapInstance.setCamera(cameraPosition); } } catch (error) { console.warn("카메라 위치 설정 실패:", error); } }, [cameraPosition, mapInitialized]); // 줌 레벨 제어 useEffect(() => { if (!mapInstanceRef.current || !mapInitialized || zoom === undefined) return; try { const mapInstance = mapInstanceRef.current; if (typeof mapInstance.setZoom === "function") { mapInstance.setZoom(zoom); } } catch (error) { console.warn("줌 레벨 설정 실패:", error); } }, [zoom, mapInitialized]); // 이벤트 리스너 등록 useEffect(() => { if (!mapInstanceRef.current || !mapInitialized) return; const mapInstance = mapInstanceRef.current; // 카메라 변경 이벤트 if (onCameraChange && typeof mapInstance.on === "function") { mapInstance.on("camerachange", (event) => { if (event && typeof event === "object" && "camera" in event) { const cameraEvent = event; onCameraChange(cameraEvent.camera); } }); } // 줌 변경 이벤트 if (onZoomChange && typeof mapInstance.on === "function") { mapInstance.on("zoomchange", (event) => { if (event && typeof event === "object" && "zoom" in event) { const zoomEvent = event; onZoomChange(zoomEvent.zoom); } }); } // 맵 클릭 이벤트 if (onMapClick && typeof mapInstance.on === "function") { mapInstance.on("click", (event) => { if (event && typeof event === "object" && "coord" in event) { const clickEvent = event; onMapClick(clickEvent.coord); } }); } // 맵 더블클릭 이벤트 if (onMapDoubleClick && typeof mapInstance.on === "function") { mapInstance.on("dblclick", (event) => { if (event && typeof event === "object" && "coord" in event) { const doubleClickEvent = event; onMapDoubleClick(doubleClickEvent.coord); } }); } // 마우스 이동 이벤트 if (onMouseMove && typeof mapInstance.on === "function") { mapInstance.on("mousemove", (event) => { if (event && typeof event === "object" && "coord" in event) { const mouseMoveEvent = event; onMouseMove(mouseMoveEvent.coord); } }); } // 마우스 휠 이벤트 if (onWheel && typeof mapInstance.on === "function") { mapInstance.on("wheel", (event) => { if (event && typeof event === "object" && "delta" in event) { const wheelEvent = event; onWheel(wheelEvent.delta); } }); } }, [ mapInitialized, onCameraChange, onZoomChange, onMapClick, onMapDoubleClick, onMouseMove, onWheel, ]); // 컴포넌트 언마운트 시 정리 useEffect(() => { return () => { console.log("VWorldMap 컴포넌트 언마운트"); // 전역 인스턴스는 유지 (다른 컴포넌트에서 재사용 가능) }; }, []); const containerStyle = { width, height, position: "relative", overflow: "hidden", ...style, }; if (loading) { return (jsx("div", { className: className, style: containerStyle, children: jsx("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", height: "100%", backgroundColor: "#f5f5f5", }, children: jsxs("div", { style: { textAlign: "center" }, children: [jsx("div", { style: { marginBottom: "10px" }, children: "VWorld 3D API \uB85C\uB529 \uC911..." }), jsx("div", { style: { fontSize: "12px", color: "#666" }, children: "\uD55C\uAD6D \uACF5\uACF5\uB370\uC774\uD130 3D \uC9C0\uB3C4" })] }) }) })); } if (error) { return (jsx("div", { className: className, style: containerStyle, children: jsx("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "red", backgroundColor: "#fff5f5", border: "1px solid #fed7d7", borderRadius: "4px", }, children: jsxs("div", { style: { textAlign: "center" }, children: [jsx("div", { style: { marginBottom: "10px" }, children: "\u26A0\uFE0F \uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4" }), jsx("div", { style: { fontSize: "14px" }, children: error })] }) }) })); } return (jsxs("div", { className: className, style: containerStyle, children: [jsx("div", { ref: mapDiv, id: "vmap", style: { width: "100%", height: "100%", position: "absolute", top: 0, left: 0, zIndex: 1, } }), !mapInitialized && (jsx("div", { style: { position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", background: "rgba(0,0,0,0.7)", color: "white", padding: "10px 20px", borderRadius: "5px", fontSize: "14px", zIndex: 2, }, children: "\uB9F5 \uCD08\uAE30\uD654 \uC911..." }))] })); }; const VWorldControls = ({ map, onCameraChange, className = "", style = {}, }) => { const handleZoomIn = () => { if (map && typeof map.zoomIn === "function") { map.zoomIn(); } }; const handleZoomOut = () => { if (map && typeof map.zoomOut === "function") { map.zoomOut(); } }; const handleReset = () => { if (map && typeof map.getCurrentPosition === "function") { const resetPosition = { coord: { x: 126.977, y: 37.566, z: 1000000 }, // 광화문 direction: { heading: 0, pitch: -90, roll: 0 }, }; if (typeof map.moveTo === "function") { map.moveTo(resetPosition); } else if (typeof map.setCamera === "function") { map.setCamera(resetPosition); } onCameraChange?.(resetPosition); } }; const handleRotateLeft = () => { if (map && typeof map.getCurrentPosition === "function") { const currentPos = map.getCurrentPosition(); const newPosition = { ...currentPos, direction: { ...currentPos.direction, heading: (currentPos.direction.heading - 45) % 360, }, }; if (typeof map.moveTo === "function") { map.moveTo(newPosition); } else if (typeof map.setCamera === "function") { map.setCamera(newPosition); } onCameraChange?.(newPosition); } }; const handleRotateRight = () => { if (map && typeof map.getCurrentPosition === "function") { const currentPos = map.getCurrentPosition(); const newPosition = { ...currentPos, direction: { ...currentPos.direction, heading: (currentPos.direction.heading + 45) % 360, }, }; if (typeof map.moveTo === "function") { map.moveTo(newPosition); } else if (typeof map.setCamera === "function") { map.setCamera(newPosition); } onCameraChange?.(newPosition); } }; const handleTiltUp = () => { if (map && typeof map.getCurrentPosition === "function") { const currentPos = map.getCurrentPosition(); const newPosition = { ...currentPos, direction: { ...currentPos.direction, pitch: Math.max(-90, currentPos.direction.pitch - 15), }, }; if (typeof map.moveTo === "function") { map.moveTo(newPosition); } else if (typeof map.setCamera === "function") { map.setCamera(newPosition); } onCameraChange?.(newPosition); } }; const handleTiltDown = () => { if (map && typeof map.getCurrentPosition === "function") { const currentPos = map.getCurrentPosition(); const newPosition = { ...currentPos, direction: { ...currentPos.direction, pitch: Math.min(90, currentPos.direction.pitch + 15), }, }; if (typeof map.moveTo === "function") { map.moveTo(newPosition); } else if (typeof map.setCamera === "function") { map.setCamera(newPosition); } onCameraChange?.(newPosition); } }; const controlButtonStyle = { width: "40px", height: "40px", border: "none", borderRadius: "4px", backgroundColor: "rgba(255, 255, 255, 0.9)", color: "#333", fontSize: "16px", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", margin: "2px", boxShadow: "0 2px 4px rgba(0,0,0,0.2)", transition: "all 0.2s ease", }; const containerStyle = { position: "absolute", top: "10px", right: "10px", zIndex: 1000, display: "flex", flexDirection: "column", gap: "4px", ...style, }; return (jsxs("div", { className: className, style: containerStyle, children: [jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "2px" }, children: [jsx("button", { onClick: handleZoomIn, style: controlButtonStyle, title: "\uD655\uB300", children: "+" }), jsx("button", { onClick: handleZoomOut, style: controlButtonStyle, title: "\uCD95\uC18C", children: "\u2212" })] }), jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "2px" }, children: [jsx("button", { onClick: handleRotateLeft, style: controlButtonStyle, title: "\uC67C\uCABD \uD68C\uC804", children: "\u21B6" }), jsx("button", { onClick: handleRotateRight, style: controlButtonStyle, title: "\uC624\uB978\uCABD \uD68C\uC804", children: "\u21B7" })] }), jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "2px" }, children: [jsx("button", { onClick: handleTiltUp, style: controlButtonStyle, title: "\uC704\uB85C \uAE30\uC6B8\uAE30", children: "\u2191" }), jsx("button", { onClick: handleTiltDown, style: controlButtonStyle, title: "\uC544\uB798\uB85C \uAE30\uC6B8\uAE30", children: "\u2193" })] }), jsx("button", { onClick: handleReset, style: { ...controlButtonStyle, width: "40px", height: "40px", backgroundColor: "rgba(255, 255, 255, 0.9)", color: "#e74c3c", }, title: "\uC704\uCE58 \uCD08\uAE30\uD654", children: "\u2302" })] })); }; const VWorldInfoPanel = ({ map, className = "", style = {}, }) => { const [currentPosition, setCurrentPosition] = useState(null); const [zoomLevel, setZoomLevel] = useState(null); const [isVisible, setIsVisible] = useState(true); useEffect(() => { if (!map) return; const updateInfo = () => { try { if (typeof map.getCurrentPosition === "function") { const position = map.getCurrentPosition(); setCurrentPosition(position); } if (typeof map.getZoom === "function") { const zoom = map.getZoom(); setZoomLevel(zoom); } } catch (error) { console.warn("정보 업데이트 실패:", error); } }; // 초기 정보 업데이트 updateInfo(); // 주기적으로 정보 업데이트 (1초마다) const interval = setInterval(updateInfo, 1000); return () => clearInterval(interval); }, [map]); const formatCoordinate = (coord) => { const lat = coord.y.toFixed(6); const lon = coord.x.toFixed(6); const alt = coord.z.toFixed(0); return `${lat}°N, ${lon}°E, ${alt}m`; }; const formatDirection = (heading, pitch, roll) => { return `H: ${heading.toFixed(1)}°, P: ${pitch.toFixed(1)}°, R: ${roll.toFixed(1)}°`; }; const containerStyle = { position: "absolute", bottom: "10px", left: "10px", zIndex: 1000, backgroundColor: "rgba(0, 0, 0, 0.8)", color: "white", padding: "12px", borderRadius: "6px", fontSize: "12px", fontFamily: "monospace", minWidth: "280px", backdropFilter: "blur(4px)", border: "1px solid rgba(255, 255, 255, 0.2)", ...style, }; const toggleButtonStyle = { position: "absolute", top: "-30px", right: "0px", backgroundColor: "rgba(0, 0, 0, 0.8)", color: "white", border: "none", borderRadius: "4px", padding: "4px 8px", fontSize: "10px", cursor: "pointer", }; if (!isVisible) { return (jsx("div", { style: { position: "absolute", bottom: "10px", left: "10px", zIndex: 1000, }, children: jsx("button", { onClick: () => setIsVisible(true), style: toggleButtonStyle, children: "\uC815\uBCF4 \uD45C\uC2DC" }) })); } return (jsxs("div", { className: className, style: containerStyle, children: [jsx("button", { onClick: () => setIsVisible(false), style: toggleButtonStyle, children: "\uC228\uAE30\uAE30" }), jsx("div", { style: { marginBottom: "8px", fontWeight: "bold", fontSize: "14px" }, children: "VWorld 3D \uC815\uBCF4" }), currentPosition && (jsxs(Fragment, { children: [jsxs("div", { style: { marginBottom: "4px" }, children: [jsx("strong", { children: "\uC704\uCE58:" }), " ", formatCoordinate(currentPosition.coord)] }), jsxs("div", { style: { marginBottom: "4px" }, children: [jsx("strong", { children: "\uBC29\uD5A5:" }), " ", formatDirection(currentPosition.direction.heading, currentPosition.direction.pitch, currentPosition.direction.roll)] }), jsxs("div", { style: { marginBottom: "4px" }, children: [jsx("strong", { children: "\uACE0\uB3C4:" }), " ", currentPosition.coord.z.toLocaleString(), "m (", getAltitudeDescription(currentPosition.coord.z), ")"] }), jsxs("div", { style: { marginBottom: "4px" }, children: [jsx("strong", { children: "WGS84:" }), " ", coordToWGS84(currentPosition.coord)] })] })), zoomLevel !== null && (jsxs("div", { style: { marginBottom: "4px" }, children: [jsx("strong", { children: "\uC90C:" }), " ", zoomLevel.toFixed(1), " (", getZoomDescription(zoomLevel), ")"] })), jsx("div", { style: { marginTop: "8px", fontSize: "10px", opacity: 0.7 }, children: "\uB9C8\uC6B0\uC2A4\uB85C \uC9C0\uB3C4\uB97C \uC870\uC791\uD558\uC138\uC694" })] })); }; const VWorldLocationPicker = ({ map, onLocationSelect, className = "", style = {}, }) => { const [savedLocations, setSavedLocations] = useState([]); const [selectedLocation, setSelectedLocation] = useState(null); const [locationName, setLocationName] = useState(""); const [isVisible, setIsVisible] = useState(false); // 로컬 스토리지에서 저장된 위치 불러오기 useEffect(() => { const saved = localStorage.getItem("vworld-saved-locations"); if (saved) { try { const locations = JSON.parse(saved); setSavedLocations(locations); } catch (error) { console.warn("저장된 위치 불러오기 실패:", error); } } }, []); // 위치 저장 const saveLocation = () => { if (!selectedLocation || !locationName.trim()) return; const newLocation = { id: Date.now().toString(), name: locationName.trim(), coord: selectedLocation, timestamp: Date.now(), }; const updatedLocations = [...savedLocations, newLocation]; setSavedLocations(updatedLocations); localStorage.setItem("vworld-saved-locations", JSON.stringify(updatedLocations)); setLocationName(""); setSelectedLocation(null); }; // 위치 삭제 const deleteLocation = (id) => { const updatedLocations = savedLocations.filter((loc) => loc.id !== id); setSavedLocations(updatedLocations); localStorage.setItem("vworld-saved-locations", JSON.stringify(updatedLocations)); }; // 위치로 이동 const moveToLocation = (coord) => { if (!map) return; try { if (typeof map.moveTo === "function") { map.moveTo({ coord, direction: { heading: 0, pitch: -90, roll: 0 }, }); } else if (typeof map.setCamera === "function") { map.setCamera({ coord, direction: { heading: 0, pitch: -90, roll: 0 }, }); } onLocationSelect?.(coord); } catch (error) { console.warn("위치 이동 실패:", error); } }; // WGS84 좌표로 이동 const moveToWGS84 = () => { const input = prompt("WGS84 좌표를 입력하세요 (예: 126.977,37.566,1000):"); if (!input) return; try { const coord = wgs84ToCoord(input); setSelectedLocation(coord); moveToLocation(coord); } catch (error) { alert("잘못된 좌표 형식입니다. 예: 126.977,37.566,1000"); } }; const containerStyle = { position: "absolute", top: "10px", left: "10px", zIndex: 1000, backgroundColor: "rgba(255, 255, 255, 0.95)", border: "1px solid #ddd", borderRadius: "8px", padding: "16px", minWidth: "300px", boxShadow: "0 4px 12px rgba(0,0,0,0.15)", ...style, }; const buttonStyle = { backgroundColor: "#007bff", color: "white", border: "none", borderRadius: "4px", padding: "8px 12px", margin: "4px", cursor: "pointer", fontSize: "12px", }; const inputStyle = { width: "100%", padding: "8px", border: "1px solid #ddd", borderRadius: "4px", marginBottom: "8px", fontSize: "12px", }; const locationItemStyle = { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px", border: "1px solid #eee", borderRadius: "4px", marginBottom: "4px", fontSize: "12px", }; if (!isVisible) { return (jsx("div", { style: { position: "absolute", top: "10px", left: "10px", zIndex: 1000, }, children: jsx("button", { onClick: () => setIsVisible(true), style: { ...buttonStyle, backgroundColor: "#28a745", }, children: "\uD83D\uDCCD \uC704\uCE58 \uAD00\uB9AC" }) })); } return (jsxs("div", { className: className, style: containerStyle, children: [jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "12px", }, children: [jsx("h3", { style: { margin: 0, fontSize: "16px" }, children: "\uD83D\uDCCD \uC704\uCE58 \uAD00\uB9AC" }), jsx("button", { onClick: () => setIsVisible(false), style: { ...buttonStyle, backgroundColor: "#6c757d", padding: "4px 8px", fontSize: "10px", }, children: "\u2715" })] }), jsxs("div", { style: { marginBottom: "16px" }, children: [jsx("h4", { style: { margin: "0 0 8px 0", fontSize: "14px" }, children: "\uD604\uC7AC \uC704\uCE58 \uC800\uC7A5" }), jsx("input", { type: "text", placeholder: "\uC704\uCE58 \uC774\uB984\uC744 \uC785\uB825\uD558\uC138\uC694", value: locationName, onChange: (e) => setLocationName(e.target.value), style: inputStyle }), jsx("button", { onClick: saveLocation, disabled: !locationName.trim(), style: { ...buttonStyle, backgroundColor: locationName.trim() ? "#28a745" : "#6c757d", opacity: locationName.trim() ? 1 : 0.6, }, children: "\uC800\uC7A5" })] }), jsxs("div", { style: { marginBottom: "16px" }, children: [jsx("h4", { style: { margin: "0 0 8px 0", fontSize: "14px" }, children: "\uC88C\uD45C\uB85C \uC774\uB3D9" }), jsx("button", { onClick: moveToWGS84, style: buttonStyle, children: "WGS84 \uC88C\uD45C \uC785\uB825" })] }), jsxs("div", { children: [jsxs("h4", { style: { margin: "0 0 8px 0", fontSize: "14px" }, children: ["\uC800\uC7A5\uB41C \uC704\uCE58 (", savedLocations.length, ")"] }), savedLocations.length === 0 ? (jsx("div", { style: { color: "#666", fontSize: "12px", fontStyle: "italic" }, children: "\uC800\uC7A5\uB41C \uC704\uCE58\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." })) : (jsx("div", { style: { maxHeight: "200px", overflowY: "auto" }, children: savedLocations.map((location) => (jsxs("div", { style: locationItemStyle, children: [jsxs("div", { children: [jsx("div", { style: { fontWeight: "bold" }, children: location.name }), jsx("div", { style: { fontSize: "10px", color: "#666" }, children: coordToWGS84(location.coord) })] }), jsxs("div", { children: [jsx("button", { onClick: () => moveToLocation(location.coord), style: { ...buttonStyle, backgroundColor: "#007bff", padding: "4px 8px", fontSize: "10px", marginRight: "4px", }, children: "\uC774\uB3D9" }), jsx("button", { onClick: () => deleteLocation(location.id), style: { ...buttonStyle, backgroundColor: "#dc3545", padding: "4px 8px", fontSize: "10px", }, children: "\uC0AD\uC81C" })] })] }, location.id))) }))] })] })); }; const VWorldTour = ({ map, onTourComplete, className = "", style = {}, }) => { const [isPlaying, setIsPlaying] = useState(false); const [currentPointIndex, setCurrentPointIndex] = useState(0); const [progress, setProgress] = useState(0); const [isVisible, setIsVisible] = useState(false); const animationRef = useRef(null); const startTimeRef = useRef(null); // 기본 투어 경로 (한국 주요 도시들) const defaultTourPoints = [ { id: "seoul", name: "서울시청", position: getSeoulCityHallCameraPosition(800000), duration: 3, }, { id: "incheon", name: "인천시청", position: getIncheonCityHallCameraPosition(600000), duration: 3, }, { id: "daejeon", name: "대전시청", position: getDaejeonCityHallCameraPosition(700000), duration: 3, }, { id: "gwangju", name: "광주시청", position: getGwangjuCityHallCameraPosition(600000), duration: 3, }, { id: "daegu", name: "대구시청", position: getDaeguCityHallCameraPosition(600000), duration: 3, }, { id: "ulsan", name: "울산시청", position: getUlsanCityHallCameraPosition(600000), duration: 3, }, { id: "busan", name: "부산시청", position: getBusanCityHallCameraPosition(600000), duration: 3, }, ]; const [tourPoints] = useState(defaultTourPoints); // 투어 애니메이션 const animateTour = (timestamp) => { if (!map || !startTimeRef.current) return; const elapsed = timestamp - startTimeRef.current; const totalDuration = tourPoints.reduce((sum, point) => sum + point.duration, 0) * 1000; const currentProgress = Math.min(elapsed / totalDuration, 1); setProgress(currentProgress); // 현재 투어 포인트 계산 let accumulatedTime = 0; let currentPoint = tourPoints[0]; let nextPoint = tourPoints[1]; for (let i = 0; i < tourPoints.length; i++) { const pointDuration = tourPoints[i].duration * 1000; if (elapsed < accumulatedTime + pointDuration) { currentPoint = tourPoints[i]; nextPoint = tourPoints[i + 1] || tourPoints[0]; setCurrentPointIndex(i); break; } accumulatedTime += pointDuration; } // 두 지점 간 보간 const segmentStart = accumulatedTime; const segmentEnd = accumulatedTime + currentPoint.duration * 1000; const segmentProgress = Math.max(0, Math.min(1, (elapsed - segmentStart) / (segmentEnd - segmentStart))); const interpolatedPosition = interpolateCameraPosition(currentPoint.position, nextPoint.position, segmentProgress, "easeInOutCubic"); // 카메라 이동 try { if (typeof map.moveTo === "function") { map.moveTo(interpolatedPosition); } else if (typeof map.setCamera === "function") { map.setCamera(interpolatedPosition); } } catch (error) { console.warn("투어 카메라 이동 실패:", error); } // 투어 계속 또는 완료 if (currentProgress < 1) { animationRef.current = requestAnimationFrame(animateTour); } else { setIsPlaying(false); onTourComplete?.(); } }; // 투어 시작 const startTour = () => { if (!map) return; setIsPlaying(true); setCurrentPointIndex(0); setProgress(0); startTimeRef.current = performance.now(); // 첫 번째 지점으로 이동 const firstPoint = tourPoints[0]; try { if (typeof map.moveTo === "function") { map.moveTo(firstPoint.position); } else if (typeof map.setCamera === "function") { map.setCamera(firstPoint.position); } } catch (error) { console.warn("투어 시작 실패:", error); } // 애니메이션 시작 animationRef.current = requestAnimationFrame(animateTour); }; // 투어 정지 const stopTour = () => { setIsPlaying(false); if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } startTimeRef.current = null; }; // 특정 지점으로 이동 const goToPoint = (index) => { if (!map || index < 0 || index >= tourPoints.length) return; const point = tourPoints[index]; setCurrentPointIndex(index); try { if (typeof map.moveTo === "function") { map.moveTo(point.position); } else if (typeof map.setCamera === "function") { map.setCamera(point.position); } } catch (error) { console.warn("지점 이동 실패:", error); } }; // 컴포넌트 언마운트 시 정리 useEffect(() => { return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, []); const containerStyle = { position: "absolute", bottom: "10px", right: "10px", zIndex: 1000, backgroundColor: "rgba(255, 255, 255, 0.95)", border: "1px solid #ddd", borderRadius: "8px", padding: "16px", minWidth: "280px", boxShadow: "0 4px 12px rgba(0,0,0,0.15)", ...style, }; const buttonStyle = { backgroundColor: "#007bff", color: "white", border: "none", borderRadius: "4px", padding: "8px 12px", margin: "4px", cursor: "pointer", fontSize: "12px", }; const progressBarStyle = { width: "100%", height: "8px", backgroundColor: "#e9ecef", borderRadius: "4px", overflow: "hidden", marginBottom: "12px", }; const progressFillStyle = { height: "100%", backgroundColor: "#007bff", width: `${progress * 100}%`, transition: "width 0.1s ease", }; if (!isVisible) { return (jsx("div", { style: { position: "absolute", bottom: "10px", right: "10px", zIndex: 1000, }, children: jsx("button", { onClick: () => setIsVisible(true), style: { ...buttonStyle, backgroundColor: "#28a745", }, children: "\uD83D\uDE81 \uC790\uB3D9 \uD22C\uC5B4" }) })); } return (jsxs("div", { className: className, style: containerStyle, children: [jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "12px", }, children: [jsx("h3", { style: { margin: 0, fontSize: "16px" }, children: "\uD83D\uDE81 \uD55C\uAD6D \uB3C4\uC2DC \uD22C\uC5B4" }), jsx("button", { onClick: () => setIsVisible(false), style: { ...buttonStyle, backgroundColor: "#6c757d", padding: "4px 8px", fontSize: "10px", }, children: "\u2715" })] }), jsxs("div", { style: { marginBottom: "12px" }, children: [jsxs("div", { style: { fontSize: "12px", marginBottom: "4px" }, children: ["\uC9C4\uD589\uB960: ", Math.round(progress * 100), "%"] }), jsx("div", { style: progressBarStyle, children: jsx("div", { style: progressFillStyle }) }), isPlaying && (jsxs("div", { style: { fontSize: "12px", color: "#666" }, children: ["\uD604\uC7AC: ", tourPoints[currentPointIndex]?.name] }))] }), jsx("div", { style: { marginBottom: "12px" }, children: !isPlaying ? (jsx("button", { onClick: startTour, style: { ...buttonStyle, backgroundColor: "#28a745", width: "100%", }, children: "\u25B6 \uD22C\uC5B4 \uC2DC\uC791" })) : (jsx("button", { onClick: stopTour, style: { ...but