@aeroswarm/weather-layer 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,77 @@
1
- # Temporary Holding Version
1
+ # @aeroswarm/weather-layer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Animated weather on a LuciadRIA map: wind particles, clouds, rain/snow, fog and lightning. It takes
4
+ the `AnimationState` produced by [`@aeroswarm/weather`](../../server/weather) (decoded METAR/SPECI/TAF)
5
+ and draws it. No network access.
6
+
7
+ ## A. Flat overlay (2D and 3D)
8
+
9
+ ```ts
10
+ import { animationSnapshot } from "@aeroswarm/weather";
11
+ import { createWeatherOverlay } from "@aeroswarm/weather-layer";
12
+
13
+ const overlay = createWeatherOverlay(map); // transparent canvases on map.domNode
14
+ const snapshot = animationSnapshot({ observation: metar, forecast: taf });
15
+ if (snapshot) overlay.setState(snapshot.state);
16
+
17
+ overlay.setVisible(false); // toggle from your layer panel
18
+ overlay.setOpacity(0.8);
19
+ overlay.destroy();
20
+ ```
21
+
22
+ React:
23
+
24
+ ```tsx
25
+ import { useWeatherOverlay } from "@aeroswarm/weather-layer/react";
26
+
27
+ useWeatherOverlay(map, snapshot?.state, { visible, opacity });
28
+ ```
29
+
30
+ Wind direction follows the map rotation (`mapScreenOffset`; override with `getScreenOffset`).
31
+ The overlay is a canvas above the map, so it is not part of `map.layerTree`: wire its `visible` and
32
+ `opacity` to your own layer panel. Honours `prefers-reduced-motion` (draws a still frame).
33
+
34
+ ## B. 3D clouds at real altitude
35
+
36
+ ```ts
37
+ import { FeatureLayer } from "@mid/ria/view/feature/FeatureLayer";
38
+ import { createCloudLayer3D } from "@aeroswarm/weather-layer";
39
+
40
+ const clouds = createCloudLayer3D(
41
+ { reference, Feature, FeatureModel, MemoryStore, FeatureLayer, FeaturePainter, createPoint },
42
+ { center: { lat: -7.3798, lon: 112.7869 }, elevationM: 3 },
43
+ );
44
+ map.layerTree.addChild(clouds.layer);
45
+ clouds.setState(snapshot.state);
46
+ ```
47
+
48
+ Cloud height = aerodrome elevation + cloud base above ground level (METAR reports AGL). Sprites are
49
+ sized in metres, so they scale with zoom and perspective. Drawn only when the map is 3D
50
+ (`EPSG:4978`) unless `only3D: false`. The RIA classes are passed in so this package never bundles
51
+ its own copy of RIA.
52
+
53
+ ## C. Windy-style fields (flow particles + colour maps)
54
+
55
+ Wind particles that follow a gridded wind field, coloured by speed, plus colour maps for cloud cover,
56
+ rain or temperature. The grid can come from anywhere (see `@aeroswarm/weather`: `windGridFromStations`
57
+ for several METARs, `parseGrib2Json`, `parseGridJson`, `stationWindsFromModelPoints`).
58
+
59
+ ```ts
60
+ import { createFieldOverlay, createRiaUnproject, PRECIPITATION_RAMP } from "@aeroswarm/weather-layer";
61
+
62
+ const unproject = createRiaUnproject(map, { wgs84, createPoint, createTransformation });
63
+ const overlay = createFieldOverlay(map, { unproject });
64
+
65
+ overlay.setWind(windGrid); // particles
66
+ overlay.setScalar(cloudGrid); // cloud cover (default ramp)
67
+ overlay.setScalar(rainGrid, PRECIPITATION_RAMP); // or rain, temperature, ...
68
+ ```
69
+
70
+ The field is mapped onto a screen grid whenever the map moves (throttled), so the particles read a
71
+ small array instead of projecting every particle on every frame. Points that are not on the map (3D:
72
+ off the globe) have no wind.
73
+
74
+ ## Limits
75
+
76
+ METAR and TAF describe one point (the aerodrome). The animation is a representation around the
77
+ airport, not the real position of clouds or rain. There is no terrain or land mask yet.
@@ -0,0 +1,312 @@
1
+ import { AnimationState, ScalarGrid, WindGrid } from '@aeroswarm/weather';
2
+ import { M as MapLike } from './overlay-U9My4PH5.js';
3
+ export { W as WeatherOverlay, a as WeatherOverlayOptions, b as WeatherRenderer, c as WeatherRendererOptions, d as WeatherTheme, e as createWeatherOverlay, m as mapScreenOffset } from './overlay-U9My4PH5.js';
4
+
5
+ /**
6
+ * Kelas dan fungsi LuciadRIA yang dipakai layer awan. Diberikan oleh pemakai (injeksi) supaya package
7
+ * ini tidak punya dependensi ke RIA dan memakai salinan RIA milik aplikasi yang sama dengan petanya.
8
+ */
9
+ interface RiaCloudDeps {
10
+ /** Referensi EPSG:4326, mis. `getReference("EPSG:4326")`. */
11
+ reference: unknown;
12
+ Feature: new (shape: unknown, properties: Record<string, unknown>, id?: string) => unknown;
13
+ FeatureModel: new (store: unknown, options: {
14
+ reference: unknown;
15
+ }) => CloudModel;
16
+ MemoryStore: new () => unknown;
17
+ FeatureLayer: new (model: CloudModel, options: Record<string, unknown>) => CloudFeatureLayer;
18
+ FeaturePainter: new () => CloudPainter;
19
+ createPoint: (reference: unknown, coordinates: number[]) => unknown;
20
+ }
21
+ interface CloudModel {
22
+ put(feature: unknown): unknown;
23
+ remove(id: string): unknown;
24
+ }
25
+ interface CloudFeatureLayer {
26
+ visible: boolean;
27
+ }
28
+ interface CloudPainter {
29
+ getDetailLevelScales?: () => number[];
30
+ paintBody: (geoCanvas: {
31
+ drawIcon(shape: unknown, style: Record<string, unknown>): void;
32
+ }, feature: {
33
+ properties: Record<string, unknown>;
34
+ }, shape: unknown, layer: unknown, map: {
35
+ reference: {
36
+ identifier: string;
37
+ };
38
+ }) => void;
39
+ }
40
+ interface CloudLayerOptions {
41
+ /** Pusat sebaran awan (bandara). */
42
+ center: {
43
+ lat: number;
44
+ lon: number;
45
+ };
46
+ /** Elevasi bandara (m di atas laut), ditambahkan ke tinggi dasar awan AGL. Bawaan 0. */
47
+ elevationM?: number;
48
+ radiusM?: number;
49
+ /** Awan hanya digambar di peta 3D (EPSG:4978). Bawaan true. */
50
+ only3D?: boolean;
51
+ /** Percepatan gerak awan. Bawaan 12. */
52
+ timeScale?: number;
53
+ /** Selang pembaruan posisi (ms). Bawaan 500. */
54
+ updateIntervalMs?: number;
55
+ id?: string;
56
+ }
57
+ interface CloudLayer3D {
58
+ readonly layer: CloudFeatureLayer;
59
+ /** Ganti kondisi cuaca: awan dibangun ulang dari lapisan awan METAR/TAF. */
60
+ setState(state: AnimationState): void;
61
+ setVisible(visible: boolean): void;
62
+ destroy(): void;
63
+ }
64
+ /** Sprite gumpalan awan sebagai data URL (gradien radial lembut). */
65
+ declare function makeCloudSpriteUrl(): string;
66
+ /**
67
+ * Layer RIA berisi gumpalan awan di ketinggian sebenarnya (elevasi bandara + tinggi dasar AGL),
68
+ * berukuran dalam meter sehingga ikut zoom dan perspektif 3D. Tambahkan ke peta dengan
69
+ * `map.layerTree.addChild(cloud.layer)`.
70
+ */
71
+ declare function createCloudLayer3D(ria: RiaCloudDeps, options: CloudLayerOptions): CloudLayer3D;
72
+
73
+ /** Satu gumpalan awan di sekitar bandara, dalam meter dari titik pusat. */
74
+ interface CloudPuff3D {
75
+ id: string;
76
+ eastM: number;
77
+ northM: number;
78
+ /** Ketinggian di atas permukaan laut (m): elevasi bandara + tinggi dasar awan (AGL). */
79
+ altM: number;
80
+ sizeM: number;
81
+ opacity: number;
82
+ convective: boolean;
83
+ }
84
+ interface CloudFieldOptions {
85
+ /** Radius sebaran awan dari bandara (m). Bawaan 12.000. */
86
+ radiusM?: number;
87
+ /** Elevasi bandara (m di atas laut). METAR memberi tinggi awan AGL, jadi ini ditambahkan. */
88
+ elevationM?: number;
89
+ /** Biji acak supaya letak awan konsisten antar render. */
90
+ seed?: number;
91
+ }
92
+ /** Pembangkit acak kecil (mulberry32) agar medan awan deterministik. */
93
+ declare function seededRandom(seed: number): () => number;
94
+ /**
95
+ * Sebar gumpalan awan di sekitar bandara dari lapisan awan METAR/TAF. Banyaknya menurut tutupan,
96
+ * ketinggiannya = elevasi bandara + tinggi dasar (AGL). Awan konvektif lebih besar dan lebih padat.
97
+ */
98
+ declare function buildCloudField(state: AnimationState, options?: CloudFieldOptions): CloudPuff3D[];
99
+ /**
100
+ * Geser awan searah angin (`toDeg`, derajat dari utara). `timeScale` mempercepat gerak supaya
101
+ * terlihat (1 = kecepatan nyata, yang nyaris tidak terlihat dari ketinggian peta). Awan yang
102
+ * keluar dari radius muncul lagi di sisi seberang.
103
+ */
104
+ declare function advanceCloudField(field: readonly CloudPuff3D[], wind: {
105
+ toDeg: number | null;
106
+ speedKt: number;
107
+ }, dtSeconds: number, options?: {
108
+ radiusM?: number;
109
+ timeScale?: number;
110
+ }): CloudPuff3D[];
111
+ /** Titik lat/lon dari selisih meter timur/utara terhadap pusat (pendekatan datar, cukup untuk ~20 km). */
112
+ declare function offsetToLonLat(centerLat: number, centerLon: number, eastM: number, northM: number): {
113
+ lat: number;
114
+ lon: number;
115
+ };
116
+
117
+ /** Warna: [nilai, merah, hijau, biru, alfa 0-1]. Nilai harus naik. */
118
+ type RampStop = readonly [number, number, number, number, number?];
119
+ type Rgba = readonly [number, number, number, number];
120
+ /** Warna di `value` dengan interpolasi linear antar titik; di luar rentang dipakai titik ujung. */
121
+ declare function rampColor(stops: readonly RampStop[], value: number): Rgba;
122
+ declare const rgbaCss: ([r, g, b, a]: Rgba, alpha?: number) => string;
123
+ /** Kecepatan angin (m/s): dari biru dingin (tenang) ke merah (kencang), seperti peta angin umum. */
124
+ declare const WIND_RAMP: readonly RampStop[];
125
+ /** Tutupan awan (%): bening ke putih keabu-abuan. */
126
+ declare const CLOUD_RAMP: readonly RampStop[];
127
+ /** Curah hujan (mm/jam). */
128
+ declare const PRECIPITATION_RAMP: readonly RampStop[];
129
+ /** Suhu (°C). */
130
+ declare const TEMPERATURE_RAMP: readonly RampStop[];
131
+
132
+ /** Titik layar (piksel) ke koordinat geografis; null bila di luar peta (mis. di luar bola bumi). */
133
+ type Unproject = (x: number, y: number) => {
134
+ lon: number;
135
+ lat: number;
136
+ } | null;
137
+ /** Angin yang sudah dipetakan ke kisi layar: satu vektor arah layar dan kecepatan per sel. */
138
+ interface ScreenWindField {
139
+ cols: number;
140
+ rows: number;
141
+ cell: number;
142
+ /** Vektor satuan arah gerak di layar (x kanan, y bawah). */
143
+ dx: Float32Array;
144
+ dy: Float32Array;
145
+ /** Kecepatan (m/s); NaN = tanpa data. */
146
+ speed: Float32Array;
147
+ }
148
+ interface ScreenScalarField {
149
+ cols: number;
150
+ rows: number;
151
+ cell: number;
152
+ values: Float32Array;
153
+ }
154
+ /**
155
+ * Ubah grid angin geografis menjadi kisi layar. Dilakukan sekali tiap peta bergerak, supaya partikel
156
+ * tinggal membaca kisi (tanpa memproyeksikan ulang tiap partikel tiap frame).
157
+ */
158
+ declare function buildScreenWindField(grid: WindGrid, width: number, height: number, unproject: Unproject, screenOffsetDeg: number, cell?: number): ScreenWindField;
159
+ declare function buildScreenScalarField(grid: ScalarGrid, width: number, height: number, unproject: Unproject, cell?: number): ScreenScalarField;
160
+ /** Angin di titik layar (x, y) dari kisi; null di luar kisi atau tanpa data. */
161
+ declare function windAt(field: ScreenWindField, x: number, y: number): {
162
+ dx: number;
163
+ dy: number;
164
+ speed: number;
165
+ } | null;
166
+
167
+ interface FieldOverlayOptions {
168
+ /** Titik layar ke lon/lat; lihat `createRiaUnproject`. */
169
+ unproject: Unproject;
170
+ zIndex?: number;
171
+ maxFps?: number;
172
+ pixelsPerMs?: number;
173
+ getScreenOffset?: (map: MapLike) => number;
174
+ }
175
+ interface FieldOverlay {
176
+ /** Aliran partikel angin dari grid; null untuk menghapus. */
177
+ setWind(grid: WindGrid | null): void;
178
+ /** Peta warna dari grid skalar (awan, hujan, suhu); null untuk menghapus. */
179
+ setScalar(grid: ScalarGrid | null, ramp?: readonly RampStop[]): void;
180
+ setVisible(visible: boolean): void;
181
+ setOpacity(opacity: number): void;
182
+ destroy(): void;
183
+ }
184
+ /**
185
+ * Overlay medan cuaca ala Windy di atas peta: peta warna (di bawah) dan aliran partikel angin (di
186
+ * atas). Medan dipetakan ulang ke layar tiap peta bergerak (dibatasi ~10 kali per detik).
187
+ */
188
+ declare function createFieldOverlay(map: MapLike, options: FieldOverlayOptions): FieldOverlay;
189
+
190
+ interface FlowRendererOptions {
191
+ container: HTMLElement;
192
+ unproject: Unproject;
193
+ zIndex?: number;
194
+ maxFps?: number;
195
+ /** Piksel per detik untuk tiap m/s angin. Bawaan 9. */
196
+ pixelsPerMs?: number;
197
+ /** Jumlah partikel maksimum. Bawaan 3000. */
198
+ maxParticles?: number;
199
+ reducedMotion?: boolean;
200
+ }
201
+ /**
202
+ * Aliran angin ala Windy: partikel bergerak mengikuti medan angin dari grid, berwarna menurut
203
+ * kecepatan, dengan ekor yang memudar. Medan dipetakan ke kisi layar sekali tiap peta bergerak.
204
+ */
205
+ declare class FlowRenderer {
206
+ private readonly options;
207
+ private readonly canvas;
208
+ private readonly ctx;
209
+ private grid;
210
+ private field;
211
+ private particles;
212
+ private width;
213
+ private height;
214
+ private dpr;
215
+ private offset;
216
+ private raf;
217
+ private running;
218
+ private last;
219
+ private accumulated;
220
+ private readonly frameInterval;
221
+ private readonly reduceMotion;
222
+ constructor(options: FlowRendererOptions);
223
+ setWind(grid: WindGrid | null): void;
224
+ setScreenOffset(degrees: number): void;
225
+ /** Dipanggil saat peta bergerak/zoom: petakan ulang medan ke layar. */
226
+ refreshField(): void;
227
+ resize(): void;
228
+ setVisible(visible: boolean): void;
229
+ setOpacity(opacity: number): void;
230
+ start(): void;
231
+ stop(): void;
232
+ destroy(): void;
233
+ private spawn;
234
+ private step;
235
+ }
236
+
237
+ /** Hitungan murni untuk renderer: tanpa DOM supaya mudah dites. */
238
+ /**
239
+ * Vektor gerak di layar (x ke kanan, y ke bawah) untuk arah `toDeg` (derajat dari utara, searah jarum
240
+ * jam). `screenOffsetDeg` = putaran peta: arah di layar = arah geografis + offset.
241
+ */
242
+ declare function screenVector(toDeg: number, screenOffsetDeg?: number): {
243
+ x: number;
244
+ y: number;
245
+ };
246
+ declare const clamp: (value: number, min?: number, max?: number) => number;
247
+ /** Jumlah partikel angin menurut luas layar, dibatasi `max`. */
248
+ declare function windParticleCount(width: number, height: number, max?: number): number;
249
+ /** Jumlah garis hujan yang digambar dari `pool` tetes, menurut intensitas 0-1. */
250
+ declare function rainCount(pool: number, intensity: number): number;
251
+ /** Kecepatan visual partikel (px/detik) dari kecepatan angin (kt) dan hembusan sesaat. */
252
+ declare function particleSpeed(speedKt: number, gustKt?: number): number;
253
+ /** Hembusan sesaat (kt tambahan) yang berdenyut halus sesuai `gustiness` 0-1. */
254
+ declare function gustPulse(gustiness: number, timeMs: number): number;
255
+ /** Selang antar kilat (ms) dari laju kilat per menit, dengan sedikit acak (`random` 0-1). */
256
+ declare function lightningInterval(perMinute: number, random: number): number;
257
+
258
+ /** Bagian RIA yang dipakai `createRiaUnproject`; diberikan oleh aplikasi (injeksi). */
259
+ interface RiaUnprojectDeps {
260
+ /** `getReference("EPSG:4326")`. */
261
+ wgs84: unknown;
262
+ createPoint: (reference: unknown, coordinates: number[]) => unknown;
263
+ createTransformation: (source: unknown, destination: unknown) => {
264
+ transform(point: unknown): {
265
+ x: number;
266
+ y: number;
267
+ };
268
+ };
269
+ }
270
+ interface UnprojectableMap {
271
+ readonly reference: unknown;
272
+ readonly viewToMapTransformation: {
273
+ transform(point: unknown): unknown;
274
+ };
275
+ }
276
+ /**
277
+ * Fungsi titik layar -> lon/lat untuk peta RIA 2D maupun 3D. Mengembalikan null untuk titik yang tidak
278
+ * jatuh pada peta (di 3D: di luar bola bumi), jadi aliran partikel berhenti di sana.
279
+ */
280
+ declare function createRiaUnproject(map: UnprojectableMap, deps: RiaUnprojectDeps): Unproject;
281
+
282
+ interface ScalarRendererOptions {
283
+ container: HTMLElement;
284
+ unproject: Unproject;
285
+ ramp?: readonly RampStop[];
286
+ zIndex?: number;
287
+ }
288
+ /**
289
+ * Peta warna dari grid skalar (tutupan awan, hujan, suhu): tiap sel layar diwarnai dari nilai grid,
290
+ * digambar halus. Digambar ulang hanya saat grid atau peta berubah, bukan tiap frame.
291
+ */
292
+ declare class ScalarRenderer {
293
+ private readonly options;
294
+ private readonly canvas;
295
+ private readonly ctx;
296
+ private readonly small;
297
+ private grid;
298
+ private ramp;
299
+ private width;
300
+ private height;
301
+ private dpr;
302
+ constructor(options: ScalarRendererOptions);
303
+ setGrid(grid: ScalarGrid | null, ramp?: readonly RampStop[]): void;
304
+ resize(): void;
305
+ /** Gambar ulang setelah peta bergerak atau di-zoom. */
306
+ draw(): void;
307
+ setVisible(visible: boolean): void;
308
+ setOpacity(opacity: number): void;
309
+ destroy(): void;
310
+ }
311
+
312
+ export { CLOUD_RAMP, type CloudFeatureLayer, type CloudFieldOptions, type CloudLayer3D, type CloudLayerOptions, type CloudModel, type CloudPuff3D, type FieldOverlay, type FieldOverlayOptions, FlowRenderer, type FlowRendererOptions, MapLike, PRECIPITATION_RAMP, type RampStop, type Rgba, type RiaCloudDeps, type RiaUnprojectDeps, ScalarRenderer, type ScalarRendererOptions, type ScreenScalarField, type ScreenWindField, TEMPERATURE_RAMP, type Unproject, WIND_RAMP, advanceCloudField, buildCloudField, buildScreenScalarField, buildScreenWindField, clamp, createCloudLayer3D, createFieldOverlay, createRiaUnproject, gustPulse, lightningInterval, makeCloudSpriteUrl, offsetToLonLat, particleSpeed, rainCount, rampColor, rgbaCss, screenVector, seededRandom, windAt, windParticleCount };