@things-factory/weather 9.1.19 → 10.0.0-beta.1
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/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/services/chrome-renderer.js +10 -2
- package/dist-server/services/chrome-renderer.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/client/tsconfig.json +0 -11
- package/server/controllers/open-meteo-adapter.ts +0 -96
- package/server/controllers/types.ts +0 -63
- package/server/controllers/weather-controller.ts +0 -192
- package/server/index.ts +0 -1
- package/server/routers/geo-router.ts +0 -297
- package/server/routers/weather-router.ts +0 -8
- package/server/routes.ts +0 -10
- package/server/services/chrome-renderer.ts +0 -206
- package/server/services/data.json +0 -8438
- package/server/services/headless-pool.ts +0 -34
- package/server/tsconfig.json +0 -10
- package/server/utils/svg-utils.ts +0 -108
- package/server/utils/tile-utils.ts +0 -77
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/weather",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.0-beta.1",
|
|
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": "^
|
|
33
|
-
"@operato/shell": "^
|
|
34
|
-
"@things-factory/auth-base": "^
|
|
35
|
-
"@things-factory/cache-service": "^
|
|
36
|
-
"@things-factory/env": "^
|
|
37
|
-
"@things-factory/shell": "^
|
|
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.1",
|
|
35
|
+
"@things-factory/cache-service": "^10.0.0-beta.1",
|
|
36
|
+
"@things-factory/env": "^10.0.0-beta.1",
|
|
37
|
+
"@things-factory/shell": "^10.0.0-beta.1",
|
|
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": "
|
|
44
|
+
"gitHead": "90f40bf160fa25edf4560d7893cfd576cf474411"
|
|
45
45
|
}
|
package/client/tsconfig.json
DELETED
|
@@ -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}¤t=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
|