@things-factory/weather 9.0.0-beta.52 → 9.0.0-beta.56

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,437 @@
1
+ import { get as getProjection, transform } from 'ol/proj.js'
2
+ import { createXYZ } from 'ol/tilegrid.js'
3
+ import { WeatherLayerType } from '@hyperviz/weather'
4
+ import BaseLayer from 'ol/layer/Base.js'
5
+ import ImageLayer from 'ol/layer/Image.js'
6
+ import Static from 'ol/source/ImageStatic.js'
7
+ import Map from 'ol/Map.js'
8
+
9
+ /**
10
+ * 바람 데이터를 캔버스에 래스터화하여 표시하는 레이어
11
+ * 전체 데이터를 한 번에 처리하여 이미지로 변환해 표시하는 방식
12
+ *
13
+ * @class WindRasterizedLayer
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
+ const canvas = document.createElement('canvas')
23
+ const width = image.width
24
+ const height = image.height
25
+ canvas.width = width
26
+ canvas.height = height
27
+
28
+ const context = canvas.getContext('2d')!
29
+ context.drawImage(image, 0, 0)
30
+ const data = context.getImageData(0, 0, width, height).data
31
+ resolve({ data, width, height })
32
+ }
33
+ image.onerror = () => {
34
+ reject(new Error('바람 데이터 이미지 로드 실패'))
35
+ }
36
+ image.src = '/assets/images/wind.png'
37
+ })
38
+
39
+ /**
40
+ * 바이리니어 보간법을 사용하여 픽셀 값 계산
41
+ */
42
+ function bilinearInterpolation(
43
+ xAlong: number,
44
+ yAlong: number,
45
+ v11: number,
46
+ v21: number,
47
+ v12: number,
48
+ v22: number
49
+ ): number {
50
+ const q11 = (1 - xAlong) * (1 - yAlong) * v11
51
+ const q21 = xAlong * (1 - yAlong) * v21
52
+ const q12 = (1 - xAlong) * yAlong * v12
53
+ const q22 = xAlong * yAlong * v22
54
+ return q11 + q21 + q12 + q22
55
+ }
56
+
57
+ /**
58
+ * 픽셀 배열에 대한 보간법 적용
59
+ */
60
+ function interpolatePixels(
61
+ xAlong: number,
62
+ yAlong: number,
63
+ p11: number[],
64
+ p21: number[],
65
+ p12: number[],
66
+ p22: number[]
67
+ ): number[] {
68
+ return p11.map((_, i) => bilinearInterpolation(xAlong, yAlong, p11[i], p21[i], p12[i], p22[i]))
69
+ }
70
+
71
+ // 바람 데이터 관련 상수
72
+ const inputImageProjection = getProjection('EPSG:4326')!
73
+ const inputBands = 4 // RGBA
74
+
75
+ // 바람 속도 범위 (원본 이미지 값 기준)
76
+ const minU = -21.32
77
+ const maxU = 26.8
78
+ const deltaU = maxU - minU
79
+ const minV = -21.57
80
+ const maxV = 21.42
81
+ const deltaV = maxV - minV
82
+
83
+ // 바람 속도에 따른 색상 매핑 (속도, [R, G, B, A])
84
+ const windColorMap: Array<[number, [number, number, number, number]]> = [
85
+ [0, [0, 50, 170, 100]], // 약한 바람 (파란색)
86
+ [5, [0, 100, 220, 150]], // 중약한 바람
87
+ [10, [0, 150, 200, 180]], // 중간 바람 (청록색)
88
+ [15, [0, 200, 150, 200]], // 중강한 바람 (녹색)
89
+ [20, [150, 250, 0, 220]], // 강한 바람 (노란색)
90
+ [25, [200, 150, 0, 230]], // 매우 강한 바람 (주황색)
91
+ [30, [250, 50, 0, 250]] // 극심한 바람 (빨간색)
92
+ ]
93
+
94
+ /**
95
+ * 바람 속도에 따른 색상 계산
96
+ * @param speed 바람 속도 (m/s)
97
+ * @returns RGBA 색상 배열 [r, g, b, a] (0-255)
98
+ */
99
+ function getWindColor(speed: number): [number, number, number, number] {
100
+ // 기본 색상 (약한 바람)
101
+ let color: [number, number, number, number] = [0, 50, 170, 100]
102
+
103
+ // 색상 맵에서 가장 가까운 색상 찾기
104
+ for (let i = 0; i < windColorMap.length - 1; i++) {
105
+ const [speed1, color1] = windColorMap[i]
106
+ const [speed2, color2] = windColorMap[i + 1]
107
+
108
+ if (speed >= speed1 && speed <= speed2) {
109
+ // 두 색상 사이 보간
110
+ const t = (speed - speed1) / (speed2 - speed1)
111
+ color = [
112
+ Math.floor(color1[0] * (1 - t) + color2[0] * t),
113
+ Math.floor(color1[1] * (1 - t) + color2[1] * t),
114
+ Math.floor(color1[2] * (1 - t) + color2[2] * t),
115
+ Math.floor(color1[3] * (1 - t) + color2[3] * t)
116
+ ]
117
+ break
118
+ }
119
+ }
120
+
121
+ // 최대 색상 (매우 강한 바람)
122
+ if (speed > windColorMap[windColorMap.length - 1][0]) {
123
+ color = [...windColorMap[windColorMap.length - 1][1]]
124
+ }
125
+
126
+ return color
127
+ }
128
+
129
+ // --- 레이어 클래스 구현 ---
130
+
131
+ export class WindRasterizedLayer {
132
+ private layer: ImageLayer<Static> | null = null
133
+ private dataPromise: Promise<{ data: ArrayBuffer; width: number; height: number }> | null = null
134
+ private canvas: HTMLCanvasElement | null = null
135
+ private ctx: CanvasRenderingContext2D | null = null
136
+ private width = 800
137
+ private height = 400
138
+ private map: Map | null = null
139
+ private useEnhancedColors: boolean = true
140
+ private isDisposed: boolean = false
141
+
142
+ /**
143
+ * 바람 래스터화 레이어 생성자
144
+ * @param options 옵션 객체
145
+ */
146
+ constructor(
147
+ options: {
148
+ width?: number
149
+ height?: number
150
+ useEnhancedColors?: boolean
151
+ } = {}
152
+ ) {
153
+ this.dataPromise = windData
154
+
155
+ // 캔버스 크기 설정
156
+ if (options.width) this.width = options.width
157
+ if (options.height) this.height = options.height
158
+
159
+ // 색상 매핑 옵션
160
+ if (options.useEnhancedColors !== undefined) {
161
+ this.useEnhancedColors = options.useEnhancedColors
162
+ }
163
+
164
+ // 사전에 캔버스 준비
165
+ this.canvas = document.createElement('canvas')
166
+ this.canvas.width = this.width
167
+ this.canvas.height = this.height
168
+ this.ctx = this.canvas.getContext('2d')
169
+ }
170
+
171
+ /**
172
+ * 레이어 타입 반환 (호환성용)
173
+ */
174
+ getType(): WeatherLayerType {
175
+ return 'wind'
176
+ }
177
+
178
+ /**
179
+ * 향상된 색상 매핑 사용 여부 설정
180
+ */
181
+ setUseEnhancedColors(use: boolean): void {
182
+ this.useEnhancedColors = use
183
+ this.updateLayer() // 변경사항 적용을 위해 레이어 업데이트
184
+ }
185
+
186
+ /**
187
+ * OpenLayers 레이어 객체 생성 또는 반환
188
+ */
189
+ createLayer(): BaseLayer {
190
+ if (this.isDisposed) {
191
+ throw new Error('레이어가 이미 폐기되었습니다')
192
+ }
193
+
194
+ if (!this.canvas || !this.ctx) {
195
+ throw new Error('캔버스가 초기화되지 않았습니다')
196
+ }
197
+
198
+ // 초기 이미지 렌더링
199
+ this.renderWindData()
200
+
201
+ // 정적 이미지 소스 생성
202
+ const source = new Static({
203
+ url: this.canvas.toDataURL(),
204
+ imageExtent: [-180, -90, 180, 90],
205
+ projection: 'EPSG:4326',
206
+ interpolate: true
207
+ })
208
+
209
+ // 레이어 생성
210
+ this.layer = new ImageLayer({
211
+ source: source,
212
+ opacity: 0.7,
213
+ properties: {
214
+ useEnhancedColors: this.useEnhancedColors
215
+ }
216
+ })
217
+
218
+ return this.layer
219
+ }
220
+
221
+ /**
222
+ * 바람 데이터 렌더링
223
+ */
224
+ async renderWindData(): Promise<void> {
225
+ if (!this.ctx || !this.canvas || this.isDisposed) return
226
+
227
+ try {
228
+ const { data, width, height } = await this.dataPromise!
229
+
230
+ // 캔버스 지우기
231
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
232
+
233
+ // 바람 데이터 시각화
234
+ const imgData = this.ctx.createImageData(this.canvas.width, this.canvas.height)
235
+
236
+ for (let y = 0; y < this.canvas.height; y++) {
237
+ for (let x = 0; x < this.canvas.width; x++) {
238
+ const idx = (y * this.canvas.width + x) * 4
239
+
240
+ // 위치 계산
241
+ const lon = -180 + (x / this.canvas.width) * 360
242
+ const lat = 90 - (y / this.canvas.height) * 180
243
+
244
+ // 원본 이미지에서의 위치 계산
245
+ const imgX = Math.floor((width * (lon + 180)) / 360)
246
+ const imgY = Math.floor((height * (90 - lat)) / 180)
247
+
248
+ // 경계 체크
249
+ if (imgX < 0 || imgX >= width || imgY < 0 || imgY >= height) {
250
+ continue
251
+ }
252
+
253
+ // 원본 데이터 인덱스
254
+ const dataIdx = (imgY * width + imgX) * inputBands
255
+
256
+ // 바람 속도 계산
257
+ const u = minU + (deltaU * data[dataIdx]) / 255
258
+ const v = minV + (deltaV * data[dataIdx + 1]) / 255
259
+ const speed = Math.sqrt(u * u + v * v)
260
+
261
+ // 색상 계산: 향상된 색상 매핑 또는 기본 스키마 사용
262
+ let r, g, b, a
263
+
264
+ if (this.useEnhancedColors) {
265
+ // 향상된 색상 매핑 사용
266
+ const color = getWindColor(speed)
267
+ ;[r, g, b, a] = color
268
+ } else {
269
+ // 기본 색상 매핑 (녹청색 그라데이션)
270
+ const normalizedSpeed = Math.min(speed / 20, 1)
271
+ r = Math.floor(normalizedSpeed * 0)
272
+ g = Math.floor(normalizedSpeed * 150)
273
+ b = Math.floor(normalizedSpeed * 255)
274
+ a = Math.floor(normalizedSpeed * 200)
275
+ }
276
+
277
+ // 이미지 데이터에 색상 설정
278
+ imgData.data[idx] = r
279
+ imgData.data[idx + 1] = g
280
+ imgData.data[idx + 2] = b
281
+ imgData.data[idx + 3] = a
282
+ }
283
+ }
284
+
285
+ // 캔버스에 이미지 데이터 적용
286
+ this.ctx.putImageData(imgData, 0, 0)
287
+
288
+ // 소스 업데이트
289
+ if (this.layer && !this.isDisposed) {
290
+ try {
291
+ const newSource = new Static({
292
+ url: this.canvas.toDataURL(),
293
+ imageExtent: [-180, -90, 180, 90],
294
+ projection: 'EPSG:4326',
295
+ interpolate: true
296
+ })
297
+
298
+ this.layer.setSource(newSource)
299
+ } catch (error) {
300
+ console.error('소스 업데이트 실패:', error)
301
+ }
302
+ }
303
+ } catch (error) {
304
+ console.error('바람 데이터 렌더링 실패:', error)
305
+ }
306
+ }
307
+
308
+ /**
309
+ * 레이어 업데이트 요청
310
+ */
311
+ updateLayer(): void {
312
+ if (this.isDisposed) return
313
+
314
+ this.renderWindData()
315
+
316
+ // 속성 업데이트 (향상된 색상 매핑 옵션)
317
+ if (this.layer) {
318
+ this.layer.set('useEnhancedColors', this.useEnhancedColors)
319
+ }
320
+ }
321
+
322
+ /**
323
+ * 레이어를 맵에 추가
324
+ */
325
+ addToMap(map: Map): void {
326
+ if (this.isDisposed) {
327
+ console.warn('폐기된 레이어는 맵에 추가할 수 없습니다')
328
+ return
329
+ }
330
+
331
+ this.map = map
332
+
333
+ if (!this.layer) {
334
+ this.layer = this.createLayer() as ImageLayer<Static>
335
+ }
336
+
337
+ this.map.addLayer(this.layer)
338
+ }
339
+
340
+ /**
341
+ * 레이어를 맵에서 제거
342
+ */
343
+ removeFromMap(): void {
344
+ if (!this.layer || !this.map || this.isDisposed) {
345
+ return
346
+ }
347
+
348
+ try {
349
+ // 렌더링 플래그 설정 (렌더링 방지)
350
+ if (this.layer['rendered'] !== undefined) {
351
+ this.layer['rendered'] = false
352
+ }
353
+
354
+ // 소스 정리
355
+ const source = this.layer.getSource()
356
+ if (source) {
357
+ try {
358
+ // setUrl 메서드가 없으므로 소스 자체를 null로 설정
359
+ this.layer.setSource(null)
360
+ } catch (e) {
361
+ // 오류 무시
362
+ }
363
+ }
364
+
365
+ // 맵에서 레이어 제거
366
+ this.map.removeLayer(this.layer)
367
+ } catch (e) {
368
+ console.error('맵에서 레이어 제거 중 오류:', e)
369
+ } finally {
370
+ // 맵 참조 해제
371
+ this.map = null
372
+ }
373
+ }
374
+
375
+ /**
376
+ * 레이어 관련 리소스 해제
377
+ */
378
+ dispose(): void {
379
+ if (this.isDisposed) {
380
+ return
381
+ }
382
+
383
+ // 이미 맵에서 제거되지 않았다면 제거
384
+ if (this.map && this.layer) {
385
+ this.removeFromMap()
386
+ }
387
+
388
+ try {
389
+ // 레이어 정리
390
+ if (this.layer) {
391
+ const source = this.layer.getSource()
392
+ if (source) {
393
+ try {
394
+ source.dispose()
395
+ } catch (e) {
396
+ // 오류 무시
397
+ }
398
+ }
399
+
400
+ try {
401
+ // 레이어 자체 dispose 호출
402
+ this.layer.dispose()
403
+ } catch (e) {
404
+ // 오류 무시
405
+ }
406
+
407
+ this.layer = null
408
+ }
409
+
410
+ // 캔버스 정리
411
+ if (this.canvas) {
412
+ // 캔버스 메모리 해제 (크기를 최소화)
413
+ this.canvas.width = 1
414
+ this.canvas.height = 1
415
+ this.canvas = null
416
+ this.ctx = null
417
+ }
418
+
419
+ // 데이터 참조 정리
420
+ this.dataPromise = null
421
+
422
+ // 폐기 플래그 설정
423
+ this.isDisposed = true
424
+ } catch (e) {
425
+ console.error('리소스 해제 중 오류:', e)
426
+ }
427
+ }
428
+
429
+ /**
430
+ * 데이터 설정 메서드 (호환성용)
431
+ */
432
+ setWeatherData(data: any): void {
433
+ // 이 구현에서는 외부 데이터를 직접 설정하지 않음
434
+ console.warn('WindRasterizedLayer에서 setWeatherData가 호출되었지만, 데이터는 정적입니다.')
435
+ this.updateLayer() // 데이터가 변경된 것처럼 레이어 갱신
436
+ }
437
+ }