@operato/weather 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.
- package/CHANGELOG.md +21 -0
- package/README.md +337 -0
- package/assets/images/no-image.png +0 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/layers/base-weather-layer.d.ts +56 -0
- package/dist/src/layers/base-weather-layer.js +83 -0
- package/dist/src/layers/base-weather-layer.js.map +1 -0
- package/dist/src/layers/cloud-layer.d.ts +17 -0
- package/dist/src/layers/cloud-layer.js +203 -0
- package/dist/src/layers/cloud-layer.js.map +1 -0
- package/dist/src/layers/precipitation-layer.d.ts +27 -0
- package/dist/src/layers/precipitation-layer.js +344 -0
- package/dist/src/layers/precipitation-layer.js.map +1 -0
- package/dist/src/layers/solar-layer.d.ts +17 -0
- package/dist/src/layers/solar-layer.js +253 -0
- package/dist/src/layers/solar-layer.js.map +1 -0
- package/dist/src/layers/temperature-layer.d.ts +13 -0
- package/dist/src/layers/temperature-layer.js +230 -0
- package/dist/src/layers/temperature-layer.js.map +1 -0
- package/dist/src/layers/wind-layer.d.ts +60 -0
- package/dist/src/layers/wind-layer.js +311 -0
- package/dist/src/layers/wind-layer.js.map +1 -0
- package/dist/src/ox-weather.d.ts +112 -0
- package/dist/src/ox-weather.js +645 -0
- package/dist/src/ox-weather.js.map +1 -0
- package/dist/src/ox-wind.d.ts +91 -0
- package/dist/src/ox-wind.js +425 -0
- package/dist/src/ox-wind.js.map +1 -0
- package/dist/src/services/weather-service.d.ts +79 -0
- package/dist/src/services/weather-service.js +560 -0
- package/dist/src/services/weather-service.js.map +1 -0
- package/dist/src/types.d.ts +71 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/stories/data-weather.stories.d.ts +47 -0
- package/dist/stories/data-weather.stories.js +93 -0
- package/dist/stories/data-weather.stories.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +86 -0
- package/themes/app-theme.css +138 -0
- package/themes/calendar-theme.css +61 -0
- package/themes/dark.css +51 -0
- package/themes/form-theme.css +70 -0
- package/themes/grist-theme.css +175 -0
- package/themes/layout-theme.css +94 -0
- package/themes/light.css +51 -0
- package/themes/material-theme.css +23 -0
- package/themes/md-typescale-styles.css +100 -0
- package/themes/oops-theme.css +22 -0
- package/themes/report-theme.css +47 -0
- package/themes/spacing.css +23 -0
- package/themes/state-color.css +6 -0
- package/themes/tooltip-theme.css +11 -0
- package/translations/en.json +1 -0
- package/translations/ja.json +1 -0
- package/translations/ko.json +1 -0
- package/translations/ms.json +1 -0
- package/translations/zh.json +1 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import VectorSource from 'ol/source/Vector';
|
|
2
|
+
import VectorLayer from 'ol/layer/Vector';
|
|
3
|
+
import Feature from 'ol/Feature';
|
|
4
|
+
import { fromLonLat } from 'ol/proj';
|
|
5
|
+
import { Style, Fill } from 'ol/style';
|
|
6
|
+
import Polygon from 'ol/geom/Polygon';
|
|
7
|
+
import { BaseWeatherLayer } from './base-weather-layer.js';
|
|
8
|
+
/**
|
|
9
|
+
* 구름 레이어 구현
|
|
10
|
+
*/
|
|
11
|
+
export class CloudLayer extends BaseWeatherLayer {
|
|
12
|
+
getType() {
|
|
13
|
+
return 'cloud';
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 벡터 레이어를 생성하여 구름을 표현합니다.
|
|
17
|
+
*/
|
|
18
|
+
createLayer() {
|
|
19
|
+
const source = new VectorSource();
|
|
20
|
+
if (!this.weatherData || this.weatherData.length === 0) {
|
|
21
|
+
return new VectorLayer({
|
|
22
|
+
source,
|
|
23
|
+
zIndex: 7
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
// 각 데이터 포인트의 위치와 구름 커버리지 추출
|
|
27
|
+
const points = this.weatherData.map(data => ({
|
|
28
|
+
lon: data.location.longitude,
|
|
29
|
+
lat: data.location.latitude,
|
|
30
|
+
coverage: data.cloud.coverage / 100
|
|
31
|
+
}));
|
|
32
|
+
console.log('구름 데이터 포인트:', points.length, '첫 번째 포인트:', points[0]);
|
|
33
|
+
// 개선된 구름 커버리지 보간 함수 - IDW(Inverse Distance Weighting) 기반 보간법
|
|
34
|
+
const interpolateCoverage = (lon, lat) => {
|
|
35
|
+
// 가장 가까운 이웃 개수 제한 - 모든 포인트 대신 가까운 포인트만 사용하여 국지적 패턴 유지
|
|
36
|
+
const MAX_NEIGHBORS = 5;
|
|
37
|
+
// 현재 위치에서 모든 포인트까지의 거리 계산
|
|
38
|
+
const distances = points.map(point => {
|
|
39
|
+
const dx = lon - point.lon;
|
|
40
|
+
const dy = lat - point.lat;
|
|
41
|
+
return {
|
|
42
|
+
point,
|
|
43
|
+
dist: Math.sqrt(dx * dx + dy * dy)
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
// 거리에 따라 정렬하고 가장 가까운 이웃만 선택
|
|
47
|
+
const nearestNeighbors = distances.sort((a, b) => a.dist - b.dist).slice(0, MAX_NEIGHBORS);
|
|
48
|
+
// 포인트가 정확히 일치하는 경우
|
|
49
|
+
if (nearestNeighbors.length > 0 && nearestNeighbors[0].dist < 0.0001) {
|
|
50
|
+
return nearestNeighbors[0].point.coverage;
|
|
51
|
+
}
|
|
52
|
+
// 역거리 가중치 보간법 적용 - 거리의 제곱에 반비례하는 가중치 사용
|
|
53
|
+
let totalWeight = 0;
|
|
54
|
+
let weightedCoverage = 0;
|
|
55
|
+
// 가중치 지수 - 낮을수록 더 부드러운 보간, 높을수록 가까운 값에 더 큰 영향
|
|
56
|
+
const power = 1.5;
|
|
57
|
+
nearestNeighbors.forEach(({ point, dist }) => {
|
|
58
|
+
// 작은 값으로 나누기 방지
|
|
59
|
+
const adjustedDist = Math.max(dist, 0.0001);
|
|
60
|
+
const weight = 1 / Math.pow(adjustedDist, power);
|
|
61
|
+
totalWeight += weight;
|
|
62
|
+
weightedCoverage += point.coverage * weight;
|
|
63
|
+
});
|
|
64
|
+
return weightedCoverage / totalWeight;
|
|
65
|
+
};
|
|
66
|
+
// 구름 커버리지에 따른 색상 계산 함수
|
|
67
|
+
const getColorFromCoverage = (coverage) => {
|
|
68
|
+
// 흰색에서 짙은 회색으로 그라데이션 (구름 농도가 짙을수록 더 어두워짐)
|
|
69
|
+
const intensity = Math.min(1, Math.max(0, coverage));
|
|
70
|
+
// 더 어두운 회색으로 조정
|
|
71
|
+
const brightness = Math.round(255 - intensity * 130);
|
|
72
|
+
// 구름 농도에 따라 투명도 조정 - 옅은 구름은 더 투명하게
|
|
73
|
+
const alpha = 0.2 + intensity * 0.6;
|
|
74
|
+
return `rgba(${brightness}, ${brightness}, ${brightness}, ${alpha})`;
|
|
75
|
+
};
|
|
76
|
+
// 그라데이션을 적용할 그리드 생성
|
|
77
|
+
const bounds = {
|
|
78
|
+
minLon: Infinity,
|
|
79
|
+
maxLon: -Infinity,
|
|
80
|
+
minLat: Infinity,
|
|
81
|
+
maxLat: -Infinity
|
|
82
|
+
};
|
|
83
|
+
// 데이터 포인트로부터 영역 경계 계산
|
|
84
|
+
points.forEach(point => {
|
|
85
|
+
bounds.minLon = Math.min(bounds.minLon, point.lon);
|
|
86
|
+
bounds.maxLon = Math.max(bounds.maxLon, point.lon);
|
|
87
|
+
bounds.minLat = Math.min(bounds.minLat, point.lat);
|
|
88
|
+
bounds.maxLat = Math.max(bounds.maxLat, point.lat);
|
|
89
|
+
});
|
|
90
|
+
// 경계에 마진 추가
|
|
91
|
+
const margin = 0.1;
|
|
92
|
+
bounds.minLon -= margin;
|
|
93
|
+
bounds.maxLon += margin;
|
|
94
|
+
bounds.minLat -= margin;
|
|
95
|
+
bounds.maxLat += margin;
|
|
96
|
+
// 고해상도 그리드 생성 (더 매끄러운 그라데이션을 위해)
|
|
97
|
+
const gridResolution = 80; // 적절한 해상도로 조정
|
|
98
|
+
const lonStep = (bounds.maxLon - bounds.minLon) / gridResolution;
|
|
99
|
+
const latStep = (bounds.maxLat - bounds.minLat) / gridResolution;
|
|
100
|
+
// 인접 셀 간의 값 차이 최소화를 위한 스무딩 함수
|
|
101
|
+
const smoothGrid = (grid, passes = 2) => {
|
|
102
|
+
const rows = grid.length;
|
|
103
|
+
if (rows === 0)
|
|
104
|
+
return grid;
|
|
105
|
+
const cols = grid[0].length;
|
|
106
|
+
const result = Array(rows)
|
|
107
|
+
.fill(0)
|
|
108
|
+
.map(() => Array(cols).fill(0));
|
|
109
|
+
// 여러 번 스무딩 패스 적용
|
|
110
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
111
|
+
const currentGrid = pass === 0 ? grid : result;
|
|
112
|
+
for (let i = 0; i < rows; i++) {
|
|
113
|
+
for (let j = 0; j < cols; j++) {
|
|
114
|
+
// 현재 셀과 인접 셀의 평균 계산
|
|
115
|
+
let sum = currentGrid[i][j];
|
|
116
|
+
let count = 1;
|
|
117
|
+
// 상하좌우 셀 확인
|
|
118
|
+
const neighbors = [
|
|
119
|
+
[i - 1, j],
|
|
120
|
+
[i + 1, j],
|
|
121
|
+
[i, j - 1],
|
|
122
|
+
[i, j + 1]
|
|
123
|
+
];
|
|
124
|
+
neighbors.forEach(([ni, nj]) => {
|
|
125
|
+
if (ni >= 0 && ni < rows && nj >= 0 && nj < cols) {
|
|
126
|
+
sum += currentGrid[ni][nj];
|
|
127
|
+
count++;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
// 평균값으로 스무딩 (약간의 원본 가중치 부여)
|
|
131
|
+
result[i][j] = currentGrid[i][j] * 0.6 + ((sum - currentGrid[i][j]) / (count - 1)) * 0.4;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
};
|
|
137
|
+
// 구름 데이터 그리드 생성
|
|
138
|
+
const coverageGrid = [];
|
|
139
|
+
for (let i = 0; i < gridResolution; i++) {
|
|
140
|
+
coverageGrid[i] = [];
|
|
141
|
+
for (let j = 0; j < gridResolution; j++) {
|
|
142
|
+
const centerLon = bounds.minLon + (j + 0.5) * lonStep;
|
|
143
|
+
const centerLat = bounds.minLat + (i + 0.5) * latStep;
|
|
144
|
+
coverageGrid[i][j] = interpolateCoverage(centerLon, centerLat);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// 그리드 스무딩 적용
|
|
148
|
+
const smoothedGrid = smoothGrid(coverageGrid, 2);
|
|
149
|
+
// 셀 생성 및 피처 추가
|
|
150
|
+
for (let i = 0; i < gridResolution; i++) {
|
|
151
|
+
for (let j = 0; j < gridResolution; j++) {
|
|
152
|
+
const lonMin = bounds.minLon + j * lonStep;
|
|
153
|
+
const lonMax = bounds.minLon + (j + 1) * lonStep;
|
|
154
|
+
const latMin = bounds.minLat + i * latStep;
|
|
155
|
+
const latMax = bounds.minLat + (i + 1) * latStep;
|
|
156
|
+
// 스무딩된 값 사용
|
|
157
|
+
const interpolatedCoverage = smoothedGrid[i][j];
|
|
158
|
+
// 너무 작은 커버리지는 제외
|
|
159
|
+
if (interpolatedCoverage < 0.01)
|
|
160
|
+
continue;
|
|
161
|
+
// 셀 좌표 계산 및 변환
|
|
162
|
+
const coordinates = [
|
|
163
|
+
[lonMin, latMin],
|
|
164
|
+
[lonMax, latMin],
|
|
165
|
+
[lonMax, latMax],
|
|
166
|
+
[lonMin, latMax],
|
|
167
|
+
[lonMin, latMin]
|
|
168
|
+
].map(coord => fromLonLat(coord));
|
|
169
|
+
// 다각형 피처 생성
|
|
170
|
+
const feature = new Feature({
|
|
171
|
+
geometry: new Polygon([coordinates]),
|
|
172
|
+
coverage: interpolatedCoverage
|
|
173
|
+
});
|
|
174
|
+
source.addFeature(feature);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// 벡터 레이어 생성
|
|
178
|
+
return new VectorLayer({
|
|
179
|
+
source,
|
|
180
|
+
zIndex: 7,
|
|
181
|
+
className: 'cloud-layer',
|
|
182
|
+
opacity: 0.9,
|
|
183
|
+
style: feature => {
|
|
184
|
+
const coverage = feature.get('coverage');
|
|
185
|
+
return new Style({
|
|
186
|
+
fill: new Fill({
|
|
187
|
+
color: getColorFromCoverage(coverage)
|
|
188
|
+
}),
|
|
189
|
+
// 경계선 완전히 제거
|
|
190
|
+
stroke: undefined
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* 레이어에 날씨 데이터를 설정합니다.
|
|
197
|
+
*/
|
|
198
|
+
setWeatherData(data) {
|
|
199
|
+
this.weatherData = data;
|
|
200
|
+
this.updateLayer();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=cloud-layer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloud-layer.js","sourceRoot":"","sources":["../../../src/layers/cloud-layer.ts"],"names":[],"mappings":"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","sourcesContent":["import BaseLayer from 'ol/layer/Base'\nimport VectorSource from 'ol/source/Vector'\nimport VectorLayer from 'ol/layer/Vector'\nimport Feature from 'ol/Feature'\nimport Point from 'ol/geom/Point'\nimport { fromLonLat } from 'ol/proj'\nimport { Style, Fill, Stroke } from 'ol/style'\nimport Polygon from 'ol/geom/Polygon'\n\nimport { BaseWeatherLayer } from './base-weather-layer.js'\nimport { WeatherData, WeatherLayerType } from '../types.js'\n\n/**\n * 구름 레이어 구현\n */\nexport class CloudLayer extends BaseWeatherLayer {\n getType(): WeatherLayerType {\n return 'cloud'\n }\n\n /**\n * 벡터 레이어를 생성하여 구름을 표현합니다.\n */\n createLayer(): BaseLayer {\n const source = new VectorSource()\n\n if (!this.weatherData || this.weatherData.length === 0) {\n return new VectorLayer({\n source,\n zIndex: 7\n })\n }\n\n // 각 데이터 포인트의 위치와 구름 커버리지 추출\n const points = this.weatherData.map(data => ({\n lon: data.location.longitude,\n lat: data.location.latitude,\n coverage: data.cloud.coverage / 100\n }))\n\n console.log('구름 데이터 포인트:', points.length, '첫 번째 포인트:', points[0])\n\n // 개선된 구름 커버리지 보간 함수 - IDW(Inverse Distance Weighting) 기반 보간법\n const interpolateCoverage = (lon: number, lat: number) => {\n // 가장 가까운 이웃 개수 제한 - 모든 포인트 대신 가까운 포인트만 사용하여 국지적 패턴 유지\n const MAX_NEIGHBORS = 5\n\n // 현재 위치에서 모든 포인트까지의 거리 계산\n const distances = points.map(point => {\n const dx = lon - point.lon\n const dy = lat - point.lat\n return {\n point,\n dist: Math.sqrt(dx * dx + dy * dy)\n }\n })\n\n // 거리에 따라 정렬하고 가장 가까운 이웃만 선택\n const nearestNeighbors = distances.sort((a, b) => a.dist - b.dist).slice(0, MAX_NEIGHBORS)\n\n // 포인트가 정확히 일치하는 경우\n if (nearestNeighbors.length > 0 && nearestNeighbors[0].dist < 0.0001) {\n return nearestNeighbors[0].point.coverage\n }\n\n // 역거리 가중치 보간법 적용 - 거리의 제곱에 반비례하는 가중치 사용\n let totalWeight = 0\n let weightedCoverage = 0\n\n // 가중치 지수 - 낮을수록 더 부드러운 보간, 높을수록 가까운 값에 더 큰 영향\n const power = 1.5\n\n nearestNeighbors.forEach(({ point, dist }) => {\n // 작은 값으로 나누기 방지\n const adjustedDist = Math.max(dist, 0.0001)\n const weight = 1 / Math.pow(adjustedDist, power)\n\n totalWeight += weight\n weightedCoverage += point.coverage * weight\n })\n\n return weightedCoverage / totalWeight\n }\n\n // 구름 커버리지에 따른 색상 계산 함수\n const getColorFromCoverage = (coverage: number) => {\n // 흰색에서 짙은 회색으로 그라데이션 (구름 농도가 짙을수록 더 어두워짐)\n const intensity = Math.min(1, Math.max(0, coverage))\n\n // 더 어두운 회색으로 조정\n const brightness = Math.round(255 - intensity * 130)\n\n // 구름 농도에 따라 투명도 조정 - 옅은 구름은 더 투명하게\n const alpha = 0.2 + intensity * 0.6\n\n return `rgba(${brightness}, ${brightness}, ${brightness}, ${alpha})`\n }\n\n // 그라데이션을 적용할 그리드 생성\n const bounds = {\n minLon: Infinity,\n maxLon: -Infinity,\n minLat: Infinity,\n maxLat: -Infinity\n }\n\n // 데이터 포인트로부터 영역 경계 계산\n points.forEach(point => {\n bounds.minLon = Math.min(bounds.minLon, point.lon)\n bounds.maxLon = Math.max(bounds.maxLon, point.lon)\n bounds.minLat = Math.min(bounds.minLat, point.lat)\n bounds.maxLat = Math.max(bounds.maxLat, point.lat)\n })\n\n // 경계에 마진 추가\n const margin = 0.1\n bounds.minLon -= margin\n bounds.maxLon += margin\n bounds.minLat -= margin\n bounds.maxLat += margin\n\n // 고해상도 그리드 생성 (더 매끄러운 그라데이션을 위해)\n const gridResolution = 80 // 적절한 해상도로 조정\n const lonStep = (bounds.maxLon - bounds.minLon) / gridResolution\n const latStep = (bounds.maxLat - bounds.minLat) / gridResolution\n\n // 인접 셀 간의 값 차이 최소화를 위한 스무딩 함수\n const smoothGrid = (grid: number[][], passes: number = 2): number[][] => {\n const rows = grid.length\n if (rows === 0) return grid\n\n const cols = grid[0].length\n const result = Array(rows)\n .fill(0)\n .map(() => Array(cols).fill(0))\n\n // 여러 번 스무딩 패스 적용\n for (let pass = 0; pass < passes; pass++) {\n const currentGrid = pass === 0 ? grid : result\n\n for (let i = 0; i < rows; i++) {\n for (let j = 0; j < cols; j++) {\n // 현재 셀과 인접 셀의 평균 계산\n let sum = currentGrid[i][j]\n let count = 1\n\n // 상하좌우 셀 확인\n const neighbors = [\n [i - 1, j],\n [i + 1, j],\n [i, j - 1],\n [i, j + 1]\n ]\n\n neighbors.forEach(([ni, nj]) => {\n if (ni >= 0 && ni < rows && nj >= 0 && nj < cols) {\n sum += currentGrid[ni][nj]\n count++\n }\n })\n\n // 평균값으로 스무딩 (약간의 원본 가중치 부여)\n result[i][j] = currentGrid[i][j] * 0.6 + ((sum - currentGrid[i][j]) / (count - 1)) * 0.4\n }\n }\n }\n\n return result\n }\n\n // 구름 데이터 그리드 생성\n const coverageGrid: number[][] = []\n\n for (let i = 0; i < gridResolution; i++) {\n coverageGrid[i] = []\n for (let j = 0; j < gridResolution; j++) {\n const centerLon = bounds.minLon + (j + 0.5) * lonStep\n const centerLat = bounds.minLat + (i + 0.5) * latStep\n coverageGrid[i][j] = interpolateCoverage(centerLon, centerLat)\n }\n }\n\n // 그리드 스무딩 적용\n const smoothedGrid = smoothGrid(coverageGrid, 2)\n\n // 셀 생성 및 피처 추가\n for (let i = 0; i < gridResolution; i++) {\n for (let j = 0; j < gridResolution; j++) {\n const lonMin = bounds.minLon + j * lonStep\n const lonMax = bounds.minLon + (j + 1) * lonStep\n const latMin = bounds.minLat + i * latStep\n const latMax = bounds.minLat + (i + 1) * latStep\n\n // 스무딩된 값 사용\n const interpolatedCoverage = smoothedGrid[i][j]\n\n // 너무 작은 커버리지는 제외\n if (interpolatedCoverage < 0.01) continue\n\n // 셀 좌표 계산 및 변환\n const coordinates = [\n [lonMin, latMin],\n [lonMax, latMin],\n [lonMax, latMax],\n [lonMin, latMax],\n [lonMin, latMin]\n ].map(coord => fromLonLat(coord))\n\n // 다각형 피처 생성\n const feature = new Feature({\n geometry: new Polygon([coordinates]),\n coverage: interpolatedCoverage\n })\n\n source.addFeature(feature)\n }\n }\n\n // 벡터 레이어 생성\n return new VectorLayer({\n source,\n zIndex: 7,\n className: 'cloud-layer',\n opacity: 0.9,\n style: feature => {\n const coverage = feature.get('coverage') as number\n return new Style({\n fill: new Fill({\n color: getColorFromCoverage(coverage)\n }),\n // 경계선 완전히 제거\n stroke: undefined\n })\n }\n })\n }\n\n /**\n * 레이어에 날씨 데이터를 설정합니다.\n */\n setWeatherData(data: WeatherData[]) {\n this.weatherData = data\n this.updateLayer()\n }\n}\n"]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { BaseWeatherLayer } from './base-weather-layer';
|
|
2
|
+
import { WeatherData, WeatherLayerType } from '../types';
|
|
3
|
+
import BaseLayer from 'ol/layer/Base';
|
|
4
|
+
/**
|
|
5
|
+
* 강수량 레이어 구현
|
|
6
|
+
*/
|
|
7
|
+
export declare class PrecipitationLayer extends BaseWeatherLayer {
|
|
8
|
+
private animationFrameId?;
|
|
9
|
+
private animationTime;
|
|
10
|
+
getType(): WeatherLayerType;
|
|
11
|
+
/**
|
|
12
|
+
* 벡터 레이어를 생성하여 강수량을 표현합니다.
|
|
13
|
+
*/
|
|
14
|
+
createLayer(): BaseLayer;
|
|
15
|
+
/**
|
|
16
|
+
* 애니메이션을 시작합니다.
|
|
17
|
+
*/
|
|
18
|
+
startAnimation(): void;
|
|
19
|
+
/**
|
|
20
|
+
* 애니메이션을 중지합니다.
|
|
21
|
+
*/
|
|
22
|
+
stopAnimation(): void;
|
|
23
|
+
/**
|
|
24
|
+
* 레이어에 날씨 데이터를 설정합니다.
|
|
25
|
+
*/
|
|
26
|
+
setWeatherData(data: WeatherData[]): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { BaseWeatherLayer } from './base-weather-layer';
|
|
2
|
+
import VectorSource from 'ol/source/Vector';
|
|
3
|
+
import VectorLayer from 'ol/layer/Vector';
|
|
4
|
+
import Feature from 'ol/Feature';
|
|
5
|
+
import { fromLonLat } from 'ol/proj';
|
|
6
|
+
import { Style, Icon, Text, Fill, Stroke } from 'ol/style';
|
|
7
|
+
import Polygon from 'ol/geom/Polygon';
|
|
8
|
+
/**
|
|
9
|
+
* 강수량 레이어 구현
|
|
10
|
+
*/
|
|
11
|
+
export class PrecipitationLayer extends BaseWeatherLayer {
|
|
12
|
+
constructor() {
|
|
13
|
+
super(...arguments);
|
|
14
|
+
this.animationTime = 0;
|
|
15
|
+
}
|
|
16
|
+
getType() {
|
|
17
|
+
return 'precipitation';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 벡터 레이어를 생성하여 강수량을 표현합니다.
|
|
21
|
+
*/
|
|
22
|
+
createLayer() {
|
|
23
|
+
const source = new VectorSource();
|
|
24
|
+
if (!this.weatherData || this.weatherData.length === 0) {
|
|
25
|
+
return new VectorLayer({
|
|
26
|
+
source,
|
|
27
|
+
zIndex: 12
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// 강수량 데이터 추출 - 강수 확률이 일정 이상인 경우만 고려
|
|
31
|
+
const validData = this.weatherData.filter(data => data.precipitation.probability >= 20);
|
|
32
|
+
console.log('강수 데이터 포인트:', validData.length, '첫 번째 포인트:', validData.length > 0 ? validData[0] : 'none');
|
|
33
|
+
if (validData.length === 0) {
|
|
34
|
+
return new VectorLayer({
|
|
35
|
+
source,
|
|
36
|
+
zIndex: 12
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// 각 데이터 포인트의 위치와 강수량 추출
|
|
40
|
+
const points = validData.map(data => ({
|
|
41
|
+
lon: data.location.longitude,
|
|
42
|
+
lat: data.location.latitude,
|
|
43
|
+
amount: data.precipitation.amount,
|
|
44
|
+
probability: data.precipitation.probability
|
|
45
|
+
}));
|
|
46
|
+
// 강수량 최대/최소값 계산
|
|
47
|
+
let minAmount = Infinity;
|
|
48
|
+
let maxAmount = -Infinity;
|
|
49
|
+
points.forEach(point => {
|
|
50
|
+
// 강수 확률을 가중치로 사용하여 실효 강수량 계산
|
|
51
|
+
const effectiveAmount = point.amount * (point.probability / 100);
|
|
52
|
+
minAmount = Math.min(minAmount, effectiveAmount);
|
|
53
|
+
maxAmount = Math.max(maxAmount, effectiveAmount);
|
|
54
|
+
});
|
|
55
|
+
console.log('강수량 최소/최대값:', minAmount, maxAmount);
|
|
56
|
+
// 강수량 정규화 함수 - 0~1 범위로 변환
|
|
57
|
+
const normalizeAmount = (amount) => {
|
|
58
|
+
if (maxAmount === minAmount)
|
|
59
|
+
return 0.5;
|
|
60
|
+
return (amount - minAmount) / (maxAmount - minAmount);
|
|
61
|
+
};
|
|
62
|
+
// 개선된 강수량 보간 함수 - IDW(Inverse Distance Weighting) 기반 보간법
|
|
63
|
+
const interpolateAmount = (lon, lat) => {
|
|
64
|
+
// 가장 가까운 이웃 개수 제한 - 모든 포인트 대신 가까운 포인트만 사용하여 국지적 패턴 유지
|
|
65
|
+
const MAX_NEIGHBORS = 5;
|
|
66
|
+
// 현재 위치에서 모든 포인트까지의 거리 계산
|
|
67
|
+
const distances = points.map(point => {
|
|
68
|
+
const dx = lon - point.lon;
|
|
69
|
+
const dy = lat - point.lat;
|
|
70
|
+
return {
|
|
71
|
+
point,
|
|
72
|
+
dist: Math.sqrt(dx * dx + dy * dy)
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
// 거리에 따라 정렬하고 가장 가까운 이웃만 선택
|
|
76
|
+
const nearestNeighbors = distances.sort((a, b) => a.dist - b.dist).slice(0, MAX_NEIGHBORS);
|
|
77
|
+
// 포인트가 정확히 일치하는 경우
|
|
78
|
+
if (nearestNeighbors.length > 0 && nearestNeighbors[0].dist < 0.0001) {
|
|
79
|
+
// 강수 확률을 가중치로 사용하여 실효 강수량 계산
|
|
80
|
+
return nearestNeighbors[0].point.amount * (nearestNeighbors[0].point.probability / 100);
|
|
81
|
+
}
|
|
82
|
+
// 역거리 가중치 보간법 적용 - 거리의 제곱에 반비례하는 가중치 사용
|
|
83
|
+
let totalWeight = 0;
|
|
84
|
+
let weightedAmount = 0;
|
|
85
|
+
// 가중치 지수 - 낮을수록 더 부드러운 보간, 높을수록 가까운 값에 더 큰 영향
|
|
86
|
+
const power = 2.0; // 강수는 지역적 특성이 강하므로 더 높은 지수 사용
|
|
87
|
+
nearestNeighbors.forEach(({ point, dist }) => {
|
|
88
|
+
// 작은 값으로 나누기 방지
|
|
89
|
+
const adjustedDist = Math.max(dist, 0.0001);
|
|
90
|
+
const weight = 1 / Math.pow(adjustedDist, power);
|
|
91
|
+
// 강수 확률을 가중치로 사용하여 실효 강수량 계산
|
|
92
|
+
const effectiveAmount = point.amount * (point.probability / 100);
|
|
93
|
+
totalWeight += weight;
|
|
94
|
+
weightedAmount += effectiveAmount * weight;
|
|
95
|
+
});
|
|
96
|
+
return weightedAmount / totalWeight;
|
|
97
|
+
};
|
|
98
|
+
// 강수량에 따른 색상 계산 함수
|
|
99
|
+
const getColorFromAmount = (amount, time = 0) => {
|
|
100
|
+
const normalized = normalizeAmount(amount);
|
|
101
|
+
if (normalized < 0.01)
|
|
102
|
+
return 'rgba(0,0,0,0)'; // 너무 적은 강수량은 투명하게
|
|
103
|
+
// 강수량에 따른 색상 설정 (파란색 계열)
|
|
104
|
+
const hue = 210 - normalized * 30; // 210~180 범위의 파란색
|
|
105
|
+
const saturation = 60 + normalized * 40; // 60~100% 채도
|
|
106
|
+
const lightness = 60 - normalized * 30; // 60~30% 밝기
|
|
107
|
+
// 강수량에 따라 투명도 조정
|
|
108
|
+
const alpha = 0.3 + normalized * 0.5;
|
|
109
|
+
// 애니메이션 효과 (시간에 따라 약간의 투명도 변화)
|
|
110
|
+
const animAlpha = alpha * (0.8 + Math.sin(time * 3) * 0.2);
|
|
111
|
+
return `hsla(${hue}, ${saturation}%, ${lightness}%, ${animAlpha})`;
|
|
112
|
+
};
|
|
113
|
+
// 그라데이션을 적용할 그리드 생성
|
|
114
|
+
const bounds = {
|
|
115
|
+
minLon: Infinity,
|
|
116
|
+
maxLon: -Infinity,
|
|
117
|
+
minLat: Infinity,
|
|
118
|
+
maxLat: -Infinity
|
|
119
|
+
};
|
|
120
|
+
// 데이터 포인트로부터 영역 경계 계산
|
|
121
|
+
points.forEach(point => {
|
|
122
|
+
bounds.minLon = Math.min(bounds.minLon, point.lon);
|
|
123
|
+
bounds.maxLon = Math.max(bounds.maxLon, point.lon);
|
|
124
|
+
bounds.minLat = Math.min(bounds.minLat, point.lat);
|
|
125
|
+
bounds.maxLat = Math.max(bounds.maxLat, point.lat);
|
|
126
|
+
});
|
|
127
|
+
// 경계에 마진 추가
|
|
128
|
+
const margin = 0.1;
|
|
129
|
+
bounds.minLon -= margin;
|
|
130
|
+
bounds.maxLon += margin;
|
|
131
|
+
bounds.minLat -= margin;
|
|
132
|
+
bounds.maxLat += margin;
|
|
133
|
+
// 고해상도 그리드 생성 (더 매끄러운 그라데이션을 위해)
|
|
134
|
+
const gridResolution = 80; // 적절한 해상도로 조정
|
|
135
|
+
const lonStep = (bounds.maxLon - bounds.minLon) / gridResolution;
|
|
136
|
+
const latStep = (bounds.maxLat - bounds.minLat) / gridResolution;
|
|
137
|
+
// 인접 셀 간의 값 차이 최소화를 위한 스무딩 함수
|
|
138
|
+
const smoothGrid = (grid, passes = 2) => {
|
|
139
|
+
const rows = grid.length;
|
|
140
|
+
if (rows === 0)
|
|
141
|
+
return grid;
|
|
142
|
+
const cols = grid[0].length;
|
|
143
|
+
const result = Array(rows)
|
|
144
|
+
.fill(0)
|
|
145
|
+
.map(() => Array(cols).fill(0));
|
|
146
|
+
// 여러 번 스무딩 패스 적용
|
|
147
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
148
|
+
const currentGrid = pass === 0 ? grid : result;
|
|
149
|
+
for (let i = 0; i < rows; i++) {
|
|
150
|
+
for (let j = 0; j < cols; j++) {
|
|
151
|
+
// 현재 셀과 인접 셀의 평균 계산
|
|
152
|
+
let sum = currentGrid[i][j];
|
|
153
|
+
let count = 1;
|
|
154
|
+
// 상하좌우 셀 확인
|
|
155
|
+
const neighbors = [
|
|
156
|
+
[i - 1, j],
|
|
157
|
+
[i + 1, j],
|
|
158
|
+
[i, j - 1],
|
|
159
|
+
[i, j + 1]
|
|
160
|
+
];
|
|
161
|
+
neighbors.forEach(([ni, nj]) => {
|
|
162
|
+
if (ni >= 0 && ni < rows && nj >= 0 && nj < cols) {
|
|
163
|
+
sum += currentGrid[ni][nj];
|
|
164
|
+
count++;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
// 평균값으로 스무딩 (약간의 원본 가중치 부여)
|
|
168
|
+
result[i][j] = currentGrid[i][j] * 0.7 + ((sum - currentGrid[i][j]) / (count - 1)) * 0.3;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
};
|
|
174
|
+
// 강수량 데이터 그리드 생성
|
|
175
|
+
const precipGrid = [];
|
|
176
|
+
for (let i = 0; i < gridResolution; i++) {
|
|
177
|
+
precipGrid[i] = [];
|
|
178
|
+
for (let j = 0; j < gridResolution; j++) {
|
|
179
|
+
const centerLon = bounds.minLon + (j + 0.5) * lonStep;
|
|
180
|
+
const centerLat = bounds.minLat + (i + 0.5) * latStep;
|
|
181
|
+
precipGrid[i][j] = interpolateAmount(centerLon, centerLat);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// 그리드 스무딩 적용 - 강수는 더 선명한 경계를 위해 약한 스무딩만 적용
|
|
185
|
+
const smoothedGrid = smoothGrid(precipGrid, 1);
|
|
186
|
+
// 셀 생성 및 피처 추가
|
|
187
|
+
for (let i = 0; i < gridResolution; i++) {
|
|
188
|
+
for (let j = 0; j < gridResolution; j++) {
|
|
189
|
+
const lonMin = bounds.minLon + j * lonStep;
|
|
190
|
+
const lonMax = bounds.minLon + (j + 1) * lonStep;
|
|
191
|
+
const latMin = bounds.minLat + i * latStep;
|
|
192
|
+
const latMax = bounds.minLat + (i + 1) * latStep;
|
|
193
|
+
// 스무딩된 값 사용
|
|
194
|
+
const interpolatedAmount = smoothedGrid[i][j];
|
|
195
|
+
// 너무 적은 강수량은 제외
|
|
196
|
+
if (interpolatedAmount < minAmount + (maxAmount - minAmount) * 0.05)
|
|
197
|
+
continue;
|
|
198
|
+
// 셀 좌표 계산 및 변환
|
|
199
|
+
const coordinates = [
|
|
200
|
+
[lonMin, latMin],
|
|
201
|
+
[lonMax, latMin],
|
|
202
|
+
[lonMax, latMax],
|
|
203
|
+
[lonMin, latMax],
|
|
204
|
+
[lonMin, latMin]
|
|
205
|
+
].map(coord => fromLonLat(coord));
|
|
206
|
+
// 다각형 피처 생성
|
|
207
|
+
const feature = new Feature({
|
|
208
|
+
geometry: new Polygon([coordinates]),
|
|
209
|
+
amount: interpolatedAmount,
|
|
210
|
+
// 애니메이션을 위한 랜덤 오프셋
|
|
211
|
+
offset: Math.random()
|
|
212
|
+
});
|
|
213
|
+
source.addFeature(feature);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// CSS 블렌딩 모드 정의를 위한 클래스 이름 추가
|
|
217
|
+
const layerClass = 'precipitation-layer';
|
|
218
|
+
// 블렌딩 모드 스타일 추가
|
|
219
|
+
if (!document.getElementById('precipitation-layer-style')) {
|
|
220
|
+
const style = document.createElement('style');
|
|
221
|
+
style.id = 'precipitation-layer-style';
|
|
222
|
+
style.textContent = `
|
|
223
|
+
.${layerClass} canvas {
|
|
224
|
+
mix-blend-mode: multiply;
|
|
225
|
+
}
|
|
226
|
+
`;
|
|
227
|
+
document.head.appendChild(style);
|
|
228
|
+
}
|
|
229
|
+
// 벡터 레이어 생성
|
|
230
|
+
return new VectorLayer({
|
|
231
|
+
source,
|
|
232
|
+
zIndex: 12,
|
|
233
|
+
className: layerClass,
|
|
234
|
+
style: feature => {
|
|
235
|
+
const amount = feature.get('amount');
|
|
236
|
+
const offset = feature.get('offset');
|
|
237
|
+
// 애니메이션 시간 (오프셋을 통해 각 위치마다 다른 타이밍을 가짐)
|
|
238
|
+
const time = this.animationTime + offset * 5;
|
|
239
|
+
return new Style({
|
|
240
|
+
fill: new Fill({
|
|
241
|
+
color: getColorFromAmount(amount, time)
|
|
242
|
+
}),
|
|
243
|
+
// 경계선 완전히 제거
|
|
244
|
+
stroke: undefined
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* 애니메이션을 시작합니다.
|
|
251
|
+
*/
|
|
252
|
+
startAnimation() {
|
|
253
|
+
if (this.animationFrameId) {
|
|
254
|
+
this.stopAnimation();
|
|
255
|
+
}
|
|
256
|
+
this.animationTime = 0;
|
|
257
|
+
let lastFrameTime = 0;
|
|
258
|
+
const targetFPS = 30; // 초당 프레임 수 제한 (60fps → 30fps)
|
|
259
|
+
const frameInterval = 1000 / targetFPS;
|
|
260
|
+
const animate = (timestamp) => {
|
|
261
|
+
// 프레임 간 시간 제어
|
|
262
|
+
if (!lastFrameTime)
|
|
263
|
+
lastFrameTime = timestamp;
|
|
264
|
+
const deltaTime = timestamp - lastFrameTime;
|
|
265
|
+
// 목표 프레임 간격보다 적게 지났으면 다음 프레임으로 넘어감
|
|
266
|
+
if (deltaTime < frameInterval) {
|
|
267
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// 지난 시간만큼 애니메이션 진행
|
|
271
|
+
const delta = Math.min(deltaTime / 1000, 0.1); // 최대 0.1초로 제한하여 큰 지연 방지
|
|
272
|
+
this.animationTime += delta * 0.5; // 속도 조절
|
|
273
|
+
lastFrameTime = timestamp;
|
|
274
|
+
// 맵 인스턴스 확인 (존재하지 않으면 애니메이션 종료)
|
|
275
|
+
if (!this.map) {
|
|
276
|
+
this.stopAnimation();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// 지도가 현재 보이는지 확인 (화면 밖이면 렌더링 넘어감)
|
|
280
|
+
const mapElement = this.map.getTargetElement();
|
|
281
|
+
if (mapElement) {
|
|
282
|
+
const rect = mapElement.getBoundingClientRect();
|
|
283
|
+
// 화면에 완전히 보이지 않으면 렌더링 건너뛰기
|
|
284
|
+
const isVisible = rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
|
|
285
|
+
// 화면에 보일 때만 렌더링
|
|
286
|
+
if (isVisible) {
|
|
287
|
+
this.map.render();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// 다음 프레임 예약
|
|
291
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
292
|
+
};
|
|
293
|
+
this.animationFrameId = requestAnimationFrame(animate);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* 애니메이션을 중지합니다.
|
|
297
|
+
*/
|
|
298
|
+
stopAnimation() {
|
|
299
|
+
if (this.animationFrameId) {
|
|
300
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
301
|
+
this.animationFrameId = undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* 레이어에 날씨 데이터를 설정합니다.
|
|
306
|
+
*/
|
|
307
|
+
setWeatherData(data) {
|
|
308
|
+
this.weatherData = data;
|
|
309
|
+
this.updateLayer();
|
|
310
|
+
// 데이터가 변경되면 애니메이션 재시작
|
|
311
|
+
this.startAnimation();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// 강수량 표시를 위한 스타일링
|
|
315
|
+
const createPrecipitationFeatureStyle = (feature, resolution) => {
|
|
316
|
+
const properties = feature.getProperties();
|
|
317
|
+
const precipitation = properties.precipitation;
|
|
318
|
+
// 강수량이 없으면 빈 스타일 반환
|
|
319
|
+
if (!precipitation || precipitation === 0) {
|
|
320
|
+
return new Style({});
|
|
321
|
+
}
|
|
322
|
+
// 아이콘과 텍스트 스타일 생성
|
|
323
|
+
return new Style({
|
|
324
|
+
image: new Icon({
|
|
325
|
+
src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="%230000ff" d="M12 4c4.41 0 8 3.59 8 8s-3.59 8-8 8s-8-3.59-8-8s3.59-8 8-8m0-2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm0 14l-4-4h8z"/></svg>',
|
|
326
|
+
scale: 0.8,
|
|
327
|
+
opacity: 0.8
|
|
328
|
+
}),
|
|
329
|
+
text: new Text({
|
|
330
|
+
text: `${precipitation} mm`,
|
|
331
|
+
font: 'bold 12px Arial',
|
|
332
|
+
fill: new Fill({
|
|
333
|
+
color: 'rgba(0, 0, 0, 0.9)'
|
|
334
|
+
}),
|
|
335
|
+
stroke: new Stroke({
|
|
336
|
+
color: 'rgba(255, 255, 255, 0.8)',
|
|
337
|
+
width: 3
|
|
338
|
+
}),
|
|
339
|
+
offsetY: -15
|
|
340
|
+
}),
|
|
341
|
+
zIndex: 12
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
//# sourceMappingURL=precipitation-layer.js.map
|