@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/dist/index.js ADDED
@@ -0,0 +1,1035 @@
1
+ // src/clouds3d.ts
2
+ var FT_TO_M = 0.3048;
3
+ var KT_TO_MS = 0.514444;
4
+ var DEFAULT_BASE_M = 600;
5
+ var MAX_PER_LAYER = 14;
6
+ function seededRandom(seed) {
7
+ let a = seed >>> 0;
8
+ return () => {
9
+ a = a + 1831565813 >>> 0;
10
+ let t = a;
11
+ t = Math.imul(t ^ t >>> 15, t | 1);
12
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
13
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
14
+ };
15
+ }
16
+ function buildCloudField(state, options = {}) {
17
+ const radius = options.radiusM ?? 12e3;
18
+ const elevation = options.elevationM ?? 0;
19
+ const random = seededRandom(options.seed ?? 1);
20
+ const field = [];
21
+ state.clouds.layers.forEach((layer, layerIndex) => {
22
+ const count = Math.max(1, Math.round(layer.coverage * MAX_PER_LAYER));
23
+ const baseM = layer.baseFt === null ? DEFAULT_BASE_M : layer.baseFt * FT_TO_M;
24
+ for (let i = 0; i < count; i++) {
25
+ const angle = random() * Math.PI * 2;
26
+ const distance = Math.sqrt(random()) * radius;
27
+ const size = (layer.convective ? 1400 : 800) * (0.6 + random() * 0.9);
28
+ field.push({
29
+ id: `cloud-${layerIndex}-${i}`,
30
+ eastM: Math.sin(angle) * distance,
31
+ northM: Math.cos(angle) * distance,
32
+ altM: elevation + baseM + random() * (layer.convective ? 400 : 120),
33
+ sizeM: size,
34
+ opacity: Math.min(0.9, 0.3 + layer.coverage * 0.5) * (layer.convective ? 1.1 : 1),
35
+ convective: layer.convective
36
+ });
37
+ }
38
+ });
39
+ return field;
40
+ }
41
+ function advanceCloudField(field, wind, dtSeconds, options = {}) {
42
+ if (wind.toDeg === null || wind.speedKt <= 0) return field.map((c) => ({ ...c }));
43
+ const radius = options.radiusM ?? 12e3;
44
+ const meters = wind.speedKt * KT_TO_MS * dtSeconds * (options.timeScale ?? 12);
45
+ const east = Math.sin(wind.toDeg * Math.PI / 180) * meters;
46
+ const north = Math.cos(wind.toDeg * Math.PI / 180) * meters;
47
+ return field.map((cloud) => {
48
+ let eastM = cloud.eastM + east;
49
+ let northM = cloud.northM + north;
50
+ if (Math.hypot(eastM, northM) > radius) {
51
+ eastM = -eastM * 0.98;
52
+ northM = -northM * 0.98;
53
+ }
54
+ return { ...cloud, eastM, northM };
55
+ });
56
+ }
57
+ function offsetToLonLat(centerLat, centerLon, eastM, northM) {
58
+ const metersPerDegLat = 111320;
59
+ const metersPerDegLon = metersPerDegLat * Math.cos(centerLat * Math.PI / 180);
60
+ return {
61
+ lat: centerLat + northM / metersPerDegLat,
62
+ lon: centerLon + eastM / metersPerDegLon
63
+ };
64
+ }
65
+
66
+ // src/cloudLayer.ts
67
+ function makeCloudSpriteUrl() {
68
+ const canvas = document.createElement("canvas");
69
+ canvas.width = canvas.height = 128;
70
+ const ctx = canvas.getContext("2d");
71
+ if (!ctx) return "";
72
+ const gradient = ctx.createRadialGradient(64, 64, 4, 64, 64, 62);
73
+ gradient.addColorStop(0, "rgba(255,255,255,0.95)");
74
+ gradient.addColorStop(0.55, "rgba(255,255,255,0.45)");
75
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
76
+ ctx.fillStyle = gradient;
77
+ ctx.fillRect(0, 0, 128, 128);
78
+ return canvas.toDataURL("image/png");
79
+ }
80
+ function createCloudLayer3D(ria, options) {
81
+ const model = new ria.FeatureModel(new ria.MemoryStore(), { reference: ria.reference });
82
+ const painter = new ria.FeaturePainter();
83
+ const spriteUrl = makeCloudSpriteUrl();
84
+ const only3D = options.only3D ?? true;
85
+ painter.getDetailLevelScales = () => [1 / 1e5, 1 / 2e4, 1 / 5e3];
86
+ painter.paintBody = (geoCanvas, feature, shape, _layer, map) => {
87
+ if (only3D && map.reference.identifier !== "EPSG:4978") return;
88
+ const size = Number(feature.properties.sizeM) || 800;
89
+ geoCanvas.drawIcon(shape, {
90
+ url: spriteUrl,
91
+ width: `${size}m`,
92
+ height: `${size}m`,
93
+ opacity: Number(feature.properties.opacity) || 0.5,
94
+ draped: false,
95
+ modulationColor: feature.properties.convective === true ? "#8f98a3" : "#ffffff"
96
+ });
97
+ };
98
+ const layer = new ria.FeatureLayer(model, {
99
+ id: options.id ?? "weather-clouds",
100
+ label: "Awan",
101
+ painter,
102
+ selectable: false,
103
+ visible: true
104
+ });
105
+ let wind = { toDeg: null, speedKt: 0 };
106
+ let field = [];
107
+ let knownIds = /* @__PURE__ */ new Set();
108
+ const render = () => {
109
+ const next = /* @__PURE__ */ new Set();
110
+ for (const cloud of field) {
111
+ const { lat, lon } = offsetToLonLat(
112
+ options.center.lat,
113
+ options.center.lon,
114
+ cloud.eastM,
115
+ cloud.northM
116
+ );
117
+ model.put(
118
+ new ria.Feature(
119
+ ria.createPoint(ria.reference, [lon, lat, cloud.altM]),
120
+ { sizeM: cloud.sizeM, opacity: cloud.opacity, convective: cloud.convective },
121
+ cloud.id
122
+ )
123
+ );
124
+ next.add(cloud.id);
125
+ }
126
+ for (const id of knownIds) if (!next.has(id)) model.remove(id);
127
+ knownIds = next;
128
+ };
129
+ const interval = options.updateIntervalMs ?? 500;
130
+ const timer = setInterval(() => {
131
+ if (!layer.visible || field.length === 0) return;
132
+ field = advanceCloudField(field, wind, interval / 1e3, {
133
+ radiusM: options.radiusM,
134
+ timeScale: options.timeScale
135
+ });
136
+ render();
137
+ }, interval);
138
+ return {
139
+ layer,
140
+ setState(state) {
141
+ wind = { toDeg: state.wind.toDeg, speedKt: state.wind.speedKt };
142
+ field = buildCloudField(state, {
143
+ radiusM: options.radiusM,
144
+ elevationM: options.elevationM
145
+ });
146
+ render();
147
+ },
148
+ setVisible(visible) {
149
+ layer.visible = visible;
150
+ },
151
+ destroy() {
152
+ clearInterval(timer);
153
+ for (const id of knownIds) model.remove(id);
154
+ knownIds = /* @__PURE__ */ new Set();
155
+ field = [];
156
+ }
157
+ };
158
+ }
159
+
160
+ // src/colorRamp.ts
161
+ function rampColor(stops, value) {
162
+ const first = stops[0];
163
+ const last = stops[stops.length - 1];
164
+ if (!first || !last) return [0, 0, 0, 0];
165
+ if (Number.isNaN(value) || value <= first[0]) return [first[1], first[2], first[3], first[4] ?? 1];
166
+ if (value >= last[0]) return [last[1], last[2], last[3], last[4] ?? 1];
167
+ for (let i = 1; i < stops.length; i++) {
168
+ const hi = stops[i];
169
+ const lo = stops[i - 1];
170
+ if (value <= hi[0]) {
171
+ const t = (value - lo[0]) / (hi[0] - lo[0] || 1);
172
+ const mix = (a, b) => a + (b - a) * t;
173
+ return [mix(lo[1], hi[1]), mix(lo[2], hi[2]), mix(lo[3], hi[3]), mix(lo[4] ?? 1, hi[4] ?? 1)];
174
+ }
175
+ }
176
+ return [last[1], last[2], last[3], last[4] ?? 1];
177
+ }
178
+ var rgbaCss = ([r, g, b, a], alpha = 1) => `rgba(${Math.round(r)},${Math.round(g)},${Math.round(b)},${(a * alpha).toFixed(3)})`;
179
+ var WIND_RAMP = [
180
+ [0, 98, 113, 184],
181
+ [2, 61, 110, 163],
182
+ [4, 74, 148, 170],
183
+ [7, 74, 146, 115],
184
+ [10, 77, 142, 56],
185
+ [13, 160, 168, 50],
186
+ [17, 210, 168, 48],
187
+ [22, 220, 110, 40],
188
+ [28, 190, 50, 50],
189
+ [36, 150, 40, 120]
190
+ ];
191
+ var CLOUD_RAMP = [
192
+ [0, 255, 255, 255, 0],
193
+ [20, 235, 238, 242, 0.15],
194
+ [60, 205, 210, 218, 0.45],
195
+ [100, 150, 158, 170, 0.75]
196
+ ];
197
+ var PRECIPITATION_RAMP = [
198
+ [0, 120, 190, 230, 0],
199
+ [0.1, 120, 190, 230, 0.45],
200
+ [1, 70, 190, 200, 0.7],
201
+ [3, 80, 190, 110, 0.78],
202
+ [6, 210, 220, 60, 0.82],
203
+ [12, 240, 170, 40, 0.85],
204
+ [25, 225, 80, 50, 0.88],
205
+ [50, 170, 40, 150, 0.9]
206
+ ];
207
+ var TEMPERATURE_RAMP = [
208
+ [10, 80, 90, 200, 0.7],
209
+ [20, 70, 170, 200, 0.7],
210
+ [26, 120, 200, 110, 0.7],
211
+ [30, 235, 200, 70, 0.72],
212
+ [34, 235, 130, 50, 0.75],
213
+ [40, 200, 50, 50, 0.8]
214
+ ];
215
+
216
+ // src/screenField.ts
217
+ import {
218
+ directionFromUV,
219
+ sampleScalar,
220
+ sampleWind,
221
+ speedOf
222
+ } from "@aeroswarm/weather";
223
+
224
+ // src/math.ts
225
+ var RAD = Math.PI / 180;
226
+ function screenVector(toDeg, screenOffsetDeg = 0) {
227
+ const angle = (toDeg + screenOffsetDeg) * RAD;
228
+ return { x: Math.sin(angle), y: -Math.cos(angle) };
229
+ }
230
+ var clamp = (value, min = 0, max = 1) => Math.min(max, Math.max(min, value));
231
+ function windParticleCount(width, height, max = 520) {
232
+ return Math.min(max, Math.round(width * height / 2600));
233
+ }
234
+ function rainCount(pool, intensity) {
235
+ return Math.round(pool * clamp(intensity));
236
+ }
237
+ function particleSpeed(speedKt, gustKt = 0) {
238
+ return (speedKt + gustKt) * 7;
239
+ }
240
+ function gustPulse(gustiness, timeMs) {
241
+ if (gustiness <= 0) return 0;
242
+ const wave = 0.5 + 0.5 * Math.sin(timeMs / 700) * Math.sin(timeMs / 1900 + 1);
243
+ return wave * gustiness * 20;
244
+ }
245
+ function lightningInterval(perMinute, random) {
246
+ if (perMinute <= 0) return Number.POSITIVE_INFINITY;
247
+ return 6e4 / perMinute * (0.4 + random * 1.2);
248
+ }
249
+
250
+ // src/screenField.ts
251
+ function buildScreenWindField(grid, width, height, unproject, screenOffsetDeg, cell = 24) {
252
+ const cols = Math.max(1, Math.ceil(width / cell));
253
+ const rows = Math.max(1, Math.ceil(height / cell));
254
+ const dx = new Float32Array(cols * rows);
255
+ const dy = new Float32Array(cols * rows);
256
+ const speed = new Float32Array(cols * rows).fill(Number.NaN);
257
+ for (let row = 0; row < rows; row++) {
258
+ for (let col = 0; col < cols; col++) {
259
+ const ll = unproject((col + 0.5) * cell, (row + 0.5) * cell);
260
+ const wind = ll ? sampleWind(grid, ll.lon, ll.lat) : null;
261
+ if (!wind) continue;
262
+ const index = row * cols + col;
263
+ const vector = screenVector((directionFromUV(wind.u, wind.v) + 180) % 360, screenOffsetDeg);
264
+ dx[index] = vector.x;
265
+ dy[index] = vector.y;
266
+ speed[index] = speedOf(wind.u, wind.v);
267
+ }
268
+ }
269
+ return { cols, rows, cell, dx, dy, speed };
270
+ }
271
+ function buildScreenScalarField(grid, width, height, unproject, cell = 8) {
272
+ const cols = Math.max(1, Math.ceil(width / cell));
273
+ const rows = Math.max(1, Math.ceil(height / cell));
274
+ const values = new Float32Array(cols * rows).fill(Number.NaN);
275
+ for (let row = 0; row < rows; row++) {
276
+ for (let col = 0; col < cols; col++) {
277
+ const ll = unproject((col + 0.5) * cell, (row + 0.5) * cell);
278
+ const value = ll ? sampleScalar(grid, ll.lon, ll.lat) : null;
279
+ if (value !== null) values[row * cols + col] = value;
280
+ }
281
+ }
282
+ return { cols, rows, cell, values };
283
+ }
284
+ function windAt(field, x, y) {
285
+ const col = Math.floor(x / field.cell);
286
+ const row = Math.floor(y / field.cell);
287
+ if (col < 0 || row < 0 || col >= field.cols || row >= field.rows) return null;
288
+ const index = row * field.cols + col;
289
+ const speed = field.speed[index];
290
+ if (Number.isNaN(speed)) return null;
291
+ return { dx: field.dx[index], dy: field.dy[index], speed };
292
+ }
293
+
294
+ // src/flowRenderer.ts
295
+ var FlowRenderer = class {
296
+ constructor(options) {
297
+ this.options = options;
298
+ this.frameInterval = 1e3 / (options.maxFps ?? 30);
299
+ this.reduceMotion = options.reducedMotion ?? (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
300
+ this.canvas = document.createElement("canvas");
301
+ this.canvas.dataset.weatherLayer = "flow";
302
+ Object.assign(this.canvas.style, {
303
+ position: "absolute",
304
+ inset: "0",
305
+ width: "100%",
306
+ height: "100%",
307
+ pointerEvents: "none",
308
+ zIndex: String(options.zIndex ?? 4)
309
+ });
310
+ options.container.appendChild(this.canvas);
311
+ const ctx = this.canvas.getContext("2d");
312
+ if (!ctx) throw new Error("Canvas 2D tidak tersedia");
313
+ this.ctx = ctx;
314
+ this.resize();
315
+ }
316
+ options;
317
+ canvas;
318
+ ctx;
319
+ grid = null;
320
+ field = null;
321
+ particles = [];
322
+ width = 1;
323
+ height = 1;
324
+ dpr = 1;
325
+ offset = 0;
326
+ raf = 0;
327
+ running = false;
328
+ last = 0;
329
+ accumulated = 0;
330
+ frameInterval;
331
+ reduceMotion;
332
+ setWind(grid) {
333
+ this.grid = grid;
334
+ this.refreshField();
335
+ }
336
+ setScreenOffset(degrees) {
337
+ if (degrees === this.offset) return;
338
+ this.offset = degrees;
339
+ this.refreshField();
340
+ }
341
+ /** Dipanggil saat peta bergerak/zoom: petakan ulang medan ke layar. */
342
+ refreshField() {
343
+ this.field = this.grid ? buildScreenWindField(this.grid, this.width, this.height, this.options.unproject, this.offset) : null;
344
+ if (!this.field) this.ctx.clearRect(0, 0, this.width, this.height);
345
+ }
346
+ resize() {
347
+ const rect = this.options.container.getBoundingClientRect();
348
+ this.width = Math.max(1, Math.round(rect.width));
349
+ this.height = Math.max(1, Math.round(rect.height));
350
+ this.dpr = Math.min(2, window.devicePixelRatio || 1);
351
+ this.canvas.width = this.width * this.dpr;
352
+ this.canvas.height = this.height * this.dpr;
353
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
354
+ const count = Math.min(this.options.maxParticles ?? 3e3, Math.round(this.width * this.height / 700));
355
+ this.particles = Array.from({ length: count }, () => this.spawn());
356
+ this.refreshField();
357
+ }
358
+ setVisible(visible) {
359
+ this.canvas.style.display = visible ? "block" : "none";
360
+ if (visible) this.start();
361
+ else this.stop();
362
+ }
363
+ setOpacity(opacity) {
364
+ this.canvas.style.opacity = String(Math.min(1, Math.max(0, opacity)));
365
+ }
366
+ start() {
367
+ if (this.running) return;
368
+ this.running = true;
369
+ this.last = performance.now();
370
+ const tick = (now) => {
371
+ if (!this.running) return;
372
+ this.accumulated += now - this.last;
373
+ this.last = now;
374
+ if (!document.hidden && this.accumulated >= this.frameInterval) {
375
+ this.step(Math.min(0.1, this.accumulated / 1e3));
376
+ this.accumulated = 0;
377
+ }
378
+ this.raf = requestAnimationFrame(tick);
379
+ };
380
+ this.raf = requestAnimationFrame(tick);
381
+ }
382
+ stop() {
383
+ this.running = false;
384
+ cancelAnimationFrame(this.raf);
385
+ }
386
+ destroy() {
387
+ this.stop();
388
+ this.canvas.remove();
389
+ }
390
+ spawn() {
391
+ return {
392
+ x: Math.random() * this.width,
393
+ y: Math.random() * this.height,
394
+ age: Math.floor(Math.random() * 60),
395
+ life: 50 + Math.random() * 80
396
+ };
397
+ }
398
+ step(dt) {
399
+ const ctx = this.ctx;
400
+ ctx.globalCompositeOperation = "destination-out";
401
+ ctx.fillStyle = `rgba(0,0,0,${this.reduceMotion ? 1 : 0.07})`;
402
+ ctx.fillRect(0, 0, this.width, this.height);
403
+ ctx.globalCompositeOperation = "source-over";
404
+ const field = this.field;
405
+ if (!field) return;
406
+ const pixelsPerMs = this.options.pixelsPerMs ?? 9;
407
+ ctx.lineWidth = 1.3;
408
+ ctx.lineCap = "round";
409
+ const motion = this.reduceMotion ? 0 : 1;
410
+ const buckets = /* @__PURE__ */ new Map();
411
+ for (const p of this.particles) {
412
+ const wind = windAt(field, p.x, p.y);
413
+ if (!wind || p.age > p.life) {
414
+ Object.assign(p, this.spawn(), { age: 0 });
415
+ continue;
416
+ }
417
+ const step = wind.speed * pixelsPerMs * dt * motion;
418
+ const nx = p.x + wind.dx * step;
419
+ const ny = p.y + wind.dy * step;
420
+ const key = Math.round(wind.speed / 1.5);
421
+ let bucket = buckets.get(key);
422
+ if (!bucket) {
423
+ bucket = { color: rgbaCss(rampColor(WIND_RAMP, key * 1.5), 0.85), path: new Path2D() };
424
+ buckets.set(key, bucket);
425
+ }
426
+ bucket.path.moveTo(p.x, p.y);
427
+ bucket.path.lineTo(nx, ny);
428
+ p.x = nx;
429
+ p.y = ny;
430
+ p.age++;
431
+ if (nx < 0 || ny < 0 || nx > this.width || ny > this.height) p.age = p.life + 1;
432
+ }
433
+ for (const { color, path } of buckets.values()) {
434
+ ctx.strokeStyle = color;
435
+ ctx.stroke(path);
436
+ }
437
+ }
438
+ };
439
+
440
+ // src/renderer.ts
441
+ var CALM = {
442
+ wind: { fromDeg: null, toDeg: null, speedKt: 0, gustiness: 0, turbulent: false },
443
+ clouds: { layers: [], total: 0 },
444
+ visibility: { meters: null, haze: 0 },
445
+ obscuration: null,
446
+ precipitation: null,
447
+ thunderstorm: null
448
+ };
449
+ var DROP_POOL = 700;
450
+ function makePuffSprite() {
451
+ const sprite = document.createElement("canvas");
452
+ sprite.width = sprite.height = 128;
453
+ const ctx = sprite.getContext("2d");
454
+ if (ctx) {
455
+ const gradient = ctx.createRadialGradient(64, 64, 4, 64, 64, 62);
456
+ gradient.addColorStop(0, "rgba(255,255,255,0.95)");
457
+ gradient.addColorStop(0.55, "rgba(255,255,255,0.45)");
458
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
459
+ ctx.fillStyle = gradient;
460
+ ctx.fillRect(0, 0, 128, 128);
461
+ }
462
+ return sprite;
463
+ }
464
+ var WeatherRenderer = class {
465
+ constructor(options) {
466
+ this.options = options;
467
+ this.theme = options.theme ?? "light";
468
+ this.frameInterval = 1e3 / (options.maxFps ?? 30);
469
+ this.reduceMotion = options.reducedMotion ?? (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches);
470
+ this.windCanvas = this.createCanvas("wind");
471
+ this.fxCanvas = this.createCanvas("fx");
472
+ const wind = this.windCanvas.getContext("2d");
473
+ const fx = this.fxCanvas.getContext("2d");
474
+ if (!wind || !fx) throw new Error("Canvas 2D tidak tersedia");
475
+ this.wind = wind;
476
+ this.fx = fx;
477
+ this.resize();
478
+ }
479
+ options;
480
+ windCanvas;
481
+ fxCanvas;
482
+ wind;
483
+ fx;
484
+ puff = makePuffSprite();
485
+ drops = Array.from({ length: DROP_POOL }, () => ({
486
+ x: Math.random(),
487
+ y: Math.random(),
488
+ len: 0.6 + Math.random() * 0.8,
489
+ speed: 0.7 + Math.random() * 0.6
490
+ }));
491
+ state = CALM;
492
+ theme;
493
+ screenOffset = 0;
494
+ width = 1;
495
+ height = 1;
496
+ dpr = 1;
497
+ particles = [];
498
+ clouds = [];
499
+ raf = 0;
500
+ lastFrame = 0;
501
+ accumulated = 0;
502
+ flash = 0;
503
+ bolt = null;
504
+ nextBolt = 0;
505
+ frameInterval;
506
+ reduceMotion;
507
+ running = false;
508
+ setState(state) {
509
+ this.state = state;
510
+ if (this.reduceMotion) this.renderFrame(performance.now(), 0);
511
+ }
512
+ /** Putaran peta (derajat) yang ditambahkan ke arah geografis angin. */
513
+ setScreenOffset(degrees) {
514
+ this.screenOffset = degrees;
515
+ }
516
+ setTheme(theme) {
517
+ this.theme = theme;
518
+ }
519
+ setVisible(visible) {
520
+ for (const canvas of [this.windCanvas, this.fxCanvas]) {
521
+ canvas.style.display = visible ? "block" : "none";
522
+ }
523
+ if (visible) this.start();
524
+ else this.stop();
525
+ }
526
+ setOpacity(opacity) {
527
+ for (const canvas of [this.windCanvas, this.fxCanvas]) {
528
+ canvas.style.opacity = String(clamp(opacity));
529
+ }
530
+ }
531
+ resize() {
532
+ const rect = this.options.container.getBoundingClientRect();
533
+ this.width = Math.max(1, Math.round(rect.width));
534
+ this.height = Math.max(1, Math.round(rect.height));
535
+ this.dpr = Math.min(2, window.devicePixelRatio || 1);
536
+ for (const canvas of [this.windCanvas, this.fxCanvas]) {
537
+ canvas.width = this.width * this.dpr;
538
+ canvas.height = this.height * this.dpr;
539
+ }
540
+ this.wind.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
541
+ this.fx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
542
+ this.seedParticles();
543
+ this.seedClouds();
544
+ }
545
+ start() {
546
+ if (this.running) return;
547
+ this.running = true;
548
+ this.lastFrame = performance.now();
549
+ const tick = (now) => {
550
+ if (!this.running) return;
551
+ const dt = (now - this.lastFrame) / 1e3;
552
+ this.lastFrame = now;
553
+ this.accumulated += dt * 1e3;
554
+ if (!document.hidden && this.accumulated >= this.frameInterval) {
555
+ this.renderFrame(now, Math.min(0.1, this.accumulated / 1e3));
556
+ this.accumulated = 0;
557
+ }
558
+ this.raf = requestAnimationFrame(tick);
559
+ };
560
+ this.raf = requestAnimationFrame(tick);
561
+ }
562
+ stop() {
563
+ this.running = false;
564
+ cancelAnimationFrame(this.raf);
565
+ }
566
+ destroy() {
567
+ this.stop();
568
+ this.windCanvas.remove();
569
+ this.fxCanvas.remove();
570
+ }
571
+ // ---- internal --------------------------------------------------------
572
+ createCanvas(name) {
573
+ const canvas = document.createElement("canvas");
574
+ canvas.dataset.weatherLayer = name;
575
+ Object.assign(canvas.style, {
576
+ position: "absolute",
577
+ inset: "0",
578
+ width: "100%",
579
+ height: "100%",
580
+ pointerEvents: "none",
581
+ zIndex: String(this.options.zIndex ?? 5)
582
+ });
583
+ this.options.container.appendChild(canvas);
584
+ return canvas;
585
+ }
586
+ seedParticles() {
587
+ const count = windParticleCount(this.width, this.height);
588
+ this.particles = Array.from({ length: count }, () => this.newParticle());
589
+ this.wind.clearRect(0, 0, this.width, this.height);
590
+ }
591
+ newParticle() {
592
+ return {
593
+ x: Math.random() * this.width,
594
+ y: Math.random() * this.height,
595
+ age: Math.floor(Math.random() * 80),
596
+ life: 60 + Math.random() * 90
597
+ };
598
+ }
599
+ seedClouds() {
600
+ this.clouds = Array.from({ length: 16 }, (_, i) => {
601
+ const size = 90 + Math.random() * 120;
602
+ const count = 5 + Math.floor(Math.random() * 4);
603
+ return {
604
+ x: Math.random() * (this.width + 300) - 150,
605
+ y: Math.random() * (this.height + 100) - 50,
606
+ layer: 0.6 + Math.random() * 0.8,
607
+ threshold: i / 16,
608
+ puffs: Array.from({ length: count }, () => ({
609
+ dx: (Math.random() - 0.5) * size * 1.4,
610
+ dy: (Math.random() - 0.5) * size * 0.5,
611
+ r: size * (0.5 + Math.random() * 0.5)
612
+ }))
613
+ };
614
+ });
615
+ }
616
+ renderFrame(now, dt) {
617
+ const { wind, thunderstorm } = this.state;
618
+ const motion = this.reduceMotion ? 0 : 1;
619
+ const toDeg = wind.toDeg;
620
+ const vector = toDeg === null ? null : screenVector(toDeg, this.screenOffset);
621
+ const gust = gustPulse(wind.gustiness, now);
622
+ const speed = particleSpeed(wind.speedKt, gust);
623
+ this.drawWind(now, dt, vector, wind.turbulent, speed, motion);
624
+ const fx = this.fx;
625
+ fx.clearRect(0, 0, this.width, this.height);
626
+ this.drawClouds(dt, vector, wind.speedKt, motion);
627
+ this.drawPrecipitation(dt, vector, wind.speedKt, motion);
628
+ this.drawHaze(now, motion);
629
+ this.drawLightning(now, dt, thunderstorm?.lightningPerMinute ?? 0);
630
+ }
631
+ drawWind(now, dt, vector, turbulent, speed, motion) {
632
+ const ctx = this.wind;
633
+ ctx.globalCompositeOperation = "destination-out";
634
+ ctx.fillStyle = `rgba(0,0,0,${this.reduceMotion ? 1 : 0.1})`;
635
+ ctx.fillRect(0, 0, this.width, this.height);
636
+ ctx.globalCompositeOperation = "source-over";
637
+ if (this.state.wind.speedKt <= 0) return;
638
+ const alpha = Math.min(0.75, 0.22 + this.state.wind.speedKt / 50);
639
+ ctx.lineWidth = 1.2;
640
+ ctx.lineCap = "round";
641
+ ctx.strokeStyle = this.theme === "dark" ? `rgba(120,200,220,${alpha})` : `rgba(31,122,140,${alpha})`;
642
+ ctx.beginPath();
643
+ for (const p of this.particles) {
644
+ const fromX = p.x;
645
+ const fromY = p.y;
646
+ let vx;
647
+ let vy;
648
+ if (turbulent || vector === null) {
649
+ vx = Math.cos(p.age / 12 + p.x) * 0.7;
650
+ vy = Math.sin(p.age / 14 + p.y) * 0.7;
651
+ } else {
652
+ const wobble = Math.sin(p.y / 55 + now / 1400) * 0.35 + Math.cos(p.x / 70 - now / 1700) * 0.25;
653
+ vx = vector.x - wobble * 0.25 * vector.y;
654
+ vy = vector.y + wobble * 0.25 * vector.x;
655
+ }
656
+ p.x += vx * speed * dt * motion;
657
+ p.y += vy * speed * dt * motion;
658
+ p.age++;
659
+ ctx.moveTo(fromX, fromY);
660
+ ctx.lineTo(p.x, p.y);
661
+ if (p.x < 0 || p.x > this.width || p.y < 0 || p.y > this.height || p.age > p.life) {
662
+ Object.assign(p, this.newParticle(), { age: 0 });
663
+ }
664
+ }
665
+ ctx.stroke();
666
+ }
667
+ drawClouds(dt, vector, speedKt, motion) {
668
+ const ctx = this.fx;
669
+ const cover = this.state.clouds.total;
670
+ const precipitation = this.state.precipitation;
671
+ const dark = clamp(
672
+ (precipitation?.intensity ?? 0) * 0.6 + (this.state.thunderstorm?.overhead ? 0.4 : 0)
673
+ );
674
+ if (cover <= 0) return;
675
+ for (const cloud of this.clouds) {
676
+ if (vector) {
677
+ cloud.x += vector.x * speedKt * 1.4 * cloud.layer * dt * motion;
678
+ cloud.y += vector.y * speedKt * 1.4 * cloud.layer * dt * motion;
679
+ }
680
+ if (cloud.x < -250) cloud.x = this.width + 250;
681
+ if (cloud.x > this.width + 250) cloud.x = -250;
682
+ if (cloud.y < -150) cloud.y = this.height + 150;
683
+ if (cloud.y > this.height + 150) cloud.y = -150;
684
+ const visible = clamp((cover * 1.15 - cloud.threshold) * 4);
685
+ if (visible <= 0) continue;
686
+ ctx.globalAlpha = visible * (0.38 + 0.3 * cover) * (1 - dark * 0.15);
687
+ for (const puff of cloud.puffs) {
688
+ ctx.drawImage(
689
+ this.puff,
690
+ cloud.x + puff.dx - puff.r,
691
+ cloud.y + puff.dy - puff.r,
692
+ puff.r * 2,
693
+ puff.r * 2
694
+ );
695
+ }
696
+ }
697
+ ctx.globalAlpha = 1;
698
+ if (dark > 0 || cover > 0.6) {
699
+ ctx.fillStyle = `rgba(38,48,62,${(cover * 0.2 + dark * 0.32).toFixed(3)})`;
700
+ ctx.fillRect(0, 0, this.width, this.height);
701
+ }
702
+ }
703
+ drawPrecipitation(dt, vector, speedKt, motion) {
704
+ const precipitation = this.state.precipitation;
705
+ if (!precipitation) return;
706
+ const ctx = this.fx;
707
+ const count = rainCount(this.drops.length, precipitation.intensity);
708
+ const slant = (vector?.x ?? 0) * Math.min(1, speedKt / 25) * 0.5;
709
+ const snow = precipitation.kind === "snow";
710
+ const fall = snow ? 0.25 : 1.6;
711
+ if (snow) ctx.fillStyle = "rgba(255,255,255,0.8)";
712
+ else {
713
+ ctx.strokeStyle = `rgba(150,185,225,${(0.3 + 0.3 * precipitation.intensity).toFixed(2)})`;
714
+ ctx.lineWidth = precipitation.kind === "drizzle" ? 0.7 : 1.1;
715
+ ctx.beginPath();
716
+ }
717
+ for (let i = 0; i < count; i++) {
718
+ const drop = this.drops[i];
719
+ if (!drop) continue;
720
+ drop.y += drop.speed * dt * fall * motion;
721
+ drop.x += slant * dt * fall * motion;
722
+ if (drop.y > 1) {
723
+ drop.y = -0.05;
724
+ drop.x = Math.random();
725
+ }
726
+ if (drop.x > 1) drop.x = 0;
727
+ if (drop.x < 0) drop.x = 1;
728
+ const x = drop.x * this.width;
729
+ const y = drop.y * this.height;
730
+ if (snow) ctx.fillRect(x, y, 2, 2);
731
+ else {
732
+ const length = 12 * drop.len;
733
+ ctx.moveTo(x, y);
734
+ ctx.lineTo(x - slant * length, y - length);
735
+ }
736
+ }
737
+ if (!snow) ctx.stroke();
738
+ }
739
+ drawHaze(now, motion) {
740
+ const { visibility, obscuration } = this.state;
741
+ const haze = Math.max(visibility.haze, (obscuration?.density ?? 0) * 0.7);
742
+ if (haze <= 0.01) return;
743
+ const ctx = this.fx;
744
+ const dark = this.theme === "dark";
745
+ ctx.fillStyle = `${dark ? "rgba(150,160,168," : "rgba(214,219,223,"}${(haze * 0.78).toFixed(3)})`;
746
+ ctx.fillRect(0, 0, this.width, this.height);
747
+ if (obscuration?.kind === "fog" || (visibility.meters ?? 1e4) < 1500) {
748
+ for (let i = 0; i < 3; i++) {
749
+ const cx = now / 40 * (i + 1) * 0.8 * motion % (this.width * 1.3) + i * this.width * 0.2;
750
+ const cy = this.height * (0.3 + i * 0.25);
751
+ const gradient = ctx.createRadialGradient(cx, cy, 10, cx, cy, this.width * 0.5);
752
+ gradient.addColorStop(0, dark ? "rgba(170,180,188,0.35)" : "rgba(235,238,240,0.5)");
753
+ gradient.addColorStop(1, "rgba(0,0,0,0)");
754
+ ctx.fillStyle = gradient;
755
+ ctx.fillRect(0, 0, this.width, this.height);
756
+ }
757
+ }
758
+ }
759
+ drawLightning(now, dt, perMinute) {
760
+ if (this.reduceMotion) return;
761
+ const ctx = this.fx;
762
+ if (perMinute > 0 && now > this.nextBolt) {
763
+ this.flash = 1;
764
+ this.bolt = this.makeBolt();
765
+ this.nextBolt = now + lightningInterval(perMinute, Math.random());
766
+ }
767
+ if (this.flash <= 0) return;
768
+ ctx.fillStyle = `rgba(255,255,255,${(this.flash * 0.35).toFixed(3)})`;
769
+ ctx.fillRect(0, 0, this.width, this.height);
770
+ if (this.bolt && this.flash > 0.35) {
771
+ ctx.strokeStyle = `rgba(255,255,255,${this.flash.toFixed(2)})`;
772
+ ctx.lineWidth = 2.2;
773
+ ctx.beginPath();
774
+ this.bolt.forEach(([x, y], i) => {
775
+ if (i === 0) ctx.moveTo(x, y);
776
+ else ctx.lineTo(x, y);
777
+ });
778
+ ctx.stroke();
779
+ }
780
+ this.flash -= dt * 3.2;
781
+ }
782
+ makeBolt() {
783
+ let x = this.width * (0.2 + Math.random() * 0.6);
784
+ let y = 0;
785
+ const points = [[x, y]];
786
+ const reach = this.height * (0.5 + Math.random() * 0.3);
787
+ while (y < reach) {
788
+ x += (Math.random() - 0.5) * 36;
789
+ y += 14 + Math.random() * 22;
790
+ points.push([x, y]);
791
+ }
792
+ return points;
793
+ }
794
+ };
795
+
796
+ // src/overlay.ts
797
+ function mapScreenOffset(map) {
798
+ const camera = map.camera;
799
+ if (!camera) return 0;
800
+ try {
801
+ if (map.reference?.identifier === "EPSG:4978") {
802
+ return -(camera.asLookAt?.(1e3).yaw ?? 0);
803
+ }
804
+ return -(camera.asLook2D?.().rotation ?? 0);
805
+ } catch {
806
+ return 0;
807
+ }
808
+ }
809
+ function detectTheme() {
810
+ const explicit = document.documentElement.dataset.theme;
811
+ if (explicit === "dark" || explicit === "light") return explicit;
812
+ return matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
813
+ }
814
+ function createWeatherOverlay(map, options = {}) {
815
+ const container = map.domNode;
816
+ if (getComputedStyle(container).position === "static") {
817
+ container.style.position = "relative";
818
+ }
819
+ const theme = options.theme ?? "auto";
820
+ const renderer = new WeatherRenderer({
821
+ container,
822
+ theme: theme === "auto" ? detectTheme() : theme,
823
+ zIndex: options.zIndex,
824
+ maxFps: options.maxFps
825
+ });
826
+ const offsetOf = options.getScreenOffset ?? mapScreenOffset;
827
+ let width = container.clientWidth;
828
+ let height = container.clientHeight;
829
+ const handle = map.on("MapChange", () => {
830
+ renderer.setScreenOffset(offsetOf(map));
831
+ if (container.clientWidth !== width || container.clientHeight !== height) {
832
+ width = container.clientWidth;
833
+ height = container.clientHeight;
834
+ renderer.resize();
835
+ }
836
+ });
837
+ const themeObserver = theme === "auto" ? new MutationObserver(() => renderer.setTheme(detectTheme())) : null;
838
+ themeObserver?.observe(document.documentElement, {
839
+ attributes: true,
840
+ attributeFilter: ["data-theme"]
841
+ });
842
+ renderer.setScreenOffset(offsetOf(map));
843
+ renderer.start();
844
+ return {
845
+ renderer,
846
+ setState: (state) => renderer.setState(state),
847
+ setVisible: (visible) => renderer.setVisible(visible),
848
+ setOpacity: (opacity) => renderer.setOpacity(opacity),
849
+ destroy() {
850
+ handle.remove();
851
+ themeObserver?.disconnect();
852
+ renderer.destroy();
853
+ }
854
+ };
855
+ }
856
+
857
+ // src/scalarRenderer.ts
858
+ var ScalarRenderer = class {
859
+ constructor(options) {
860
+ this.options = options;
861
+ this.ramp = options.ramp ?? CLOUD_RAMP;
862
+ this.canvas = document.createElement("canvas");
863
+ this.canvas.dataset.weatherLayer = "scalar";
864
+ Object.assign(this.canvas.style, {
865
+ position: "absolute",
866
+ inset: "0",
867
+ width: "100%",
868
+ height: "100%",
869
+ pointerEvents: "none",
870
+ zIndex: String(options.zIndex ?? 3)
871
+ });
872
+ options.container.appendChild(this.canvas);
873
+ const ctx = this.canvas.getContext("2d");
874
+ if (!ctx) throw new Error("Canvas 2D tidak tersedia");
875
+ this.ctx = ctx;
876
+ this.resize();
877
+ }
878
+ options;
879
+ canvas;
880
+ ctx;
881
+ small = document.createElement("canvas");
882
+ grid = null;
883
+ ramp;
884
+ width = 1;
885
+ height = 1;
886
+ dpr = 1;
887
+ setGrid(grid, ramp) {
888
+ this.grid = grid;
889
+ if (ramp) this.ramp = ramp;
890
+ this.draw();
891
+ }
892
+ resize() {
893
+ const rect = this.options.container.getBoundingClientRect();
894
+ this.width = Math.max(1, Math.round(rect.width));
895
+ this.height = Math.max(1, Math.round(rect.height));
896
+ this.dpr = Math.min(2, window.devicePixelRatio || 1);
897
+ this.canvas.width = this.width * this.dpr;
898
+ this.canvas.height = this.height * this.dpr;
899
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
900
+ this.draw();
901
+ }
902
+ /** Gambar ulang setelah peta bergerak atau di-zoom. */
903
+ draw() {
904
+ const ctx = this.ctx;
905
+ ctx.clearRect(0, 0, this.width, this.height);
906
+ if (!this.grid) return;
907
+ const field = buildScreenScalarField(this.grid, this.width, this.height, this.options.unproject);
908
+ this.small.width = field.cols;
909
+ this.small.height = field.rows;
910
+ const small = this.small.getContext("2d");
911
+ if (!small) return;
912
+ const image = small.createImageData(field.cols, field.rows);
913
+ for (let i = 0; i < field.values.length; i++) {
914
+ const value = field.values[i];
915
+ if (Number.isNaN(value)) continue;
916
+ const [r, g, b, a] = rampColor(this.ramp, value);
917
+ image.data[i * 4] = r;
918
+ image.data[i * 4 + 1] = g;
919
+ image.data[i * 4 + 2] = b;
920
+ image.data[i * 4 + 3] = Math.round(a * 255);
921
+ }
922
+ small.putImageData(image, 0, 0);
923
+ ctx.imageSmoothingEnabled = true;
924
+ ctx.imageSmoothingQuality = "high";
925
+ ctx.drawImage(this.small, 0, 0, field.cols * field.cell, field.rows * field.cell);
926
+ }
927
+ setVisible(visible) {
928
+ this.canvas.style.display = visible ? "block" : "none";
929
+ }
930
+ setOpacity(opacity) {
931
+ this.canvas.style.opacity = String(Math.min(1, Math.max(0, opacity)));
932
+ }
933
+ destroy() {
934
+ this.canvas.remove();
935
+ }
936
+ };
937
+
938
+ // src/fieldOverlay.ts
939
+ function createFieldOverlay(map, options) {
940
+ const container = map.domNode;
941
+ if (getComputedStyle(container).position === "static") container.style.position = "relative";
942
+ const offsetOf = options.getScreenOffset ?? mapScreenOffset;
943
+ const scalar = new ScalarRenderer({ container, unproject: options.unproject, zIndex: options.zIndex });
944
+ const flow = new FlowRenderer({
945
+ container,
946
+ unproject: options.unproject,
947
+ zIndex: (options.zIndex ?? 3) + 1,
948
+ maxFps: options.maxFps,
949
+ pixelsPerMs: options.pixelsPerMs
950
+ });
951
+ let width = container.clientWidth;
952
+ let height = container.clientHeight;
953
+ let pending = null;
954
+ const refresh = () => {
955
+ pending = null;
956
+ if (container.clientWidth !== width || container.clientHeight !== height) {
957
+ width = container.clientWidth;
958
+ height = container.clientHeight;
959
+ scalar.resize();
960
+ flow.resize();
961
+ }
962
+ flow.setScreenOffset(offsetOf(map));
963
+ flow.refreshField();
964
+ scalar.draw();
965
+ };
966
+ const handle = map.on("MapChange", () => {
967
+ if (pending === null) pending = setTimeout(refresh, 100);
968
+ });
969
+ flow.setScreenOffset(offsetOf(map));
970
+ flow.start();
971
+ return {
972
+ setWind: (grid) => flow.setWind(grid),
973
+ setScalar: (grid, ramp) => scalar.setGrid(grid, ramp),
974
+ setVisible(visible) {
975
+ flow.setVisible(visible);
976
+ scalar.setVisible(visible);
977
+ },
978
+ setOpacity(opacity) {
979
+ flow.setOpacity(opacity);
980
+ scalar.setOpacity(opacity);
981
+ },
982
+ destroy() {
983
+ if (pending !== null) clearTimeout(pending);
984
+ handle.remove();
985
+ flow.destroy();
986
+ scalar.destroy();
987
+ }
988
+ };
989
+ }
990
+
991
+ // src/riaUnproject.ts
992
+ function createRiaUnproject(map, deps) {
993
+ const toWgs84 = deps.createTransformation(map.reference, deps.wgs84);
994
+ return (x, y) => {
995
+ try {
996
+ const mapPoint = map.viewToMapTransformation.transform(deps.createPoint(null, [x, y, 0]));
997
+ const point = toWgs84.transform(mapPoint);
998
+ return Number.isFinite(point.x) && Number.isFinite(point.y) ? { lon: point.x, lat: point.y } : null;
999
+ } catch {
1000
+ return null;
1001
+ }
1002
+ };
1003
+ }
1004
+ export {
1005
+ CLOUD_RAMP,
1006
+ FlowRenderer,
1007
+ PRECIPITATION_RAMP,
1008
+ ScalarRenderer,
1009
+ TEMPERATURE_RAMP,
1010
+ WIND_RAMP,
1011
+ WeatherRenderer,
1012
+ advanceCloudField,
1013
+ buildCloudField,
1014
+ buildScreenScalarField,
1015
+ buildScreenWindField,
1016
+ clamp,
1017
+ createCloudLayer3D,
1018
+ createFieldOverlay,
1019
+ createRiaUnproject,
1020
+ createWeatherOverlay,
1021
+ gustPulse,
1022
+ lightningInterval,
1023
+ makeCloudSpriteUrl,
1024
+ mapScreenOffset,
1025
+ offsetToLonLat,
1026
+ particleSpeed,
1027
+ rainCount,
1028
+ rampColor,
1029
+ rgbaCss,
1030
+ screenVector,
1031
+ seededRandom,
1032
+ windAt,
1033
+ windParticleCount
1034
+ };
1035
+ //# sourceMappingURL=index.js.map