vworld-react-3d
Version:
VWorld 3.0 API React Component - 한국 공공데이터 3D 지도 컴포넌트
1,248 lines (1,242 loc) • 54.6 kB
JavaScript
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