feat: 구마모토 전용 Docker 지도 서비스 구축
- Google Maps API에서 Leaflet + OpenStreetMap으로 전환 - 구마모토 지역 특화 타일 서버 Docker 컨테이너 추가 - PostgreSQL + PostGIS + Mapnik 스택으로 지도 타일 생성 - API 키 불필요한 오픈소스 지도 솔루션 구현 - 개발/프로덕션 환경 Docker Compose 설정 완료 - 빠른 로딩과 오프라인 지원 가능한 지도 서비스 주요 변경사항: - src/components/Map.tsx: Leaflet 기반으로 완전 재작성 - docker/map-server/: 구마모토 지역 타일 서버 구축 - docker-compose.yml, docker-compose.dev.yml: 지도 서버 연동 - package.json: leaflet, react-leaflet 의존성 추가
This commit is contained in:
@@ -1,144 +1,110 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'
|
||||
import { Icon } from 'leaflet'
|
||||
import { Attraction } from '../types'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
interface MapProps {
|
||||
attractions: Attraction[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google: typeof google
|
||||
}
|
||||
}
|
||||
// 커스텀 마커 아이콘 설정
|
||||
const attractionIcon = new Icon({
|
||||
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41]
|
||||
})
|
||||
|
||||
const Map = ({ attractions }: MapProps) => {
|
||||
const mapRef = useRef<HTMLDivElement>(null)
|
||||
const mapInstanceRef = useRef<google.maps.Map | null>(null)
|
||||
const markersRef = useRef<google.maps.Marker[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''
|
||||
|
||||
// Google Maps API 스크립트 로드
|
||||
if (!window.google && apiKey) {
|
||||
const script = document.createElement('script')
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}`
|
||||
script.async = true
|
||||
script.defer = true
|
||||
|
||||
script.onload = () => {
|
||||
initMap()
|
||||
}
|
||||
|
||||
script.onerror = () => {
|
||||
console.error('Google Maps API 로드 실패')
|
||||
}
|
||||
|
||||
document.head.appendChild(script)
|
||||
} else if (window.google) {
|
||||
initMap()
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (!mapRef.current || !window.google) return
|
||||
|
||||
// 구마모토 중심 좌표
|
||||
const kumamotoCenter = { lat: 32.8031, lng: 130.7079 }
|
||||
|
||||
// 지도 생성 (위성 지도)
|
||||
const map = new window.google.maps.Map(mapRef.current, {
|
||||
center: kumamotoCenter,
|
||||
zoom: 10,
|
||||
mapTypeId: window.google.maps.MapTypeId.SATELLITE, // 위성 지도
|
||||
mapTypeControl: true,
|
||||
mapTypeControlOptions: {
|
||||
style: window.google.maps.MapTypeControlStyle.HORIZONTAL_BAR,
|
||||
position: window.google.maps.ControlPosition.TOP_RIGHT,
|
||||
mapTypeIds: [
|
||||
window.google.maps.MapTypeId.SATELLITE,
|
||||
window.google.maps.MapTypeId.ROADMAP,
|
||||
window.google.maps.MapTypeId.HYBRID,
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
mapInstanceRef.current = map
|
||||
|
||||
// 기존 마커 제거
|
||||
markersRef.current.forEach((marker) => {
|
||||
marker.setMap(null)
|
||||
})
|
||||
markersRef.current = []
|
||||
|
||||
// 관광지 마커 추가
|
||||
attractions.forEach((attraction) => {
|
||||
if (attraction.coordinates) {
|
||||
const marker = new window.google.maps.Marker({
|
||||
position: {
|
||||
lat: attraction.coordinates.lat,
|
||||
lng: attraction.coordinates.lng,
|
||||
},
|
||||
map: map,
|
||||
title: attraction.nameKo,
|
||||
label: {
|
||||
text: attraction.nameKo,
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
})
|
||||
|
||||
// 정보창 추가
|
||||
const infoWindow = new window.google.maps.InfoWindow({
|
||||
content: `
|
||||
<div style="padding: 8px; max-width: 250px;">
|
||||
<h3 style="margin: 0 0 8px 0; font-weight: bold; color: #333; font-size: 16px;">${attraction.nameKo}</h3>
|
||||
<p style="margin: 0 0 4px 0; color: #666; font-size: 13px;">${attraction.description}</p>
|
||||
<p style="margin: 0; color: #888; font-size: 12px;">📍 ${attraction.location}</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
marker.addListener('click', () => {
|
||||
infoWindow.open(map, marker)
|
||||
})
|
||||
|
||||
markersRef.current.push(marker)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
// cleanup
|
||||
markersRef.current.forEach((marker) => {
|
||||
marker.setMap(null)
|
||||
})
|
||||
markersRef.current = []
|
||||
}
|
||||
}, [attractions])
|
||||
|
||||
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY
|
||||
// 구마모토 중심 좌표
|
||||
const kumamotoCenter: [number, number] = [32.8031, 130.7079]
|
||||
|
||||
// 타일 서버 URL (Docker 컨테이너 또는 기본 OSM)
|
||||
const tilesUrl = import.meta.env.VITE_MAP_TILES_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
const isCustomTiles = !!import.meta.env.VITE_MAP_TILES_URL
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">🗺️ 구마모토 지도</h2>
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="w-full h-96 rounded-lg overflow-hidden border border-gray-200"
|
||||
style={{ minHeight: '400px' }}
|
||||
/>
|
||||
{!apiKey && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded text-sm text-blue-800">
|
||||
💡 Google Maps API 키가 설정되지 않았습니다.
|
||||
<br />
|
||||
<span className="text-xs mt-1 block">
|
||||
• 가족 여행용으로 사용하시면 <strong>월 $200 무료 크레딧</strong>으로 충분합니다!<br />
|
||||
• 지도 로드: 월 28,000회까지 무료<br />
|
||||
• API 키 발급: <a href="https://console.cloud.google.com/google/maps-apis" target="_blank" rel="noopener noreferrer" className="underline">Google Cloud Console</a><br />
|
||||
• <code className="px-1 bg-blue-100 rounded">.env</code> 파일에 <code className="px-1 bg-blue-100 rounded">VITE_GOOGLE_MAPS_API_KEY=your_key</code> 추가
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200">
|
||||
<MapContainer
|
||||
center={kumamotoCenter}
|
||||
zoom={10}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<TileLayer
|
||||
url={tilesUrl}
|
||||
attribution={
|
||||
isCustomTiles
|
||||
? '© 구마모토 여행 지도 | OpenStreetMap contributors'
|
||||
: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}
|
||||
maxZoom={18}
|
||||
/>
|
||||
|
||||
{attractions.map((attraction) => (
|
||||
attraction.coordinates && (
|
||||
<Marker
|
||||
key={attraction.id}
|
||||
position={[attraction.coordinates.lat, attraction.coordinates.lng]}
|
||||
icon={attractionIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-2 max-w-xs">
|
||||
<h3 className="font-bold text-gray-800 mb-2 text-base">
|
||||
{attraction.nameKo}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-2">
|
||||
{attraction.description}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
📍 {attraction.location}
|
||||
</p>
|
||||
{attraction.website && (
|
||||
<a
|
||||
href={attraction.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 text-xs underline mt-1 block"
|
||||
>
|
||||
웹사이트 방문
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded text-sm text-green-800">
|
||||
{isCustomTiles ? (
|
||||
<>
|
||||
🎯 <strong>구마모토 전용 지도 서버</strong> 사용 중!
|
||||
<br />
|
||||
<span className="text-xs mt-1 block">
|
||||
• 빠른 로딩 속도와 오프라인 지원<br />
|
||||
• Google Maps API 키 불필요<br />
|
||||
• 구마모토 지역 최적화된 지도 데이터
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🌍 <strong>OpenStreetMap</strong> 사용 중
|
||||
<br />
|
||||
<span className="text-xs mt-1 block">
|
||||
• 무료 오픈소스 지도 서비스<br />
|
||||
• API 키 불필요<br />
|
||||
• Docker 지도 서버 실행 시 더 빠른 성능 제공
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user