@things-factory/weather 9.0.0-beta.55 → 9.0.0-beta.58

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.
@@ -0,0 +1,485 @@
1
+ import { get as getProjection, transform } from 'ol/proj.js'
2
+ import DataTileSource from 'ol/source/DataTile.js'
3
+ import { createXYZ, wrapX } from 'ol/tilegrid.js'
4
+ import Layer from 'ol/layer/Layer.js'
5
+ import BaseLayer from 'ol/layer/Base.js'
6
+ import Map from 'ol/Map.js'
7
+ import { WeatherLayerType } from '@hyperviz/weather' // BaseWeatherLayer 인터페이스 호환을 위해 타입 임포트
8
+
9
+ /**
10
+ * 바람 데이터를 표시하기 위한 타일 기반 레이어
11
+ * PNG 이미지 데이터를 DataTileSource로 변환하여 효율적인 렌더링 제공
12
+ *
13
+ * @class WindDataTileLayer
14
+ */
15
+
16
+ // --- 데이터 로딩 및 변환 로직 ---
17
+
18
+ /** 바람 데이터 이미지 로딩 Promise */
19
+ const windData: Promise<{ data: ArrayBuffer; width: number; height: number }> = new Promise((resolve, reject) => {
20
+ const image = new Image()
21
+ image.onload = () => {
22
+ // 이미지 데이터를 캔버스로 변환하여 픽셀 데이터 접근
23
+ const canvas = document.createElement('canvas')
24
+ const width = image.width
25
+ const height = image.height
26
+ canvas.width = width
27
+ canvas.height = height
28
+
29
+ const context = canvas.getContext('2d')!
30
+ context.drawImage(image, 0, 0)
31
+ const data = context.getImageData(0, 0, width, height).data
32
+ resolve({ data, width, height })
33
+ }
34
+ image.onerror = () => {
35
+ reject(new Error('바람 데이터 이미지 로드 실패'))
36
+ }
37
+ image.src = '/assets/images/wind.png' // 데이터 소스 경로
38
+ })
39
+
40
+ /**
41
+ * 바이리니어 보간법을 사용하여 픽셀 값 계산
42
+ */
43
+ function bilinearInterpolation(
44
+ xAlong: number,
45
+ yAlong: number,
46
+ v11: number,
47
+ v21: number,
48
+ v12: number,
49
+ v22: number
50
+ ): number {
51
+ const q11 = (1 - xAlong) * (1 - yAlong) * v11
52
+ const q21 = xAlong * (1 - yAlong) * v21
53
+ const q12 = (1 - xAlong) * yAlong * v12
54
+ const q22 = xAlong * yAlong * v22
55
+ return q11 + q21 + q12 + q22
56
+ }
57
+
58
+ /**
59
+ * 픽셀 배열에 대한 보간법 적용
60
+ */
61
+ function interpolatePixels(
62
+ xAlong: number,
63
+ yAlong: number,
64
+ p11: number[],
65
+ p21: number[],
66
+ p12: number[],
67
+ p22: number[]
68
+ ): number[] {
69
+ return p11.map((_, i) => bilinearInterpolation(xAlong, yAlong, p11[i], p21[i], p12[i], p22[i]))
70
+ }
71
+
72
+ // 타일 그리드 및 투영 설정
73
+ const dataTileGrid = createXYZ()
74
+ const dataTileSize = 256
75
+
76
+ const inputImageProjection = getProjection('EPSG:4326')!
77
+ const dataTileProjection = getProjection('EPSG:3857')!
78
+
79
+ const inputBands = 4 // RGBA
80
+ const dataBands = 3 // u, v, magnitude
81
+
82
+ // 바람 속도 범위 (원본 이미지 값 기준)
83
+ const minU = -21.32
84
+ const maxU = 26.8
85
+ const deltaU = maxU - minU
86
+ const minV = -21.57
87
+ const maxV = 21.42
88
+ const deltaV = maxV - minV
89
+
90
+ // 바람 속도에 따른 색상 매핑 (속도, [R, G, B, A])
91
+ const windColorMap: Array<[number, [number, number, number, number]]> = [
92
+ [0, [0, 50, 170, 100]], // 약한 바람 (파란색)
93
+ [5, [0, 100, 220, 150]], // 중약한 바람
94
+ [10, [0, 150, 200, 180]], // 중간 바람 (청록색)
95
+ [15, [0, 200, 150, 200]], // 중강한 바람 (녹색)
96
+ [20, [150, 250, 0, 220]], // 강한 바람 (노란색)
97
+ [25, [200, 150, 0, 230]], // 매우 강한 바람 (주황색)
98
+ [30, [250, 50, 0, 250]] // 극심한 바람 (빨간색)
99
+ ]
100
+
101
+ // --- 레이어 클래스 구현 ---
102
+
103
+ export class WindDataTileLayer {
104
+ private source: DataTileSource
105
+ private layer: Layer | null = null // 실제 OpenLayers 레이어
106
+ private map: Map | null = null
107
+ private useEnhancedColors: boolean = true // 향상된 색상 매핑 사용 여부
108
+ private __disposed: boolean = false // 레이어가 폐기되었는지 여부를 추적하기 위한 플래그
109
+
110
+ /**
111
+ * 바람 데이터 타일 레이어 생성
112
+ * @param options 옵션 객체
113
+ */
114
+ constructor(options: { useEnhancedColors?: boolean } = {}) {
115
+ // 향상된 색상 매핑 옵션 설정
116
+ if (options.useEnhancedColors !== undefined) {
117
+ this.useEnhancedColors = options.useEnhancedColors
118
+ }
119
+
120
+ // DataTileSource 생성
121
+ this.source = new DataTileSource({
122
+ // transition must be 0 for DataTileSource, see https://github.com/openlayers/openlayers/issues/16119
123
+ transition: 0,
124
+ wrapX: true,
125
+ loader: this._tileLoader.bind(this)
126
+ })
127
+ }
128
+
129
+ /**
130
+ * 타일 데이터 로딩 함수
131
+ * @private
132
+ */
133
+ private async _tileLoader(z: number, x: number, y: number): Promise<Float32Array> {
134
+ // 타일 경계 체크: 요청된 타일이 유효한 범위를 벗어나면 빈 데이터 반환
135
+ // z=0일 때 x와 y는 모두 0이어야 함
136
+ // z=1일 때 x와 y는 0 또는 1
137
+ // z=2일 때 x와 y는 0, 1, 2, 3 중 하나
138
+ // 즉, z에 대해 x와 y는 0부터 2^z-1 사이의 값이어야 함
139
+ const maxTileCoord = Math.pow(2, z) - 1
140
+ if (x < 0 || y < 0 || x > maxTileCoord || y > maxTileCoord) {
141
+ console.warn(`🚨 타일 범위 초과: z=${z}, x=${x}, y=${y}, 최대값=${maxTileCoord}`)
142
+ return new Float32Array(dataTileSize * dataTileSize * dataBands)
143
+ }
144
+
145
+ try {
146
+ // 폐기된 레이어의 경우 빈 데이터 반환
147
+ if (this.__disposed) {
148
+ return new Float32Array(dataTileSize * dataTileSize * dataBands)
149
+ }
150
+
151
+ const { data: inputData, width: inputWidth, height: inputHeight } = await windData
152
+
153
+ // wrapX를 통해 날짜변경선 처리
154
+ const tileCoord = wrapX(dataTileGrid, [z, x, y], dataTileProjection)
155
+ const extent = dataTileGrid.getTileCoordExtent(tileCoord)
156
+ const resolution = dataTileGrid.getResolution(z)
157
+
158
+ // 타일 데이터 배열 (Float32Array 사용)
159
+ const data = new Float32Array(dataTileSize * dataTileSize * dataBands)
160
+
161
+ for (let row = 0; row < dataTileSize; ++row) {
162
+ let offset = row * dataTileSize * dataBands
163
+ const mapY = extent[3] - row * resolution
164
+
165
+ for (let col = 0; col < dataTileSize; ++col) {
166
+ const mapX = extent[0] + col * resolution
167
+
168
+ try {
169
+ // 현재 픽셀의 좌표를 EPSG:4326으로 변환
170
+ const [lon, lat] = transform([mapX, mapY], dataTileProjection, inputImageProjection)
171
+
172
+ // 유효한 좌표 범위 확인 (-180 ~ 180, -90 ~ 90)
173
+ if (lon < -180 || lon > 180 || lat < -90 || lat > 90) {
174
+ // 범위를 벗어나는 좌표는 기본값으로 처리
175
+ data[offset] = 0 // u (동서 방향 바람)
176
+ data[offset + 1] = 0 // v (남북 방향 바람)
177
+ data[offset + 2] = 0 // magnitude (바람 세기)
178
+ offset += dataBands
179
+ continue
180
+ }
181
+
182
+ // 원본 이미지에서의 상대적 위치 계산
183
+ const x = (inputWidth * (lon + 180)) / 360
184
+ let x1 = Math.floor(x)
185
+ let x2 = Math.ceil(x)
186
+ const xAlong = x - x1
187
+
188
+ // 경도 랩핑 처리 (날짜변경선)
189
+ if (x1 < 0) {
190
+ x1 += inputWidth
191
+ }
192
+ if (x2 >= inputWidth) {
193
+ x2 -= inputWidth
194
+ }
195
+
196
+ const y = (inputHeight * (90 - lat)) / 180
197
+ let y1 = Math.floor(y)
198
+ let y2 = Math.ceil(y)
199
+ const yAlong = y - y1
200
+
201
+ // 위도 범위 제한
202
+ if (y1 < 0) {
203
+ y1 = 0
204
+ }
205
+ if (y2 >= inputHeight) {
206
+ y2 = inputHeight - 1
207
+ }
208
+
209
+ const corners = [
210
+ [x1, y1],
211
+ [x2, y1],
212
+ [x1, y2],
213
+ [x2, y2]
214
+ ]
215
+
216
+ // 주변 4개 픽셀의 u, v 값 읽기
217
+ const pixels = corners.map(([cx, cy]) => {
218
+ // 유효한 인덱스 확인
219
+ if (cx < 0 || cx >= inputWidth || cy < 0 || cy >= inputHeight) {
220
+ return [0, 0] // 잘못된 좌표는 기본값 사용
221
+ }
222
+
223
+ const inputOffset = (cy * inputWidth + cx) * inputBands
224
+ // 원본 PNG 데이터에서 u, v 값 추출 (R 채널=u, G 채널=v)
225
+ return [inputData[inputOffset], inputData[inputOffset + 1]]
226
+ })
227
+
228
+ // u, v 값 보간
229
+ const interpolated = interpolatePixels(xAlong, yAlong, ...(pixels as [any, any, any, any]))
230
+
231
+ // 실제 u, v 값으로 변환
232
+ const u = minU + (deltaU * interpolated[0]) / 255
233
+ const v = minV + (deltaV * interpolated[1]) / 255
234
+
235
+ // 바람 속도 계산 (옵션)
236
+ const speed = Math.sqrt(u * u + v * v)
237
+
238
+ // 타일 데이터에 저장
239
+ data[offset] = u
240
+ data[offset + 1] = v
241
+ data[offset + 2] = speed // 속도 값 저장 (셰이더에서 활용 가능)
242
+ } catch (pixelError) {
243
+ // 개별 픽셀 처리 중 오류 발생 시 해당 픽셀만 기본값으로 처리
244
+ data[offset] = 0 // u
245
+ data[offset + 1] = 0 // v
246
+ data[offset + 2] = 0 // magnitude
247
+ }
248
+
249
+ offset += dataBands
250
+ }
251
+ }
252
+
253
+ return data // Float32Array 반환
254
+ } catch (error) {
255
+ // 타일 로드 실패 로그
256
+ console.warn(`🚨 타일 없음: z=${z}, x=${x}, y=${y}`, error)
257
+
258
+ // 오류 발생 시 빈 타일 데이터 반환
259
+ return new Float32Array(dataTileSize * dataTileSize * dataBands)
260
+ }
261
+ }
262
+
263
+ /**
264
+ * 레이어 타입 반환 (호환성용)
265
+ */
266
+ getType(): WeatherLayerType {
267
+ return 'wind'
268
+ }
269
+
270
+ /**
271
+ * 향상된 색상 매핑 사용 여부 설정
272
+ */
273
+ setUseEnhancedColors(use: boolean): void {
274
+ this.useEnhancedColors = use
275
+ this.updateLayer() // 변경사항 적용을 위해 레이어 업데이트
276
+ }
277
+
278
+ /**
279
+ * OpenLayers 레이어 객체 생성 또는 반환
280
+ */
281
+ createLayer(): BaseLayer {
282
+ if (this.__disposed) {
283
+ console.warn('폐기된 레이어에서 createLayer가 호출되었습니다')
284
+ return new Layer({}) // 빈 레이어 반환
285
+ }
286
+
287
+ if (!this.layer) {
288
+ // 렌더링 파이프라인과의 충돌을 최소화하기 위해 간단한 설정으로 레이어 생성
289
+ this.layer = new Layer({
290
+ source: this.source,
291
+ properties: {
292
+ useEnhancedColors: this.useEnhancedColors,
293
+ // 렌더링 시 이 속성을 확인하도록 구현
294
+ __windLayerDisposed: false
295
+ }
296
+ })
297
+ }
298
+ return this.layer
299
+ }
300
+
301
+ /**
302
+ * 요청 중인 데이터 로드 중단 및 소스 무효화
303
+ * @private
304
+ */
305
+ private _clearSourceData(): void {
306
+ if (!this.source) return
307
+
308
+ try {
309
+ // 소스에서 로드된 타일 데이터 초기화
310
+ if (typeof this.source.clear === 'function') {
311
+ this.source.clear() // 타일 캐시 정리
312
+ }
313
+
314
+ // 더 이상 데이터를 제공하지 않도록 소스 무효화
315
+ // 소스의 내부 속성에 접근 (타입 어설션 사용)
316
+ const sourceAny = this.source as any
317
+ if (sourceAny.loader) {
318
+ // 로더 함수를 빈 데이터를 반환하는 함수로 재정의
319
+ sourceAny.loader = (_z: number, _x: number, _y: number) => {
320
+ // 빈 데이터 반환
321
+ return Promise.resolve(new Float32Array(dataTileSize * dataTileSize * dataBands))
322
+ }
323
+ }
324
+ } catch (e) {
325
+ console.warn('소스 정리 중 오류:', e)
326
+ }
327
+ }
328
+
329
+ /**
330
+ * 레이어를 맵에 추가
331
+ */
332
+ addToMap(map: Map): void {
333
+ if (this.__disposed) {
334
+ console.warn('폐기된 레이어는 맵에 추가할 수 없습니다')
335
+ return
336
+ }
337
+
338
+ if (!this.map) {
339
+ this.map = map
340
+ }
341
+
342
+ if (!this.layer) {
343
+ this.createLayer()
344
+ }
345
+
346
+ if (this.layer && this.map) {
347
+ this.map.addLayer(this.layer)
348
+ }
349
+ }
350
+
351
+ /**
352
+ * 레이어를 맵에서 제거
353
+ */
354
+ removeFromMap(): void {
355
+ if (!this.layer || !this.map || this.__disposed) {
356
+ return // 이미 제거되었거나 맵에 추가되지 않은 경우
357
+ }
358
+
359
+ try {
360
+ // OpenLayers 렌더링 문제 방지를 위한 사전 처리
361
+ this.__disposed = true // 먼저 플래그 설정
362
+
363
+ // 1. 레이어가 렌더링 파이프라인에서 무시되도록 설정
364
+ this.layer.setVisible(false) // 시각적 숨김
365
+ this.layer.set('__windLayerDisposed', true) // 내부 플래그 설정
366
+ this.layer.changed() // 렌더링 파이프라인에 변경 알림
367
+
368
+ // 2. 요청 중인 모든 데이터 로드 중단
369
+ this._clearSourceData()
370
+
371
+ // 3. 연결된 맵과 소스에서 레이어 분리
372
+ try {
373
+ if (this.map && this.layer) {
374
+ // 맵에서 레이어 제거
375
+ this.map.removeLayer(this.layer)
376
+ }
377
+ } catch (e) {
378
+ console.error('맵에서 레이어 제거 중 오류:', e)
379
+ }
380
+
381
+ // 4. 레이어 참조 정리
382
+ this.map = null
383
+ } catch (e) {
384
+ console.error('레이어 제거 중 오류:', e)
385
+
386
+ // 오류 발생 시에도 맵에서 레이어 제거 시도
387
+ try {
388
+ if (this.map && this.layer) {
389
+ this.map.removeLayer(this.layer)
390
+ }
391
+ } catch (e2) {
392
+ // 무시
393
+ }
394
+ this.map = null
395
+ }
396
+ }
397
+
398
+ /**
399
+ * 레이어 관련 리소스 해제
400
+ */
401
+ dispose(): void {
402
+ if (this.__disposed) {
403
+ return // 이미 폐기된 경우
404
+ }
405
+
406
+ this.__disposed = true // 폐기 플래그 설정
407
+
408
+ // 맵에서 제거 (아직 제거되지 않은 경우)
409
+ if (this.map && this.layer) {
410
+ try {
411
+ // 렌더링 에러 방지를 위해 먼저 레이어 숨김 및 상태 설정
412
+ this.layer.setVisible(false)
413
+ this.layer.set('__windLayerDisposed', true)
414
+ this.layer.changed()
415
+
416
+ // 맵에서 레이어 제거
417
+ this.map.removeLayer(this.layer)
418
+ } catch (e) {
419
+ console.error('맵에서 레이어 제거 중 오류:', e)
420
+ } finally {
421
+ this.map = null // 맵 참조 해제
422
+ }
423
+ }
424
+
425
+ // 나머지 리소스 정리
426
+ try {
427
+ // 레이어 정리
428
+ if (this.layer) {
429
+ try {
430
+ // 레이어에서 소스 분리
431
+ if (typeof this.layer.setSource === 'function') {
432
+ this.layer.setSource(null)
433
+ }
434
+
435
+ // 레이어 객체 정리
436
+ if (typeof this.layer.dispose === 'function') {
437
+ this.layer.dispose()
438
+ }
439
+ } catch (e) {
440
+ console.warn('레이어 정리 중 오류:', e)
441
+ }
442
+ this.layer = null
443
+ }
444
+
445
+ // 소스 정리
446
+ if (this.source) {
447
+ try {
448
+ // 소스 정리
449
+ if (typeof this.source.dispose === 'function') {
450
+ this.source.dispose()
451
+ }
452
+ } catch (e) {
453
+ console.warn('소스 정리 중 오류:', e)
454
+ }
455
+ this.source = null as any
456
+ }
457
+ } catch (e) {
458
+ console.error('리소스 정리 중 오류:', e)
459
+ }
460
+ }
461
+
462
+ /**
463
+ * 레이어 업데이트 요청 (데이터 변경 시)
464
+ */
465
+ updateLayer(): void {
466
+ if (this.source) {
467
+ this.source.changed() // 타일 다시 로드 트리거
468
+ }
469
+
470
+ // 속성 업데이트 (향상된 색상 매핑 옵션)
471
+ if (this.layer) {
472
+ this.layer.set('useEnhancedColors', this.useEnhancedColors)
473
+ }
474
+ }
475
+
476
+ /**
477
+ * 데이터 설정 메서드 (호환성용, 실제 데이터는 PNG 이미지에 고정됨)
478
+ */
479
+ setWeatherData(data: any): void {
480
+ // 이 구현에서는 외부 데이터를 직접 설정하지 않음
481
+ // 필요하다면 updateLayer 호출 또는 다른 로직 구현 가능
482
+ console.warn('setWeatherData called on WindDataTileLayer, but data is static.')
483
+ this.updateLayer() // 데이터가 변경된 것처럼 타일 갱신 시도
484
+ }
485
+ }