@things-factory/weather 9.1.19 → 10.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/weather",
3
- "version": "9.1.19",
3
+ "version": "10.0.0-beta.2",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -29,17 +29,17 @@
29
29
  "dependencies": {
30
30
  "@hyperviz/weather": "^0.1.0",
31
31
  "@mapbox/mbtiles": "^0.12.1",
32
- "@operato/graphql": "^9.0.0",
33
- "@operato/shell": "^9.0.0",
34
- "@things-factory/auth-base": "^9.1.19",
35
- "@things-factory/cache-service": "^9.1.19",
36
- "@things-factory/env": "^9.1.13",
37
- "@things-factory/shell": "^9.1.19",
32
+ "@operato/graphql": "^10.0.0-beta.1",
33
+ "@operato/shell": "^10.0.0-beta.1",
34
+ "@things-factory/auth-base": "^10.0.0-beta.2",
35
+ "@things-factory/cache-service": "^10.0.0-beta.2",
36
+ "@things-factory/env": "^10.0.0-beta.2",
37
+ "@things-factory/shell": "^10.0.0-beta.2",
38
38
  "colormap": "^2.3.2",
39
39
  "ol": "^10.4.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/colormap": "^2.3.4"
43
43
  },
44
- "gitHead": "078438034dbe19915108e89ff24024f7044a85a9"
44
+ "gitHead": "ea8a08ff802ae28a37fdda605b6356482e7a6faf"
45
45
  }
@@ -1,11 +0,0 @@
1
- {
2
- "extends": "../../tsconfig-base.json",
3
- "compilerOptions": {
4
- "strict": true,
5
- "declaration": true,
6
- "module": "esnext",
7
- "outDir": "../dist-client",
8
- "baseUrl": "./"
9
- },
10
- "include": ["./**/*"]
11
- }
@@ -1,96 +0,0 @@
1
- import { config } from '@things-factory/env'
2
- import { WeatherService, WeatherData, WeatherAround, Location } from './types.js'
3
-
4
- const baseUrl = 'https://api.open-meteo.com/v1/forecast'
5
- const apiKey = config.get('ApiKey')
6
-
7
- export async function getWeatherByLocation(location: Location): Promise<WeatherData> {
8
- const url = `${baseUrl}?apiKey=${apiKey}&latitude=${location.latitude}&longitude=${location.longitude}&current=temperature_2m,windspeed_10m,cloudcover,relative_humidity_2m,shortwave_radiation&timezone=UTC`
9
-
10
- const response = await fetch(url)
11
- if (!response.ok) throw new Error(`OpenMeteo API request failed: ${response.status}`)
12
-
13
- const data = await response.json()
14
-
15
- return {
16
- location,
17
- temperature: { current: data.current.temperature_2m, unit: '°C' },
18
- wind: { speed: data.current.windspeed_10m, unit: 'm/s', direction: null },
19
- humidity: data.current.relative_humidity_2m,
20
- cloudCoverage: data.current.cloudcover,
21
- solarRadiation: data.current.shortwave_radiation,
22
- weatherDescription: 'N/A',
23
- timestamp: new Date().toISOString()
24
- }
25
- }
26
-
27
- export async function getWeatherAroundLocation(location: Location, radius: number): Promise<WeatherAround> {
28
- // 그리드 크기를 도 단위로 명확히 정의
29
- const gridSizeDegrees = 0.2 // 약 22km (적도 기준)
30
-
31
- // Open-Meteo API는 grid 파라미터에 도 단위 사용
32
- const url = `${baseUrl}?latitude=${location.latitude}&longitude=${location.longitude}&grid=${gridSizeDegrees}&hourly=temperature_2m,windspeed_10m,wind_direction_10m,cloudcover,relative_humidity_2m,shortwave_radiation&timezone=UTC`
33
-
34
- const response = await fetch(url)
35
- if (!response.ok) throw new Error(`OpenMeteo API request failed: ${response.status}`)
36
-
37
- const data = await response.json()
38
- const weatherPoints = []
39
-
40
- // 2D 격자 구성 (API 응답에서 grid_latitude와 grid_longitude 배열 사용)
41
- const latitudes = data.grid_latitude || [location.latitude]
42
- const longitudes = data.grid_longitude || [location.longitude]
43
-
44
- // 2차원 그리드 구성
45
- for (let latIndex = 0; latIndex < latitudes.length; latIndex++) {
46
- for (let lonIndex = 0; lonIndex < longitudes.length; lonIndex++) {
47
- const pointIndex = latIndex * longitudes.length + lonIndex
48
-
49
- // 데이터 포인트가 있는 경우만 추가
50
- if (data.hourly.temperature_2m[pointIndex] !== undefined) {
51
- weatherPoints.push({
52
- location: {
53
- latitude: latitudes[latIndex],
54
- longitude: longitudes[lonIndex]
55
- },
56
- temperature: {
57
- current: data.hourly.temperature_2m[pointIndex],
58
- unit: '°C'
59
- },
60
- wind: {
61
- speed: data.hourly.windspeed_10m[pointIndex],
62
- unit: 'm/s',
63
- direction: data.hourly.wind_direction_10m[pointIndex]
64
- },
65
- humidity: data.hourly.relative_humidity_2m[pointIndex],
66
- cloudCoverage: data.hourly.cloudcover[pointIndex],
67
- solarRadiation: data.hourly.shortwave_radiation[pointIndex],
68
- weatherDescription: 'N/A',
69
- timestamp: new Date().toISOString()
70
- })
71
- }
72
- }
73
- }
74
-
75
- return { center: location, radius, weatherPoints }
76
- }
77
-
78
- export async function getGlobalWeatherData(gridResolution: number = 10): Promise<WeatherAround[]> {
79
- const globalWeatherData: WeatherAround[] = []
80
-
81
- for (let lat = -90; lat <= 90; lat += gridResolution) {
82
- // 위도에 따라 경도 샘플링 간격 조정
83
- const lonStep = Math.max(gridResolution * Math.cos((Math.abs(lat) * Math.PI) / 180), gridResolution / 3)
84
-
85
- for (let lon = -180; lon < 180; lon += lonStep) {
86
- try {
87
- const regionWeather = await getWeatherAroundLocation({ latitude: lat, longitude: lon }, gridResolution)
88
- globalWeatherData.push(regionWeather)
89
- } catch (error) {
90
- console.error(`Failed to fetch weather for (${lat}, ${lon}):`, error)
91
- }
92
- }
93
- }
94
-
95
- return globalWeatherData
96
- }
@@ -1,63 +0,0 @@
1
- /** 🌍 위치 정보 */
2
- export interface Location {
3
- latitude: number
4
- longitude: number
5
- }
6
-
7
- /** 🌡️ 기온 정보 */
8
- export interface Temperature {
9
- current: number // 현재 기온
10
- unit: '°C' | '°F'
11
- }
12
-
13
- /** 💨 바람 정보 */
14
- export interface Wind {
15
- speed: number // 풍속
16
- unit: 'm/s' | 'km/h' | 'mph'
17
- direction: number | null // 풍향 (0~360도, 0=북쪽), 일부 API에서 제공되지 않을 수 있음
18
- }
19
-
20
- /** 🌤️ 날씨 공통 데이터 모델 */
21
- export interface WeatherData {
22
- location: Location
23
- temperature: Temperature
24
- wind: Wind
25
- humidity: number // 상대 습도 (%)
26
- cloudCoverage: number // 구름량 (%)
27
- solarRadiation: number | null // 태양광량 (W/m²), 일부 API에서 제공되지 않음
28
- weatherDescription: string // 날씨 상태 (예: "clear", "partly cloudy")
29
- timestamp: string // 데이터 수집 시간 (ISO8601)
30
- }
31
-
32
- /** 🌍 특정 위치의 주변 날씨 데이터 (여러 개의 WeatherData 포함) */
33
- export interface WeatherAround {
34
- center: Location // 중심 좌표
35
- radius: number // 반경 (km)
36
- weatherPoints: WeatherData[] // 반경 내의 날씨 데이터 리스트
37
- }
38
-
39
- export type WeatherType = 'temperature' | 'humidity' | 'wind' | 'precipitation'
40
-
41
- export interface WeatherService {
42
- /**
43
- * 특정 위치의 날씨 데이터를 가져온다.
44
- * @param location - 조회할 위치 (위도, 경도)
45
- * @returns WeatherData - 표준화된 날씨 데이터
46
- */
47
- getWeatherByLocation(location: Location): Promise<WeatherData>
48
-
49
- /**
50
- * 특정 위치를 중심으로 주변 반경 내의 날씨 데이터를 가져온다.
51
- * @param location - 중심 위치 (위도, 경도)
52
- * @param radius - 검색할 반경 (단위: km)
53
- * @returns WeatherAround - 주변의 날씨 데이터 리스트
54
- */
55
- getWeatherAroundLocation(location: Location, radius: number): Promise<WeatherAround>
56
-
57
- /**
58
- * 전 세계 날씨 데이터를 가져온다.
59
- * @param gridResolution - 격자 크기 (단위: km)
60
- * @returns WeatherAround[] - 전 세계의 날씨 데이터 리스트
61
- */
62
- getGlobalWeatherData(gridResolution: number): Promise<WeatherAround[]>
63
- }
@@ -1,192 +0,0 @@
1
- import { Context } from 'koa'
2
-
3
- import { config } from '@things-factory/env'
4
- import { cacheService } from '@things-factory/cache-service'
5
-
6
- import { WeatherType } from './types.js'
7
- import { getGlobalWeatherData } from './open-meteo-adapter.js'
8
- import { renderWeatherMap } from '../services/chrome-renderer.js'
9
-
10
- /**
11
- * 전체 날씨 지도 이미지 컨트롤러
12
- */
13
- export async function globalWeatherController(ctx: Context) {
14
- const type = ctx.params.type
15
- const forceRefresh = ctx.query.refresh === 'true'
16
-
17
- try {
18
- // 유효한 날씨 타입인지 확인
19
- if (!['temperature', 'humidity', 'wind', 'precipitation'].includes(type)) {
20
- ctx.status = 400
21
- ctx.body = {
22
- error: `유효하지 않은 날씨 타입: ${type}`,
23
- validTypes: ['temperature', 'humidity', 'wind', 'precipitation']
24
- }
25
- return
26
- }
27
-
28
- // 날씨 이미지 가져오기
29
- const imageBuffer = await getGlobalWeatherImage(type, forceRefresh)
30
-
31
- // 이미지 데이터 응답
32
- ctx.body = Buffer.from(imageBuffer)
33
-
34
- // 이미지 응답 헤더 설정
35
- ctx.status = 200
36
- ctx.set('Content-Type', 'image/png')
37
- ctx.set('Cache-Control', 'public, max-age=3600') // 1시간 캐싱
38
- } catch (error) {
39
- console.error(`날씨 지도 생성 오류 (${type}):`, error)
40
- ctx.status = 500
41
- ctx.body = {
42
- error: '날씨 지도 생성 중 오류가 발생했습니다',
43
- message: error.message
44
- }
45
- }
46
- }
47
-
48
- /**
49
- * 전체 날씨 이미지 가져오기
50
- * @param type 날씨 타입 (temperature, humidity, wind, precipitation)
51
- * @param forceRefresh 캐시 무시하고 새로 가져오기
52
- */
53
- export async function getGlobalWeatherImage(type: WeatherType, forceRefresh: boolean = false): Promise<Buffer> {
54
- if (!['temperature', 'humidity', 'wind', 'precipitation'].includes(type)) {
55
- throw new Error(`유효하지 않은 날씨 타입: ${type}`)
56
- }
57
-
58
- // 캐시 유효 시간 (분)
59
- const cacheLifetime = config.get('WEATHER_CACHE_LIFETIME') || 60
60
-
61
- try {
62
- // 캐시에서 데이터 확인
63
- if (!forceRefresh) {
64
- const { value: cachedDataJson } = (await cacheService.getFromCache(`global-weather-image`, { type })) || {}
65
-
66
- // Buffer.from이 객체를 직접 처리할 수 없으므로 타입에 따라 적절하게 처리
67
- let cachedData = null
68
- if (cachedDataJson) {
69
- if (Buffer.isBuffer(cachedDataJson)) {
70
- cachedData = cachedDataJson
71
- } else if (typeof cachedDataJson === 'string') {
72
- // 문자열이면 버퍼로 변환
73
- cachedData = cachedDataJson
74
- } else if (Array.isArray(cachedDataJson)) {
75
- // 배열이면 버퍼로 변환
76
- cachedData = cachedDataJson
77
- } else if (typeof cachedDataJson === 'object') {
78
- // 객체인 경우 처리 (JSON.stringify된 버퍼일 수 있음)
79
- try {
80
- // JSON 문자열로 변환했다가 다시 파싱하면 배열로 변환될 수 있음
81
- if ((cachedDataJson.type = 'Buffer')) {
82
- cachedData = cachedDataJson.data
83
- }
84
- } catch (conversionError) {
85
- console.error('캐시 데이터 변환 실패:', conversionError)
86
- }
87
- }
88
- }
89
-
90
- if (cachedData && cachedData.length > 0) {
91
- console.log(`전체 날씨 이미지 캐시 사용 (타입: ${type})`)
92
- return cachedData
93
- }
94
- }
95
-
96
- console.log(`전체 날씨 이미지 생성 (타입: ${type})`)
97
-
98
- var { value: weatherPoints } = (await cacheService.getFromCache(`weather-points`, {})) || {}
99
-
100
- if (!weatherPoints || weatherPoints.length === 0) {
101
- const weatherData = await getGlobalWeatherData(10)
102
-
103
- const flattenedWeatherPoints = weatherData.reduce((acc, area) => {
104
- return acc.concat(area.weatherPoints)
105
- }, [])
106
-
107
- weatherPoints = flattenedWeatherPoints.map(point => {
108
- let value
109
- switch (type) {
110
- case 'temperature':
111
- value = point.temperature?.current
112
- break
113
- case 'humidity':
114
- value = point.humidity
115
- break
116
- case 'wind':
117
- value = point.wind?.speed
118
- break
119
- case 'precipitation':
120
- value = 0
121
- break // 현재 데이터 없음
122
- }
123
-
124
- return {
125
- lat: point.location.latitude,
126
- lng: point.location.longitude,
127
- value,
128
- // value: point.temperature?.current,
129
- description: point.weatherDescription,
130
- temperature: point.temperature?.current,
131
- humidity: point.humidity,
132
- cloudCoverage: point.cloudCoverage,
133
- solarRadiation: point.solarRadiation,
134
- windSpeed: point.wind?.speed,
135
- windDirection: point.wind?.direction
136
- // precipitation: point.precipitation.current
137
- }
138
- })
139
-
140
- await cacheService.setInCache(`weather-points`, {}, weatherPoints, cacheLifetime * 60)
141
- }
142
-
143
- // Headless Chrome으로 이미지 생성
144
- const imageBuffer = await renderWeatherMap(type, weatherPoints, 1800, 900)
145
-
146
- // 캐시에 저장 (초 단위로 TTL 설정)
147
- await cacheService.setInCache(`global-weather-image`, { type }, imageBuffer, cacheLifetime * 60)
148
-
149
- return imageBuffer
150
- } catch (error) {
151
- console.error(`전체 날씨 이미지 생성 실패 (타입: ${type}):`, error)
152
-
153
- // 에러 발생 시 캐시에서 데이터 확인
154
- const { value: cachedDataJson } = (await cacheService.getFromCache(`global-weather-image`, { type })) || {}
155
-
156
- // Buffer.from이 객체를 직접 처리할 수 없으므로 타입에 따라 적절하게 처리
157
- let cachedData = null
158
- if (cachedDataJson) {
159
- if (Buffer.isBuffer(cachedDataJson)) {
160
- cachedData = cachedDataJson
161
- } else if (typeof cachedDataJson === 'string') {
162
- // 문자열이면 버퍼로 변환
163
- cachedData = Buffer.from(cachedDataJson)
164
- } else if (Array.isArray(cachedDataJson)) {
165
- // 배열이면 버퍼로 변환
166
- cachedData = Buffer.from(cachedDataJson)
167
- } else if (typeof cachedDataJson === 'object') {
168
- // 객체인 경우 처리 (JSON.stringify된 버퍼일 수 있음)
169
- try {
170
- // JSON 문자열로 변환했다가 다시 파싱하면 배열로 변환될 수 있음
171
- const dataArray = Object.keys(cachedDataJson)
172
- .filter(key => !isNaN(Number(key)))
173
- .sort((a, b) => Number(a) - Number(b))
174
- .map(key => cachedDataJson[key])
175
-
176
- if (dataArray.length > 0) {
177
- cachedData = Buffer.from(dataArray)
178
- }
179
- } catch (conversionError) {
180
- console.error('캐시 데이터 변환 실패:', conversionError)
181
- }
182
- }
183
- }
184
-
185
- if (cachedData) {
186
- console.log(`에러로 인해 캐시된 날씨 이미지 사용 (타입: ${type})`)
187
- return cachedData
188
- }
189
-
190
- throw error
191
- }
192
- }
package/server/index.ts DELETED
@@ -1 +0,0 @@
1
- import './routes'
@@ -1,297 +0,0 @@
1
- /**
2
- * @fileoverview
3
- * Koa 기반의 GeoJSON → MBTiles 변환 및 VectorTile(MVT) 서비스 제공 라우터
4
- *
5
- * 이 라우터는 GeoJSON 데이터를 MBTiles 형식으로 변환하고, OpenLayers 등의 클라이언트에서
6
- * Vector Tile(MVT) 형식으로 요청할 수 있도록 API 엔드포인트를 제공합니다.
7
- *
8
- * 📌 주요 기능:
9
- * 1️⃣ `/tiles/regenerate` (POST) → GeoJSON을 다시 MBTiles로 변환
10
- * 2️⃣ `/tiles/:z/:x/:y.mvt` (GET) → 특정 타일 데이터를 반환
11
- * 3️⃣ `/tiles/metadata` (GET) → MBTiles의 메타데이터 반환
12
- *
13
- * 🚀 서비스 초기화 흐름:
14
- * 1. 서버 시작 시 `initializeTileService()` 호출
15
- * - MBTiles 파일이 없으면 `convertGeoJsonToMBTiles()` 실행하여 생성
16
- * - MBTiles가 존재하면 `loadTileSource()`를 호출하여 데이터 로드
17
- * 2. 클라이언트가 MVT 타일을 요청하면 `tileSource.getTile(z, x, y, callback)`을 통해 데이터 반환
18
- *
19
- * 📌 주요 처리 로직:
20
- * ✅ **GeoJSON → MBTiles 변환 (`convertGeoJsonToMBTiles`)**
21
- * - `tippecanoe` CLI를 사용하여 GeoJSON을 MBTiles로 변환
22
- * - 기존 MBTiles 파일이 있으면 삭제 후 재생성
23
- * - `spawn`을 사용하여 비동기적으로 실행하며 로그 출력
24
- *
25
- * ✅ **MBTiles 파일을 로드 (`loadTileSource`)**
26
- * - `@mapbox/mbtiles` 라이브러리를 사용하여 MBTiles 데이터베이스를 로드
27
- * - 타일 요청이 있을 때 해당 타일을 `tileSource.getTile()`을 통해 제공
28
- *
29
- * ✅ **타일 요청 처리 (`/tiles/:z/:x/:y.mvt`)**
30
- * - MBTiles에서 특정 타일을 찾아 바이너리 MVT 데이터로 반환
31
- * - `Content-Encoding: gzip` 설정으로 압축된 타일 제공
32
- * - `TMS 좌표 변환` 문제 제거하여 직접 요청 가능하도록 개선
33
- *
34
- * ✅ **타일셋 메타데이터 반환 (`/tiles/metadata`)**
35
- * - MBTiles 파일에서 메타데이터 정보를 가져와 JSON 형태로 반환
36
- *
37
- * 📌 종속성:
38
- * - `koa-router`: Koa 기반 API 라우팅
39
- * - `fs`, `path`: 파일 시스템 접근 및 경로 처리
40
- * - `child_process`, `util`: `tippecanoe` 실행을 위한 비동기 프로세스 실행
41
- * - `@mapbox/mbtiles`: MBTiles 데이터베이스 접근 라이브러리
42
- * - `@things-factory/env`: 환경 변수 관리
43
- *
44
- * 📌 디렉토리 구조:
45
- * ```
46
- * /cache/mvt-tiles/ # 생성된 MBTiles 저장
47
- * /data/geo-json/ # 원본 GeoJSON 파일 저장
48
- * ```
49
- *
50
- * 📌 실행 방법:
51
- * 1. GeoJSON 데이터 준비
52
- * 2. Koa 서버 실행 → 자동으로 MBTiles 변환 후 서비스 시작
53
- * 3. 클라이언트(OpenLayers 등)에서 VectorTile 요청 가능
54
- * 4. 필요 시 `/tiles/regenerate` 엔드포인트로 타일 재생성
55
- *
56
- * @module geoRouter
57
- */
58
-
59
- import Router from 'koa-router'
60
- import * as fs from 'fs'
61
- import * as path from 'path'
62
- import * as child_process from 'child_process'
63
- import * as util from 'util'
64
- import { config } from '@things-factory/env'
65
- import mbtiles from '@mapbox/mbtiles'
66
-
67
- const exec = util.promisify(child_process.exec)
68
-
69
- export const geoRouter = new Router()
70
-
71
- const TILE_CACHE_DIR = path.resolve(config.get('directory', './cache'), 'mvt-tiles')
72
- const GEO_DATA_DIR = path.resolve(__dirname, '..', '..', 'data', 'geo-json')
73
-
74
- if (!fs.existsSync(TILE_CACHE_DIR)) {
75
- fs.mkdirSync(TILE_CACHE_DIR, { recursive: true })
76
- }
77
- if (!fs.existsSync(GEO_DATA_DIR)) {
78
- fs.mkdirSync(GEO_DATA_DIR, { recursive: true })
79
- }
80
-
81
- const COUNTRIES_GEO_JSON = path.resolve(GEO_DATA_DIR, 'countries-max-geo.json')
82
- const MBTILES_PATH = path.resolve(TILE_CACHE_DIR, 'countries.mbtiles')
83
-
84
- import { spawn } from 'child_process'
85
-
86
- const convertGeoJsonToMBTiles = async () => {
87
- // tippecanoe -o ./packages/operato-ecs/cache/mvt-tiles/countries.mbtiles -z10 -Z5 --force --maximum-zoom=10 --minimum-zoom=0 --drop-smallest-as-needed --layer=countries ./packages/weather/data/geo-json/countries-max-geo.json
88
- try {
89
- if (!fs.existsSync(COUNTRIES_GEO_JSON)) {
90
- console.error('GeoJSON 파일을 찾을 수 없습니다:', COUNTRIES_GEO_JSON)
91
- return false
92
- }
93
-
94
- if (fs.existsSync(MBTILES_PATH)) {
95
- fs.unlinkSync(MBTILES_PATH)
96
- console.log('기존 MBTiles 파일 삭제됨')
97
- }
98
-
99
- const tippecanoeCmd = [
100
- '-o',
101
- MBTILES_PATH,
102
- '-z10',
103
- '-Z5',
104
- '--force',
105
- '--maximum-zoom=10',
106
- '--minimum-zoom=0',
107
- '--drop-smallest-as-needed',
108
- '--layer=countries',
109
- COUNTRIES_GEO_JSON
110
- ]
111
-
112
- console.log('Tippecanoe 명령 실행 중...')
113
- console.log(`tippecanoe ${tippecanoeCmd.join(' ')}`)
114
-
115
- return new Promise((resolve, reject) => {
116
- const process = spawn('tippecanoe', tippecanoeCmd)
117
-
118
- process.stdout.on('data', data => {
119
- console.log(`[tippecanoe]: ${data.toString()}`)
120
- })
121
-
122
- process.stderr.on('data', data => {
123
- console.error(`[tippecanoe ERROR]: ${data.toString()}`)
124
- })
125
-
126
- process.on('close', code => {
127
- if (code === 0) {
128
- console.log('MBTiles 변환 완료 ✅')
129
- resolve(true)
130
- } else {
131
- console.error(`Tippecanoe 종료 코드: ${code}`)
132
- resolve(false)
133
- }
134
- })
135
-
136
- process.on('error', err => {
137
- console.error(`Tippecanoe 실행 오류: ${err}`)
138
- reject(err)
139
- })
140
- })
141
- } catch (error) {
142
- console.error('MBTiles 변환 오류:', error)
143
- return false
144
- }
145
- }
146
-
147
- // MBTiles 소스
148
- let tileSource = null
149
-
150
- // MBTiles 소스 로드
151
- const loadTileSource = async () => {
152
- if (tileSource) return tileSource
153
-
154
- return new Promise((resolve, reject) => {
155
- new mbtiles(`mbtiles://${MBTILES_PATH}`, (err, source) => {
156
- if (err) {
157
- console.error('MBTiles 로드 오류:', err)
158
- return reject(err)
159
- }
160
-
161
- tileSource = source
162
- console.log('MBTiles 소스 로드 완료')
163
- resolve(source)
164
- })
165
- })
166
- }
167
-
168
- // 타일 서비스 초기화
169
- const initializeTileService = async () => {
170
- try {
171
- // MBTiles 파일이 없으면 생성
172
- if (!fs.existsSync(MBTILES_PATH)) {
173
- console.log('MBTiles 파일이 없습니다. 생성을 시작합니다...')
174
- const result = await convertGeoJsonToMBTiles()
175
- if (!result) return false
176
- }
177
-
178
- // MBTiles 소스 로드
179
- await loadTileSource()
180
- return true
181
- } catch (error) {
182
- console.error('타일 서비스 초기화 오류:', error)
183
- return false
184
- }
185
- }
186
-
187
- // API 엔드포인트: 타일 재생성 트리거
188
- geoRouter.post('/tiles/regenerate', async ctx => {
189
- try {
190
- console.log('타일 재생성 요청 받음')
191
- const result = await convertGeoJsonToMBTiles()
192
-
193
- if (result) {
194
- // 새 타일소스 로드
195
- await loadTileSource()
196
- ctx.body = { success: true, message: 'MBTiles 타일 생성 완료' }
197
- } else {
198
- ctx.status = 500
199
- ctx.body = { success: false, message: 'MBTiles 타일 생성 실패' }
200
- }
201
- } catch (error) {
202
- console.error('타일 재생성 오류:', error)
203
- ctx.status = 500
204
- ctx.body = { success: false, message: '서버 오류 발생' }
205
- }
206
- })
207
-
208
- geoRouter.get('/tiles/:z/:x/:y.mvt', async ctx => {
209
- try {
210
- const z = parseInt(ctx.params.z, 10)
211
- const x = parseInt(ctx.params.x, 10)
212
- const y = parseInt(ctx.params.y, 10)
213
-
214
- // 타일 서비스 로드 확인
215
- if (!tileSource) {
216
- console.log(`🚀 타일 서비스 로드 중...`)
217
- const initialized = await initializeTileService()
218
- if (!initialized) {
219
- ctx.status = 500
220
- ctx.body = { error: '타일 서비스를 초기화할 수 없습니다.' }
221
- return
222
- }
223
- }
224
-
225
- await new Promise((resolve, reject) => {
226
- tileSource.getTile(z, x, y, (err, data, headers) => {
227
- if (err) {
228
- if (err.message.includes('Tile does not exist')) {
229
- ctx.status = 204
230
- console.log(`🚨 타일 없음: z=${z}, x=${x}, y=${y}`)
231
- return resolve(null)
232
- }
233
-
234
- console.error(`❌ 타일 로드 오류 (z:${z}, x:${x}, y:${y}):`, err)
235
- ctx.status = 500
236
- ctx.body = { error: '타일을 로드할 수 없습니다.' }
237
- return resolve(null)
238
- }
239
-
240
- ctx.set('Content-Type', headers['Content-Type'] || 'application/x-protobuf')
241
- ctx.set({
242
- 'Access-Control-Allow-Origin': '*',
243
- 'Cache-Control': 'no-cache, no-store, must-revalidate, proxy-revalidate',
244
- 'Content-Encoding': 'gzip'
245
- })
246
-
247
- ctx.body = data
248
- resolve(null)
249
- })
250
- })
251
- } catch (error) {
252
- console.error('❌ 타일 서비스 오류:', error)
253
- ctx.status = 500
254
- ctx.body = { error: '서버 오류가 발생했습니다.' }
255
- }
256
- })
257
-
258
- geoRouter.get('/tiles/metadata', async ctx => {
259
- try {
260
- if (!tileSource) {
261
- const initialized = await initializeTileService()
262
- if (!initialized) {
263
- ctx.status = 500
264
- ctx.body = { error: '타일 서비스를 초기화할 수 없습니다.' }
265
- return
266
- }
267
- }
268
-
269
- await new Promise((resolve, reject) => {
270
- tileSource.getInfo((err, info) => {
271
- if (err) {
272
- console.error('메타데이터 로드 오류:', err)
273
- ctx.status = 500
274
- ctx.body = { error: '메타데이터를 로드할 수 없습니다.' }
275
- return resolve(null)
276
- }
277
-
278
- ctx.body = info
279
- resolve(null)
280
- })
281
- })
282
- } catch (error) {
283
- console.error('메타데이터 서비스 오류:', error)
284
- ctx.status = 500
285
- ctx.body = { error: '서버 오류가 발생했습니다.' }
286
- }
287
- })
288
-
289
- initializeTileService().then(success => {
290
- if (success) {
291
- console.log('🗺️ 타일 서비스가 준비되었습니다.')
292
- } else {
293
- console.error('❌ 타일 서비스 초기화에 실패했습니다.')
294
- }
295
- })
296
-
297
- export default geoRouter