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:
@@ -12,4 +12,28 @@ services:
|
||||
command: sh -c "npm install && npm run dev -- --host"
|
||||
container_name: kumamoto-travel-planner-dev
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- map-server
|
||||
environment:
|
||||
- VITE_MAP_TILES_URL=http://localhost:8080/tiles
|
||||
|
||||
map-server:
|
||||
build:
|
||||
context: ./docker/map-server
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:80"
|
||||
container_name: kumamoto-map-server-dev
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- map_data_dev:/var/lib/postgresql/data
|
||||
- map_tiles_dev:/var/www/html/tiles
|
||||
environment:
|
||||
- POSTGRES_DB=kumamoto_map
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=mapserver123
|
||||
|
||||
volumes:
|
||||
map_data_dev:
|
||||
map_tiles_dev:
|
||||
|
||||
|
||||
@@ -9,4 +9,28 @@ services:
|
||||
- "3000:80"
|
||||
container_name: kumamoto-travel-planner
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- map-server
|
||||
environment:
|
||||
- VITE_MAP_TILES_URL=http://localhost:8080/tiles
|
||||
|
||||
map-server:
|
||||
build:
|
||||
context: ./docker/map-server
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:80"
|
||||
container_name: kumamoto-map-server
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- map_data:/var/lib/postgresql/data
|
||||
- map_tiles:/var/www/html/tiles
|
||||
environment:
|
||||
- POSTGRES_DB=kumamoto_map
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=mapserver123
|
||||
|
||||
volumes:
|
||||
map_data:
|
||||
map_tiles:
|
||||
|
||||
|
||||
44
docker/map-server/Dockerfile
Normal file
44
docker/map-server/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# 구마모토 지역 전용 타일 서버
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# 필요한 패키지 설치
|
||||
RUN apt-get update && apt-get install -y \
|
||||
apache2 \
|
||||
python3 \
|
||||
python3-pip \
|
||||
wget \
|
||||
unzip \
|
||||
osmosis \
|
||||
postgresql \
|
||||
postgresql-contrib \
|
||||
postgis \
|
||||
postgresql-postgis-scripts \
|
||||
osm2pgsql \
|
||||
mapnik-utils \
|
||||
python3-mapnik \
|
||||
fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /opt/map-server
|
||||
|
||||
# 구마모토 지역 OSM 데이터 다운로드 스크립트
|
||||
COPY scripts/download-kumamoto-data.sh /opt/map-server/
|
||||
COPY scripts/setup-tiles.py /opt/map-server/
|
||||
COPY config/mapnik-style.xml /opt/map-server/
|
||||
|
||||
# 권한 설정
|
||||
RUN chmod +x /opt/map-server/download-kumamoto-data.sh
|
||||
|
||||
# Apache 설정
|
||||
COPY config/apache-tiles.conf /etc/apache2/sites-available/tiles.conf
|
||||
RUN a2ensite tiles && a2dissite 000-default
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 80
|
||||
|
||||
# 시작 스크립트
|
||||
COPY scripts/start-server.sh /opt/map-server/
|
||||
RUN chmod +x /opt/map-server/start-server.sh
|
||||
|
||||
CMD ["/opt/map-server/start-server.sh"]
|
||||
31
docker/map-server/config/apache-tiles.conf
Normal file
31
docker/map-server/config/apache-tiles.conf
Normal file
@@ -0,0 +1,31 @@
|
||||
<VirtualHost *:80>
|
||||
ServerName kumamoto-tiles
|
||||
DocumentRoot /var/www/html
|
||||
|
||||
# 타일 서빙을 위한 설정
|
||||
<Directory "/var/www/html/tiles">
|
||||
Options Indexes FollowSymLinks
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
|
||||
# CORS 헤더 추가 (프론트엔드에서 접근 가능하도록)
|
||||
Header always set Access-Control-Allow-Origin "*"
|
||||
Header always set Access-Control-Allow-Methods "GET, OPTIONS"
|
||||
Header always set Access-Control-Allow-Headers "Content-Type"
|
||||
</Directory>
|
||||
|
||||
# 타일 캐싱 설정
|
||||
<LocationMatch "\.(png|jpg|jpeg)$">
|
||||
ExpiresActive On
|
||||
ExpiresDefault "access plus 7 days"
|
||||
Header append Cache-Control "public"
|
||||
</LocationMatch>
|
||||
|
||||
# 타일 URL 리라이팅
|
||||
RewriteEngine On
|
||||
RewriteRule ^/tiles/([0-9]+)/([0-9]+)/([0-9]+)\.png$ /tiles/$1/$2/$3.png [L]
|
||||
|
||||
# 로그 설정
|
||||
ErrorLog ${APACHE_LOG_DIR}/tiles_error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/tiles_access.log combined
|
||||
</VirtualHost>
|
||||
179
docker/map-server/config/mapnik-style.xml
Normal file
179
docker/map-server/config/mapnik-style.xml
Normal file
@@ -0,0 +1,179 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Map srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over" background-color="#f8f8f8">
|
||||
|
||||
<!-- 데이터베이스 연결 설정 -->
|
||||
<Parameters>
|
||||
<Parameter name="host">localhost</Parameter>
|
||||
<Parameter name="port">5432</Parameter>
|
||||
<Parameter name="user">postgres</Parameter>
|
||||
<Parameter name="dbname">kumamoto_map</Parameter>
|
||||
</Parameters>
|
||||
|
||||
<!-- 도로 스타일 -->
|
||||
<Style name="roads">
|
||||
<Rule>
|
||||
<Filter>[highway] = 'motorway'</Filter>
|
||||
<LineSymbolizer stroke="#ff6b6b" stroke-width="4" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[highway] = 'trunk'</Filter>
|
||||
<LineSymbolizer stroke="#ff8e53" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[highway] = 'primary'</Filter>
|
||||
<LineSymbolizer stroke="#ffa726" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[highway] = 'secondary'</Filter>
|
||||
<LineSymbolizer stroke="#ffcc02" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[highway] = 'tertiary'</Filter>
|
||||
<LineSymbolizer stroke="#fff176" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[highway] = 'residential' or [highway] = 'unclassified'</Filter>
|
||||
<LineSymbolizer stroke="#ffffff" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</Rule>
|
||||
</Style>
|
||||
|
||||
<!-- 물 스타일 -->
|
||||
<Style name="water">
|
||||
<Rule>
|
||||
<Filter>[natural] = 'water' or [waterway] = 'river' or [waterway] = 'stream'</Filter>
|
||||
<PolygonSymbolizer fill="#4fc3f7" fill-opacity="0.8"/>
|
||||
<LineSymbolizer stroke="#29b6f6" stroke-width="1"/>
|
||||
</Rule>
|
||||
</Style>
|
||||
|
||||
<!-- 건물 스타일 -->
|
||||
<Style name="buildings">
|
||||
<Rule>
|
||||
<Filter>[building] != ''</Filter>
|
||||
<PolygonSymbolizer fill="#e0e0e0" fill-opacity="0.8"/>
|
||||
<LineSymbolizer stroke="#bdbdbd" stroke-width="0.5"/>
|
||||
</Rule>
|
||||
</Style>
|
||||
|
||||
<!-- 녹지 스타일 -->
|
||||
<Style name="landuse">
|
||||
<Rule>
|
||||
<Filter>[landuse] = 'forest' or [natural] = 'wood'</Filter>
|
||||
<PolygonSymbolizer fill="#81c784" fill-opacity="0.6"/>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[landuse] = 'grass' or [leisure] = 'park'</Filter>
|
||||
<PolygonSymbolizer fill="#aed581" fill-opacity="0.6"/>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[landuse] = 'residential'</Filter>
|
||||
<PolygonSymbolizer fill="#f5f5f5" fill-opacity="0.4"/>
|
||||
</Rule>
|
||||
</Style>
|
||||
|
||||
<!-- 관광지 포인트 스타일 -->
|
||||
<Style name="tourism">
|
||||
<Rule>
|
||||
<Filter>[tourism] = 'attraction' or [tourism] = 'museum'</Filter>
|
||||
<MarkersSymbolizer file="/opt/map-server/symbols/attraction.png" width="16" height="16" allow-overlap="true"/>
|
||||
</Rule>
|
||||
</Style>
|
||||
|
||||
<!-- 지명 라벨 스타일 -->
|
||||
<Style name="place_labels">
|
||||
<Rule>
|
||||
<Filter>[place] = 'city'</Filter>
|
||||
<TextSymbolizer face-name="Noto Sans CJK JP Regular" size="14" fill="#333333" halo-fill="#ffffff" halo-radius="2">[name]</TextSymbolizer>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[place] = 'town'</Filter>
|
||||
<TextSymbolizer face-name="Noto Sans CJK JP Regular" size="12" fill="#555555" halo-fill="#ffffff" halo-radius="1">[name]</TextSymbolizer>
|
||||
</Rule>
|
||||
</Style>
|
||||
|
||||
<!-- 레이어 정의 -->
|
||||
<Layer name="landuse" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
|
||||
<StyleName>landuse</StyleName>
|
||||
<Datasource>
|
||||
<Parameter name="type">postgis</Parameter>
|
||||
<Parameter name="host">localhost</Parameter>
|
||||
<Parameter name="port">5432</Parameter>
|
||||
<Parameter name="user">postgres</Parameter>
|
||||
<Parameter name="dbname">kumamoto_map</Parameter>
|
||||
<Parameter name="table">(SELECT way, landuse, natural, leisure FROM planet_osm_polygon WHERE landuse IS NOT NULL OR natural IS NOT NULL OR leisure IS NOT NULL) as landuse</Parameter>
|
||||
<Parameter name="geometry_field">way</Parameter>
|
||||
<Parameter name="srid">3857</Parameter>
|
||||
</Datasource>
|
||||
</Layer>
|
||||
|
||||
<Layer name="water" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
|
||||
<StyleName>water</StyleName>
|
||||
<Datasource>
|
||||
<Parameter name="type">postgis</Parameter>
|
||||
<Parameter name="host">localhost</Parameter>
|
||||
<Parameter name="port">5432</Parameter>
|
||||
<Parameter name="user">postgres</Parameter>
|
||||
<Parameter name="dbname">kumamoto_map</Parameter>
|
||||
<Parameter name="table">(SELECT way, natural, waterway FROM planet_osm_polygon WHERE natural = 'water' UNION SELECT way, natural, waterway FROM planet_osm_line WHERE waterway IN ('river', 'stream')) as water</Parameter>
|
||||
<Parameter name="geometry_field">way</Parameter>
|
||||
<Parameter name="srid">3857</Parameter>
|
||||
</Datasource>
|
||||
</Layer>
|
||||
|
||||
<Layer name="buildings" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
|
||||
<StyleName>buildings</StyleName>
|
||||
<Datasource>
|
||||
<Parameter name="type">postgis</Parameter>
|
||||
<Parameter name="host">localhost</Parameter>
|
||||
<Parameter name="port">5432</Parameter>
|
||||
<Parameter name="user">postgres</Parameter>
|
||||
<Parameter name="dbname">kumamoto_map</Parameter>
|
||||
<Parameter name="table">(SELECT way, building FROM planet_osm_polygon WHERE building IS NOT NULL) as buildings</Parameter>
|
||||
<Parameter name="geometry_field">way</Parameter>
|
||||
<Parameter name="srid">3857</Parameter>
|
||||
</Datasource>
|
||||
</Layer>
|
||||
|
||||
<Layer name="roads" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
|
||||
<StyleName>roads</StyleName>
|
||||
<Datasource>
|
||||
<Parameter name="type">postgis</Parameter>
|
||||
<Parameter name="host">localhost</Parameter>
|
||||
<Parameter name="port">5432</Parameter>
|
||||
<Parameter name="user">postgres</Parameter>
|
||||
<Parameter name="dbname">kumamoto_map</Parameter>
|
||||
<Parameter name="table">(SELECT way, highway FROM planet_osm_line WHERE highway IS NOT NULL) as roads</Parameter>
|
||||
<Parameter name="geometry_field">way</Parameter>
|
||||
<Parameter name="srid">3857</Parameter>
|
||||
</Datasource>
|
||||
</Layer>
|
||||
|
||||
<Layer name="tourism" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
|
||||
<StyleName>tourism</StyleName>
|
||||
<Datasource>
|
||||
<Parameter name="type">postgis</Parameter>
|
||||
<Parameter name="host">localhost</Parameter>
|
||||
<Parameter name="port">5432</Parameter>
|
||||
<Parameter name="user">postgres</Parameter>
|
||||
<Parameter name="dbname">kumamoto_map</Parameter>
|
||||
<Parameter name="table">(SELECT way, tourism FROM planet_osm_point WHERE tourism IS NOT NULL) as tourism</Parameter>
|
||||
<Parameter name="geometry_field">way</Parameter>
|
||||
<Parameter name="srid">3857</Parameter>
|
||||
</Datasource>
|
||||
</Layer>
|
||||
|
||||
<Layer name="place_labels" srs="+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over">
|
||||
<StyleName>place_labels</StyleName>
|
||||
<Datasource>
|
||||
<Parameter name="type">postgis</Parameter>
|
||||
<Parameter name="host">localhost</Parameter>
|
||||
<Parameter name="port">5432</Parameter>
|
||||
<Parameter name="user">postgres</Parameter>
|
||||
<Parameter name="dbname">kumamoto_map</Parameter>
|
||||
<Parameter name="table">(SELECT way, name, place FROM planet_osm_point WHERE place IN ('city', 'town') AND name IS NOT NULL) as places</Parameter>
|
||||
<Parameter name="geometry_field">way</Parameter>
|
||||
<Parameter name="srid">3857</Parameter>
|
||||
</Datasource>
|
||||
</Layer>
|
||||
|
||||
</Map>
|
||||
50
docker/map-server/scripts/download-kumamoto-data.sh
Normal file
50
docker/map-server/scripts/download-kumamoto-data.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 구마모토 지역 OSM 데이터 다운로드 및 설정
|
||||
|
||||
echo "구마모토 지역 지도 데이터 다운로드 중..."
|
||||
|
||||
# 구마모토 지역 경계 (대략적인 좌표)
|
||||
# 북: 33.2, 남: 32.4, 동: 131.2, 서: 130.2
|
||||
NORTH=33.2
|
||||
SOUTH=32.4
|
||||
EAST=131.2
|
||||
WEST=130.2
|
||||
|
||||
# 구마모토 지역 OSM 데이터 다운로드
|
||||
wget -O /tmp/kumamoto.osm.pbf \
|
||||
"https://overpass-api.de/api/interpreter?data=[out:pbf][timeout:300];(relation[admin_level=4][name~'熊本'][name~'Kumamoto'];>;);out meta;"
|
||||
|
||||
# 대안: 일본 전체에서 구마모토 지역 추출
|
||||
if [ ! -f /tmp/kumamoto.osm.pbf ] || [ ! -s /tmp/kumamoto.osm.pbf ]; then
|
||||
echo "Overpass API에서 다운로드 실패, 대안 방법 사용..."
|
||||
|
||||
# 일본 큐슈 지역 데이터 다운로드 (더 안정적)
|
||||
wget -O /tmp/kyushu.osm.pbf \
|
||||
"http://download.geofabrik.de/asia/japan/kyushu-latest.osm.pbf"
|
||||
|
||||
# osmosis를 사용해 구마모토 지역만 추출
|
||||
osmosis --read-pbf /tmp/kyushu.osm.pbf \
|
||||
--bounding-box top=$NORTH left=$WEST bottom=$SOUTH right=$EAST \
|
||||
--write-pbf /tmp/kumamoto.osm.pbf
|
||||
fi
|
||||
|
||||
echo "구마모토 지역 데이터 다운로드 완료!"
|
||||
|
||||
# PostgreSQL 데이터베이스 설정
|
||||
echo "데이터베이스 설정 중..."
|
||||
|
||||
# PostgreSQL 시작
|
||||
service postgresql start
|
||||
|
||||
# 데이터베이스 생성
|
||||
sudo -u postgres createdb kumamoto_map
|
||||
sudo -u postgres psql -d kumamoto_map -c "CREATE EXTENSION postgis;"
|
||||
sudo -u postgres psql -d kumamoto_map -c "CREATE EXTENSION hstore;"
|
||||
|
||||
# OSM 데이터를 PostgreSQL에 임포트
|
||||
echo "OSM 데이터 임포트 중..."
|
||||
sudo -u postgres osm2pgsql -d kumamoto_map -c /tmp/kumamoto.osm.pbf \
|
||||
--hstore --style /usr/share/osm2pgsql/default.style --multi-geometry
|
||||
|
||||
echo "구마모토 지도 데이터 설정 완료!"
|
||||
114
docker/map-server/scripts/setup-tiles.py
Normal file
114
docker/map-server/scripts/setup-tiles.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
구마모토 지역 타일 생성 스크립트
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import mapnik
|
||||
import psycopg2
|
||||
from PIL import Image
|
||||
|
||||
# 구마모토 지역 경계
|
||||
KUMAMOTO_BOUNDS = {
|
||||
'north': 33.2,
|
||||
'south': 32.4,
|
||||
'east': 131.2,
|
||||
'west': 130.2
|
||||
}
|
||||
|
||||
# 타일 크기
|
||||
TILE_SIZE = 256
|
||||
|
||||
def deg2num(lat_deg, lon_deg, zoom):
|
||||
"""위경도를 타일 번호로 변환"""
|
||||
lat_rad = math.radians(lat_deg)
|
||||
n = 2.0 ** zoom
|
||||
xtile = int((lon_deg + 180.0) / 360.0 * n)
|
||||
ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
|
||||
return (xtile, ytile)
|
||||
|
||||
def num2deg(xtile, ytile, zoom):
|
||||
"""타일 번호를 위경도로 변환"""
|
||||
n = 2.0 ** zoom
|
||||
lon_deg = xtile / n * 360.0 - 180.0
|
||||
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
|
||||
lat_deg = math.degrees(lat_rad)
|
||||
return (lat_deg, lon_deg)
|
||||
|
||||
def create_mapnik_map():
|
||||
"""Mapnik 지도 객체 생성"""
|
||||
m = mapnik.Map(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
# 한국어/일본어 폰트 설정
|
||||
mapnik.FontEngine.register_fonts('/usr/share/fonts/truetype/noto/')
|
||||
|
||||
# 스타일 로드
|
||||
mapnik.load_map(m, '/opt/map-server/mapnik-style.xml')
|
||||
|
||||
return m
|
||||
|
||||
def generate_tile(m, z, x, y):
|
||||
"""개별 타일 생성"""
|
||||
# 타일 경계 계산
|
||||
lat_north, lon_west = num2deg(x, y, z)
|
||||
lat_south, lon_east = num2deg(x + 1, y + 1, z)
|
||||
|
||||
# 지도 범위 설정
|
||||
bbox = mapnik.Box2d(lon_west, lat_south, lon_east, lat_north)
|
||||
m.zoom_to_box(bbox)
|
||||
|
||||
# 타일 렌더링
|
||||
im = mapnik.Image(TILE_SIZE, TILE_SIZE)
|
||||
mapnik.render(m, im)
|
||||
|
||||
return im.tostring('png')
|
||||
|
||||
def generate_kumamoto_tiles():
|
||||
"""구마모토 지역 타일 생성"""
|
||||
print("구마모토 지역 타일 생성 시작...")
|
||||
|
||||
# Mapnik 지도 생성
|
||||
m = create_mapnik_map()
|
||||
|
||||
# 줌 레벨별 타일 생성 (8-16레벨)
|
||||
for zoom in range(8, 17):
|
||||
print(f"줌 레벨 {zoom} 타일 생성 중...")
|
||||
|
||||
# 구마모토 지역의 타일 범위 계산
|
||||
x_min, y_max = deg2num(KUMAMOTO_BOUNDS['north'], KUMAMOTO_BOUNDS['west'], zoom)
|
||||
x_max, y_min = deg2num(KUMAMOTO_BOUNDS['south'], KUMAMOTO_BOUNDS['east'], zoom)
|
||||
|
||||
tile_count = 0
|
||||
for x in range(x_min, x_max + 1):
|
||||
for y in range(y_min, y_max + 1):
|
||||
# 타일 디렉토리 생성
|
||||
tile_dir = f"/var/www/html/tiles/{zoom}/{x}"
|
||||
os.makedirs(tile_dir, exist_ok=True)
|
||||
|
||||
# 타일 파일 경로
|
||||
tile_path = f"{tile_dir}/{y}.png"
|
||||
|
||||
# 이미 존재하는 타일은 건너뛰기
|
||||
if os.path.exists(tile_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
# 타일 생성
|
||||
tile_data = generate_tile(m, zoom, x, y)
|
||||
|
||||
# 타일 저장
|
||||
with open(tile_path, 'wb') as f:
|
||||
f.write(tile_data)
|
||||
|
||||
tile_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"타일 생성 실패 ({zoom}/{x}/{y}): {e}")
|
||||
|
||||
print(f"줌 레벨 {zoom}: {tile_count}개 타일 생성 완료")
|
||||
|
||||
print("구마모토 지역 타일 생성 완료!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_kumamoto_tiles()
|
||||
26
docker/map-server/scripts/start-server.sh
Normal file
26
docker/map-server/scripts/start-server.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "구마모토 지도 서버 시작 중..."
|
||||
|
||||
# PostgreSQL 시작
|
||||
service postgresql start
|
||||
|
||||
# 데이터베이스가 없으면 초기 설정 실행
|
||||
if ! sudo -u postgres psql -lqt | cut -d \| -f 1 | grep -qw kumamoto_map; then
|
||||
echo "초기 데이터베이스 설정 실행 중..."
|
||||
/opt/map-server/download-kumamoto-data.sh
|
||||
|
||||
echo "타일 생성 중..."
|
||||
python3 /opt/map-server/setup-tiles.py
|
||||
else
|
||||
echo "기존 데이터베이스 사용"
|
||||
fi
|
||||
|
||||
# Apache 모듈 활성화
|
||||
a2enmod rewrite
|
||||
a2enmod headers
|
||||
a2enmod expires
|
||||
|
||||
# Apache 시작
|
||||
echo "Apache 웹서버 시작..."
|
||||
apache2ctl -D FOREGROUND
|
||||
51
package-lock.json
generated
51
package-lock.json
generated
@@ -8,9 +8,12 @@
|
||||
"name": "kumamoto-travel-planner",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"date-fns": "^2.30.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
@@ -835,6 +838,17 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1202,6 +1216,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1992,6 +2021,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -2466,6 +2501,20 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"date-fns": "^2.30.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"date-fns": "^2.30.0"
|
||||
"react-leaflet": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
@@ -24,4 +27,3 @@
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]>([])
|
||||
// 구마모토 중심 좌표
|
||||
const kumamotoCenter: [number, number] = [32.8031, 130.7079]
|
||||
|
||||
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
|
||||
// 타일 서버 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