From 25872c2255cc93a43db0dbf434baeab042d7fca9 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Thu, 15 Oct 2026 14:03:45 +0800 Subject: [PATCH] fix(map): rebuild the wind particle renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 修正風場粒子在 iOS 上下顛倒、在部分 Android 手機上全部往同一個方向飛 Fix(en-US): wind particles are no longer upside down on iOS or all flying the same way on some Android phones Optimization(zh-Hant): 風場粒子改成較長的流線尾跡,拖動與縮放地圖時跟著地圖移動,不再消失 Optimization(en-US): wind particles leave longer flowing trails and move with the map while you pan or zoom instead of disappearing --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../layers/wind_forecast_layer.dart | 22 +- .../layers/wind_particle_native.dart | 85 +- .../layers/wind_particle_sim.dart | 894 ++++++------------ pubspec.lock | 18 +- pubspec.yaml | 16 +- .../layers/wind_forecast_chrome_test.dart | 12 +- .../map/wind_particle_native_test.dart | 77 +- test/features/map/wind_particle_sim_test.dart | 554 +++++------ test/features/map/wind_web_parity_test.dart | 127 --- 11 files changed, 604 insertions(+), 1209 deletions(-) delete mode 100644 test/features/map/wind_web_parity_test.dart diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2f2801e7f..256ba0a1e 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", "state" : { - "revision" : "84a79bc375a301169390ac110c868f06c857b83f", - "version" : "6.27.0" + "revision" : "5ee345ca5d65238a6fce29bba87816204be7df20", + "version" : "6.28.0" } }, { diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2f2801e7f..256ba0a1e 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", "state" : { - "revision" : "84a79bc375a301169390ac110c868f06c857b83f", - "version" : "6.27.0" + "revision" : "5ee345ca5d65238a6fce29bba87816204be7df20", + "version" : "6.28.0" } }, { diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart index 13d173e55..6da6a3dde 100644 --- a/lib/features/map/presentation/layers/wind_forecast_layer.dart +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -61,30 +61,10 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { /// frame under HCPP, and the particles were the only thing in the app /// repainting every frame. iOS has no leak, but the same native path is where /// map content belongs and keeps one wire for both. - late final WindParticleNative particles = WindParticleNative( - field: field, - interacting: interacting, - ); + late final WindParticleNative particles = WindParticleNative(field: field); bool _particlesAttached = false; - /// Whether a finger is currently on the map. - /// - /// The particle field is torn down for the whole gesture and reseeded when it - /// ends. That is cheaper *and* more correct than animating through it: the - /// population size is a function of zoom, so a pinch re-sizes it on every - /// frame of the gesture — growing it seeds particles into a viewport that is - /// still moving, shrinking it truncates the list — and the field arrives at - /// the final zoom carrying whatever that churn produced. Starting again from - /// the camera the gesture settled on costs one reseed and is always right. - final ValueNotifier interacting = ValueNotifier(false); - - @override - void onMapGestureStart() => interacting.value = true; - - @override - void onMapGestureEnd() => interacting.value = false; - String? _loadedFieldFrame; String? _requestedFieldFrame; int _fieldGeneration = 0; diff --git a/lib/features/map/presentation/layers/wind_particle_native.dart b/lib/features/map/presentation/layers/wind_particle_native.dart index e6a5a72e4..e62d2f50a 100644 --- a/lib/features/map/presentation/layers/wind_particle_native.dart +++ b/lib/features/map/presentation/layers/wind_particle_native.dart @@ -2,10 +2,8 @@ library; import 'dart:async'; -import 'dart:ui' as ui; import 'package:dpip/core/logging/log.dart'; -import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; import 'package:dpip/features/weather/domain/wind_field.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -25,22 +23,15 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// but the same pass is where map content belongs, and one wire feeds both. /// /// This class owns only the *conversation* with that renderer — when to add it, -/// what to upload, when to let it run. The simulation itself is gone from Dart; -/// [WindParticleSim] survives as the numeric oracle the native shaders are -/// checked against, not as something that runs in production. +/// what to upload, when to let it run. Every number the renderer draws with is +/// native; `wind_particle_sim.dart` is their reference, not a source. class WindParticleNative { - WindParticleNative({ - required this.field, - required this.interacting, - TargetPlatform? platform, - }) : _platform = platform ?? defaultTargetPlatform; + WindParticleNative({required this.field, TargetPlatform? platform}) + : _platform = platform ?? defaultTargetPlatform; /// The grid to animate; null while the timeline moves or before it arrives. final ValueListenable field; - /// Whether a finger is on the map. - final ValueListenable interacting; - final TargetPlatform _platform; MapLibreMapController? _controller; @@ -81,13 +72,9 @@ class WindParticleNative { if (!isSupported || _unavailable) return; _controller = controller; field.addListener(_onFieldChanged); - interacting.addListener(_onInteractingChanged); _enqueue(() async { await controller.addWindParticleLayer(); _added = true; - await controller.setWindParticleTuning( - windParticleTuning(pixelRatio: _devicePixelRatio()), - ); await _pushField(controller); await _pushPlaying(controller); }); @@ -96,7 +83,6 @@ class WindParticleNative { /// Releases the native layer. Safe to call when nothing was ever added. Future detach() async { field.removeListener(_onFieldChanged); - interacting.removeListener(_onInteractingChanged); final controller = _controller; _controller = null; if (!_added || controller == null) { @@ -128,8 +114,6 @@ class WindParticleNative { }); } - void _onInteractingChanged() => _sync(); - void _sync() { final controller = _controller; if (controller == null) return; @@ -145,16 +129,15 @@ class WindParticleNative { await controller.setWindParticleField(payload); } - /// The animation runs only when there is something to animate, someone to - /// see it, and the camera is stable. Pausing during a gesture prevents a - /// state pair sampled under different projections from becoming a streak; - /// native clears its trail buffers before resuming. + /// The animation runs only when there is something to animate and someone + /// to see it. + /// + /// Not paused for gestures. Native keeps the field in the viewport as it was + /// when the camera last settled and carries that with the map, so a pan or + /// a pinch moves the trails instead of wiping them — and pausing would hand + /// back the blank map and the regrowth that are the thing being avoided. Future _pushPlaying(MapLibreMapController controller) async { - final want = windParticleShouldPlay( - visible: _visible, - interacting: interacting.value, - field: _uploaded, - ); + final want = windParticleShouldPlay(visible: _visible, field: _uploaded); if (want == _playing) return; _playing = want; await controller.setWindParticlePlaying(want); @@ -187,50 +170,8 @@ class WindParticleNative { @visibleForTesting bool windParticleShouldPlay({ required bool visible, - required bool interacting, required WindField? field, -}) => visible && !interacting && field?.source != null; - -/// The display scale the point size is expressed in. -/// -/// `pointSize` is tuned in logical pixels, but `gl_PointSize` is in physical -/// ones — on a 3x phone an unconverted value draws the field a third of its -/// intended weight, which reads as "the wind is faint today" rather than as a -/// bug. Read from the dispatcher rather than a `BuildContext` because the layer -/// attaches from a controller callback, where there is no element to look up. -double _devicePixelRatio() { - final views = ui.PlatformDispatcher.instance.views; - return views.isEmpty ? 1 : views.first.devicePixelRatio; -} - -/// The zoom curves as their endpoints, in the wire's shape. -/// -/// Sent whole so the numbers keep one home. Evaluating them per zoom in Dart -/// would put a platform call on the camera path — the exact per-frame traffic -/// this design exists to remove — and would leave two copies of the curve to -/// drift apart. `wind_particle_sim.dart` stays their definition; this is a -/// projection of it. -Map windParticleTuning({double pixelRatio = 1}) => { - 'zoomLo': kWindZoomLo, - 'zoomHi': kWindZoomHi, - 'particlesLo': kWindParticles.$1, - 'particlesHi': kWindParticles.$2, - 'lineWidthZ3': kWindLineWidths[0], - 'lineWidthZ4': kWindLineWidths[1], - 'lineWidthZ5': kWindLineWidths[2], - 'lineWidthZ6': kWindLineWidths[3], - 'lineWidthZ7': kWindLineWidths[4], - 'particleWidth': kWindParticleWidth, - 'speedFactorLo': kWindSpeedFactor.$1, - 'speedFactorHi': kWindSpeedFactor.$2, - 'fadeOpacityLo': kWindFadeOpacity.$1, - 'fadeOpacityHi': kWindFadeOpacity.$2, - 'dropRate': kWindDropRate, - 'densityCalm': kWindDensityCalm, - 'densityStrong': kWindDensityStrong, - 'speedScale': kWindSpeedScale, - 'pixelRatio': pixelRatio, -}; +}) => visible && field?.source != null; /// One wind field in the wire's shape, or null when it carries no payload. /// diff --git a/lib/features/map/presentation/layers/wind_particle_sim.dart b/lib/features/map/presentation/layers/wind_particle_sim.dart index efb22fd8c..1afe284f8 100644 --- a/lib/features/map/presentation/layers/wind_particle_sim.dart +++ b/lib/features/map/presentation/layers/wind_particle_sim.dart @@ -1,214 +1,215 @@ -/// Pure particle-advection math for the wind-forecast overlay — no Flutter -/// widgets, so every projection and step is unit-testable in isolation. +/// The wind particles' math, as the GPU does it. /// -/// The numbers below are the web renderer's, ported rather than reinvented: -/// `satellite-tiles-go/web/index.html` carries a tuning panel whose sliders -/// were dragged until the flow looked right, and `DEFAULTS` is where those -/// values were written down. Anything tuned by eye cannot be re-derived, so the -/// only way for two renderers to agree is for one of them to copy. +/// The particles are drawn natively — `WindParticleLayer.java` and +/// `WindParticleLayer.swift` in the MapLibre fork. Nothing in this file runs in +/// production. It is the one place every number and every sign those two +/// shaders must agree on is written in a language with a test runner, so the +/// tests can hold them still: the tuning tables, the speed curve, the velocity +/// byte encoding, which way is up, and where the trail texture lands while the +/// camera moves. +/// +/// Each function names the native code it mirrors. A change to one without the +/// other is the bug these exist to catch, so change them together. library; import 'dart:math' as math; -import 'dart:typed_data'; import 'dart:ui'; -import 'package:dpip/features/weather/domain/wind_field.dart'; +// --------------------------------------------------------------- the tuning +// +// How the field looks, as numbers. Native carries the same values as +// constants; change them together. + +/// Particle-state texture edge: a pool of 65,536, of which zoom draws a part. +const int kWindStateEdge = 256; + +/// Sixteen blocks, one reborn every eight 60 Hz frames: every particle lives +/// 128 frames (2.13 s), and its age is always known. +const int kWindBlocks = 16; +const int kWindBlockFrames = 8; +const int kWindLifetime = kWindBlocks * kWindBlockFrames; + +/// Stroke width, indexed by tile zoom ([tileZoom]). +const List kWindLineWidth = [ + .6, .6, .6, 1, 1.2, 1.6, 1.8, 2, 2.2, 2.4, 2.4, 2.4, // + 2.4, 2.6, 2.8, 3, 3, 3, 3, 3, 3, 3, 3, 3, +]; + +/// Speed factor, indexed by tile zoom: particles slow down zoomed out. +const List kWindZoomToSpeed = [ + .5, .5, .5, .6, .7, .8, .9, 1, 1, 1, 1, 1, 1, // + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, +]; + +/// Stroke width multiplier, before the device pixel ratio. +const double kWindParticleWidth = 1.3; -/// Wind speed (m/s) that saturates the visual ramp — fixed across every frame -/// and both models so a streak means the same thing everywhere -/// (`web/wind.js`, `SPEED_SCALE`). -/// The tuning curves below are **public because they are now a wire contract**. -/// -/// The simulation they were written for no longer runs in production — the -/// particles are advected on the GPU inside the map. These constants are sent -/// to that renderer as endpoints and interpolated there with the same rules, -/// so they keep exactly one definition. This file stays as the numeric oracle -/// the GLSL is checked against; see `wind_particle_native.dart`. -const double kWindSpeedScale = 32; - -/// The zooms the tuned values are pinned at (`web/index.html`, `ZOOM_STOPS`), -/// which are also this layer's own limits. -const double kWindZoomLo = 3; -const double kWindZoomHi = 7; - -// Each pair is (value at z3, value at z7). Which ones interpolate -// geometrically and which linearly is not a free choice either — it is what -// `TUNE` declares, and a count that steps 6400 → 4096 → 2601 is a visibly -// different field from one that steps 6400 → 5056 → 3712. -const (double, double) kWindParticles = (6400, 1024); // log -const (double, double) kWindSpeedFactor = (0.2, 0.0151); // log -// Windy's default blending is 1.0. Mobile multiplies it by 1.06, and the -// renderer's `0.9 + 0.5 * (blending - 0.92)` formula therefore gives 0.97. -// DPIP only runs this native renderer on mobile, so there is no desktop branch -// to preserve here. -/// What fraction of the accumulated trail survives each 60 Hz step. -/// -/// 0.85, not the reference's 0.97, and the difference is a deliberate choice -/// about what a streak should look like rather than a correction. -/// -/// Windy's 0.97 has a 46-step half life, so a stroke stays visible for most of -/// a second and the field reads as long continuous flow lines. The Flutter -/// overlay this app used to fall back to drew something else entirely: the last -/// **14 frames** of dot positions with a quadratic alpha ramp, which reads as -/// short bright dashes. That was the look chosen here. -/// -/// 0.85 puts the exponential where the 14-frame window was — `0.85^14 = 0.10`, -/// and the composite's black-point lift removes what is left — so the native -/// renderer produces the dash rather than the streak. -const (double, double) kWindFadeOpacity = (0.85, 0.85); // mobile, constant +/// Physical pixels per second at full curve strength. +const double kWindSpeedPx = 100; -/// Windy's integer-zoom line-width table over this layer's z3–z7 range. -/// -/// This is not an endpoint curve: the reference has a deliberate kink at -/// every zoom (`1, 1.2, 1.6, 1.8, 2`). Collapsing it to z3/z7 endpoints makes -/// z5 only 1.5 and is visibly thinner than the source renderer. -const List kWindLineWidths = [1, 1.2, 1.6, 1.8, 2]; +/// The speed curve: full strength at [kWindMaxSpeed] m/s, never below +/// [kWindMinSpeed] m/s's share, and `(speed / max)^pow` between. +const double kWindMaxSpeed = 30; +const double kWindMinSpeed = 1.5; +const double kWindCurvePow = 0.7; -/// `glParticleWidth` from the reference wind product. -const double kWindParticleWidth = 1.3; +/// The particle count is halved for a phone screen. +const double kWindCountMul = 0.5; -/// Chance per frame that a particle in good standing is recycled anyway. -const double kWindDropRate = 0.011; +/// Trail decay per 60 Hz frame. +const double kWindFade = 0.97; -/// Relative density of particles in still air and in strong wind. -/// -/// Strong by eleven to one, which is the opposite of what the same pair used -/// to say. See the note in `web/index.html`: once the colour underneath -/// carries the speed, thinning the streaks where the weather is spends the one -/// channel still describing direction. -const double kWindDensityCalm = 0.5; -const double kWindDensityStrong = 5.5; - -/// Where a zoom sits between the two tuned stops. +/// The trails reach the map at 0.44, with 0.1 subtracted. /// -/// Clamped rather than extrapolated, matching the web's `effective()`: outside -/// the stops there is no judgement behind the number, only arithmetic. -double _stopFraction(double zoom) => - ((zoom - kWindZoomLo) / (kWindZoomHi - kWindZoomLo)).clamp(0.0, 1.0); - -double _lerpStops((double, double) v, double zoom) { - final f = _stopFraction(zoom); - return v.$1 * (1 - f) + v.$2 * f; -} +/// The subtraction is not a matter of taste. An 8-bit trail multiplied by +/// 0.97 stops getting darker once `round(v · 0.97) == v`, which is every value +/// below 17/255; without the lift that floor is a permanent grey haze. +const double kWindCompositeGain = 0.44; +const double kWindCompositeLift = -0.1; -double _logLerpStops((double, double) v, double zoom) { - final f = _stopFraction(zoom); - return math.exp(math.log(v.$1) * (1 - f) + math.log(v.$2) * f); -} +/// The layer fades in at this rate per second when playback starts. +const double kWindAlphaRate = 1.8; -/// How many particles the field holds at [zoom]. -/// -/// Rounded to a square because the web renderer allocates a square texture of -/// particle state and rounds to one; matching the count matters more than the -/// squareness, but rounding the same way keeps the two exactly equal. -int particleCountFor(double zoom) { - final edge = math.max( - 1, - math.sqrt(_logLerpStops(kWindParticles, zoom)).round(), - ); - return edge * edge; -} +/// How long the camera must hold still before its new view is adopted. +const Duration kWindSettle = Duration(milliseconds: 100); -/// Base line width at [zoom], interpolated between the reference's exact -/// integer stops and clamped to this layer's supported range. -double lineWidthFor(double zoom) { - final z = zoom.clamp(kWindZoomLo, kWindZoomHi); - final lo = z.floor() - kWindZoomLo.toInt(); - final hi = z.ceil() - kWindZoomLo.toInt(); - if (lo == hi) return kWindLineWidths[lo]; - final f = z - z.floor(); - return kWindLineWidths[lo] * (1 - f) + kWindLineWidths[hi] * f; +/// The zoom the tables are indexed by: the 256 px tile zoom, one above +/// MapLibre's 512 px one — kept fractional. +/// +/// Rounding it to the nearest entry makes the field jump by 1.6× each time +/// the zoom crosses a half: the framed-Taiwan view at 4.67 drew the density +/// meant for a whole zoom closer. Continuous, the count and both tables hit +/// their entries exactly at whole zooms and move smoothly between them. +double tileZoom(double zoom) => zoom + 1; + +/// [table] at a fractional [index], linear between entries, clamped at the +/// ends. +double _at(List table, double index) { + final i = index.clamp(0.0, table.length - 1.0); + final lo = i.floor(); + final hi = math.min(table.length - 1, lo + 1); + return table[lo] + (table[hi] - table[lo]) * (i - lo); } -/// Full rendered stroke width in logical pixels. +/// How many particles each of the sixteen blocks draws for a viewport of +/// [width] × [height] logical points. /// -/// The reference first computes `widthFactor = max(1, lineWidth × 1.3 × DPR)` -/// and then grows the antialiased quad by one physical pixel. Keeping that -/// final pixel in physical space matters: adding one logical pixel made the -/// same wind conspicuously heavier on high-density phones. -double pointSizeFor(double zoom, {double pixelRatio = 1}) { - final ratio = pixelRatio <= 0 ? 1 : pixelRatio; - final widthFactor = math.max( - 1.0, - lineWidthFor(zoom) * kWindParticleWidth * ratio, +/// `min(15000, w·h / (50 · 1.6^(z−2)))`, halved on a phone, as a +/// share of the pool. Proportional to area, so a tablet gets more particles +/// than a phone at the same zoom and both look equally dense. +int windParticlesPerBlock(double width, double height, double zoom) { + final z = tileZoom(zoom); + final amount = math.min( + 15000, + (width * height / (50 * math.pow(1.6, z - 2))).roundToDouble(), ); - return (widthFactor + 1) / ratio; + final relative = amount * kWindCountMul / (kWindStateEdge * kWindStateEdge); + const perBlock = kWindStateEdge * kWindStateEdge ~/ kWindBlocks; + return (relative * perBlock).round().clamp(1, perBlock); } -/// What fraction of the trail buffer survives each frame. -/// -/// This is the whole trail mechanism: nothing records where a particle has -/// been. Every frame the previous picture is redrawn at this opacity and the -/// current dots stamped on top, so a streak is the exponential decay of the -/// dots that came before — length falls out of the fade, and costs one draw -/// instead of one per remembered position. -/// -/// Never 1: a fade that does not fade accumulates for ever and the screen -/// saturates to white. -double fadeOpacityFor(double zoom) => _lerpStops(kWindFadeOpacity, zoom); +/// The stroke's full width in physical pixels: `max(1, lineWidth · 1.3 · +/// DPR)`, plus the one feathered pixel the quad grows by. +double windStrokeWidth(double zoom, double pixelRatio) => + math.max( + 1.0, + _at(kWindLineWidth, tileZoom(zoom)) * kWindParticleWidth * pixelRatio, + ) + + 1; + +/// Physical pixels a particle moves per second at full curve strength. +double windFullSpeed(double zoom, double pixelRatio) => + kWindSpeedPx * _at(kWindZoomToSpeed, tileZoom(zoom)) * pixelRatio; -/// Field-space distance a particle rides per (m/s · frame) at [zoom]. +/// The fraction of [windFullSpeed] a wind of [speed] m/s moves at. /// -/// It has to fall with zoom: a field-space step is a fraction of the *world*, -/// so the pixels it covers double with every zoom level in. Held constant at -/// the value that suits z3, particles at z7 move 23× too fast. -double fieldStepFor(double zoom) => - 0.0001 * _logLerpStops(kWindSpeedFactor, zoom); - -/// The web's `densityWeight`: how many particles a place should hold relative -/// to spreading them evenly, by how hard the wind is blowing there. -double densityWeight(double speed) { - final t = (speed / (0.6 * kWindSpeedScale)).clamp(0.0, 1.0); - final s = t * t * (3 - 2 * t); // smoothstep - return kWindDensityCalm + (kWindDensityStrong - kWindDensityCalm) * s; +/// `(speed / 30)^0.7`, floored at 1.5 m/s's share so a breeze still visibly +/// moves. Capped at 1: a byte-encoded velocity has no headroom past 30 m/s, +/// and uncapped, a typhoon core would overflow and skew the direction. +double windSpeedCurve(double speed) { + final o = speed / kWindMaxSpeed; + if (o * o < 1e-8) return 0; + return math + .max( + math.pow(o * o, kWindCurvePow / 2).toDouble(), + kWindMinSpeed / kWindMaxSpeed, + ) + .clamp(0.0, 1.0); } -/// The camera a wind overlay is drawn under — enough to project a lat/lng to -/// screen pixels on a tilt-free, bearing-rotated Web Mercator view. +/// The alpha a particle is drawn with at each frame of its life: in over the +/// first 20 % (power 0.9), out over the last 30 % (power 0.8). +List windAlphaLut() { + final fadeInEnd = (0.2 * kWindLifetime).round(); + final fadeOutFrom = kWindLifetime - (0.3 * kWindLifetime).round(); + return [ + for (var i = 0; i < kWindLifetime; i++) + if (i < fadeInEnd) + math.pow(i / fadeInEnd, 0.9).toDouble() + else if (i >= fadeOutFrom) + math + .pow((kWindLifetime - i) / (kWindLifetime - fadeOutFrom), 0.8) + .toDouble() + else + 1.0, + ]; +} + +// ------------------------------------------------------------------ camera + +/// A tilt-free, bearing-rotated Web Mercator camera over a viewport of +/// [width] × [height] logical points — what MapLibre hands a custom layer. class WindCamera { const WindCamera({ required this.centerLat, required this.centerLng, required this.zoom, required this.bearing, + this.width = 0, + this.height = 0, }); final double centerLat; final double centerLng; final double zoom; - /// Camera heading, degrees clockwise from north. + /// Camera heading, degrees clockwise from north. 90 puts east at the top. final double bearing; + final double width; + final double height; + + double get _world => 512 * math.pow(2.0, zoom).toDouble(); + double get _cx => (centerLng + 180) / 360 * _world; + double get _cy => mercatorY(centerLat) * _world; + + /// cos and sin of **minus** the bearing, which is how MapLibre rotates. + double get _cos => math.cos(-bearing * math.pi / 180); + double get _sin => math.sin(-bearing * math.pi / 180); + + /// Same scale, rotation and size: the two views differ by a pan only. + bool sameFrame(WindCamera o) => + (zoom - o.zoom).abs() < 1e-6 && + (bearing - o.bearing).abs() < 1e-6 && + width == o.width && + height == o.height; + @override bool operator ==(Object other) => other is WindCamera && other.centerLat == centerLat && other.centerLng == centerLng && other.zoom == zoom && - other.bearing == bearing; + other.bearing == bearing && + other.width == width && + other.height == height; @override - int get hashCode => Object.hash(centerLat, centerLng, zoom, bearing); -} - -/// The rectangle of the world a [Size] screen shows, as a lat/lng span. -class WindViewport { - const WindViewport({ - required this.westLng, - required this.eastLng, - required this.northLat, - required this.southLat, - }); - - final double westLng; - final double eastLng; - final double northLat; - final double southLat; + int get hashCode => + Object.hash(centerLat, centerLng, zoom, bearing, width, height); } -/// Web Mercator northing in `[0, 1]`, 0 at the north edge — the projection -/// every map in this app uses, kept identical to the web demo's `mercY`. +/// Web Mercator northing in `[0, 1]`, 0 at the north edge. double mercatorY(double lat) { final phi = lat.clamp(-85.051129, 85.051129) * math.pi / 180; return (1 - math.log(math.tan(math.pi / 4 + phi / 2)) / math.pi) / 2; @@ -220,471 +221,142 @@ double mercatorLat(double y) => 180 / math.pi; -/// Project a geographic point into the map view. The camera's target sits at -/// the viewport centre; tilt is always zero in this app (the map disables it), -/// so no perspective. -/// -/// The view rotates by **minus** [WindCamera.bearing], which is what MapLibre -/// itself does (`mat4.rotateZ(m, m, -this.bearingInRadians)` when it builds the -/// projection) and what the definition of bearing requires: a bearing of 90° -/// means *east is up*, so a point due east must land above the centre and north -/// must fall to the left. -/// -/// The sign was positive here, which is invisible at bearing 0 — the only -/// orientation anyone tests by eye — and puts the field at twice the bearing -/// off as soon as the map is turned. At 90° it drew the wind field upside down. -Offset projectLatLng(WindCamera cam, double lat, double lng, Size size) { - final world = _worldSize(cam.zoom); - final wx = (lng + 180) / 360 * world; - final wy = mercatorY(lat) * world; - final cx = (cam.centerLng + 180) / 360 * world; - final cy = mercatorY(cam.centerLat) * world; - // World x is cyclic, so fold the offset into the half-world nearest the - // camera. Callers do not always hand over a longitude already inside - // [-180, 180]: a particle's longitude is `lon0 + x · 360`, and ECMWF's grid - // starts at 180, which puts every one of its particles in [180, 540). Taking - // that difference literally throws them tens of thousands of pixels off - // screen, where they are judged out of view and recycled before they can draw - // anything. Folding also makes a point behind the antimeridian project onto - // the near edge rather than across the whole world, which is what a map - // centred at 121°E should do with 175°W. - final dx = _wrapWorld(wx - cx, world); - final dy = wy - cy; - final r = -cam.bearing * math.pi / 180; - final cosR = math.cos(r); - final sinR = math.sin(r); +/// Where a geographic point sits on screen, in logical points from the +/// top-left — MapLibre's own projection, written independently of the +/// functions below so they can be checked against it. +Offset projectLatLng(WindCamera cam, double lat, double lng) { + final world = cam._world; + var dx = (lng + 180) / 360 * world - cam._cx; + dx -= world * (dx / world + 0.5).floorToDouble(); + final dy = mercatorY(lat) * world - cam._cy; return Offset( - size.width / 2 + dx * cosR - dy * sinR, - size.height / 2 + dx * sinR + dy * cosR, + cam.width / 2 + dx * cam._cos - dy * cam._sin, + cam.height / 2 + dx * cam._sin + dy * cam._cos, ); } -/// The axis-aligned world rectangle a [Size] viewport shows under [cam] — -/// the bounds the simulation respawns particles into. Rotating the view makes -/// this a super-set of the visible area, which only wastes a few particles. -WindViewport viewportBounds(WindCamera cam, Size size) { - final world = _worldSize(cam.zoom); - final cx = (cam.centerLng + 180) / 360 * world; - final cy = mercatorY(cam.centerLat) * world; - // The exact inverse of [projectLatLng]'s rotation, so the bounds describe - // the region actually on screen. A mismatch here respawns particles outside - // the view, which drains the field from wherever the user is looking. - final r = -cam.bearing * math.pi / 180; - final cosR = math.cos(r); - final sinR = math.sin(r); - var minX = double.infinity, maxX = -double.infinity; - var minY = double.infinity, maxY = -double.infinity; - // The four corners of the screen, un-rotated back into world space. - for (final corner in const [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]) { - final dx = (corner.$1 - 0.5) * size.width; - final dy = (corner.$2 - 0.5) * size.height; - final wx = cx + dx * cosR + dy * sinR; - final wy = cy - dx * sinR + dy * cosR; - minX = math.min(minX, wx); - maxX = math.max(maxX, wx); - minY = math.min(minY, wy); - maxY = math.max(maxY, wy); - } - return WindViewport( - westLng: minX / world * 360 - 180, - eastLng: maxX / world * 360 - 180, - northLat: mercatorLat(minY / world), - southLat: mercatorLat(maxY / world), - ); +/// The lat/lng under a point of the viewport in *particle space* — 0..1 across +/// it, y up. `FRAG_PROJECT` / `projectFragment`: unrotate, then unproject. +({double lat, double lng}) particleToLatLng(WindCamera cam, Offset p) { + final dx = (p.dx * 2 - 1) * cam.width / 2; + final dy = (1 - p.dy * 2) * cam.height / 2; + final wx = cam._cx + dx * cam._cos + dy * cam._sin; + final wy = cam._cy - dx * cam._sin + dy * cam._cos; + final lng = wx / cam._world * 360 - 180; + return (lat: mercatorLat(wy / cam._world), lng: lng); } -double _worldSize(double zoom) => 512 * math.pow(2.0, zoom).toDouble(); +/// The wind (m/s, eastward [u], northward [v]) as the particle-space +/// displacement it becomes on screen: rotated into the view, y up. +Offset windToScreen(WindCamera cam, double u, double v) => + Offset(u * cam._cos + v * cam._sin, -u * cam._sin + v * cam._cos); + +/// One velocity texel, as `FRAG_PROJECT` / `projectFragment` writes it: +/// `(128 + 127 · direction · curve) / 255` per axis, rounded to a byte. +/// +/// Calm is exactly byte 128, which decodes to exactly zero. A `128 ± 128` +/// encoding cannot represent zero in eight bits, and drifts every particle +/// toward the top right by half a step. +(int, int) encodeWindTexel(WindCamera cam, double u, double v) { + final s = windToScreen(cam, u, v); + final speed = s.distance; + final mag = windSpeedCurve(speed); + if (mag == 0) return (128, 128); + int byte(double c) => (128 + 127 * c / speed * mag).round().clamp(0, 255); + return (byte(s.dx), byte(s.dy)); +} -/// Folds a world-space x offset into `(-world/2, world/2]`. -double _wrapWorld(double dx, double world) => - dx - world * (dx / world + 0.5).floorToDouble(); +/// What `FRAG_UPDATE` / `updateFragment` turns a velocity texel into: the +/// fraction of full speed along each axis. +Offset decodeWindTexel(int r, int g) => + Offset((r - 128) / 127, (g - 128) / 127); -/// One particle: where it is in the field, how fast the air there is moving, -/// and where that puts it on screen. +/// The texture coordinate the projection pass samples the grid at for a +/// point at [lat], [lng]. /// -/// Positions live in field space (x along the field's longitude span, y along -/// its latitude span) so advection is projection-free. Nothing remembers where -/// the particle has been — the streak behind it is the trail buffer's business, -/// not the particle's. +/// The half-texel term is what puts grid node `i` — the value at `lon0 + i · +/// dLon` — at the centre of texel `i`, where a linear sample returns it +/// exactly. Without it the particles ride a field half a cell (0.125°) west +/// and north of the raster drawn from the same data. +Offset windFieldUv({ + required double lat, + required double lng, + required double lat0, + required double lon0, + required double dLat, + required int width, + required int height, +}) { + final x = ((lng - lon0) / 360) % 1.0; + final y = (lat - lat0) / (dLat * height); + return Offset(x + 0.5 / width, y + 0.5 / height); +} + +// --------------------------------------------------------------- the trails + +/// Where a point of the reference viewport ([p] in particle space) is in +/// [cam]'s clip space — `refToClip` in both natives. /// -/// Screen position is two plain doubles plus a flag instead of a nullable -/// [Offset]: the simulation allocates nothing per particle per frame, and the -/// stamp pass has the coordinates it needs without unwrapping an object the -/// GC would have to collect. -class WindParticle { - WindParticle(this.x, this.y); - - double x; - double y; - - /// Speed of the air the particle last sampled, m/s — sets how bright its dot - /// is stamped, so faster air reads as a brighter streak. - double speed = 0; - - /// Screen x/y of the last step, valid only when [visible]. - double sx = 0; - double sy = 0; - - /// Whether the last step landed the particle inside the viewport, where it - /// has something to stamp. - bool visible = false; +/// The trails and the particles live in the viewport as it was when the +/// camera last settled ([ref]). While the camera moves they are drawn as one +/// quad placed by this mapping, so a pan or a pinch carries the field with the +/// map at the cost of three points per frame. The world shift folds the +/// reference onto the copy of the world nearest the camera, or a pan across +/// the antimeridian would throw the field a world away. +Offset referenceToClip(WindCamera ref, WindCamera cam, Offset p) { + final s = cam._world / ref._world; + final wrap = + cam._world * ((ref._cx * s - cam._cx) / cam._world).roundToDouble(); + final dx = (p.dx * 2 - 1) * ref.width / 2; + final dy = (1 - p.dy * 2) * ref.height / 2; + final ex = (ref._cx + dx * ref._cos + dy * ref._sin) * s - cam._cx - wrap; + final ey = (ref._cy - dx * ref._sin + dy * ref._cos) * s - cam._cy; + return Offset( + (ex * cam._cos - ey * cam._sin) / (cam.width / 2), + -(ex * cam._sin + ey * cam._cos) / (cam.height / 2), + ); } -/// The animation state — a population advected through a [WindField]. -class WindParticleSim { - /// [count] is only the starting population: [step] resizes it to whatever - /// the current zoom asks for, because the web renderer varies it from 6400 - /// at z3 down to 1024 at z7 and a fixed number matches neither end. - WindParticleSim(this.field, {int count = 6400, math.Random? random}) - : _random = random ?? math.Random(), - _uScale = (field.uMax - field.uMin) / 255, - _vScale = (field.vMax - field.vMin) / 255, - _mercY = _buildMercY(field), - _secLat = _buildSecLat(field), - particles = [for (var i = 0; i < count; i++) WindParticle(0, 0)]; - - final WindField field; - final math.Random _random; - final List particles; - - /// Quantised byte → component scales. A field never changes during a - /// simulation, so computing these inside every bilinear sample spent two - /// divisions per particle per frame on constants. - final double _uScale; - final double _vScale; - - /// Field-space y → mercator-y lookup, so the projection per particle per - /// frame is two array reads instead of a `log` + `tan`. - /// - /// The web's GPU shader evaluates the projection per vertex for free; the - /// CPU port would spend ~6000 transcendentals a frame on the same thing. - /// The LUT is built over this field's own latitude span (y ∈ [0, 1]) with - /// linear interpolation; its error stays under a pixel at any zoom this - /// layer can show. - final Float64List _mercY; - - /// Field-space y → 1/cos(latitude) — the longitude-step correction - /// ([step]'s `u / cos(lat)` term). The latitude axis is the same linear - /// span as [_mercY], so the same table shape and interpolant serve it. - final Float64List _secLat; - - static const int _mercYEntries = 1024; - - bool _seeded = false; - _StepGeometry? _geometry; - - /// Moves every particle one frame: sample the wind, step, work out where - /// that lands on screen, and recycle the ones that have left. - /// - /// The view's projection is shared by the whole population, so its rotation - /// and origin are computed here once and the loop inlines the same arithmetic - /// [projectLatLng] does per point — six thousand `sin`/`cos` calls a frame - /// become two. - void step(WindCamera cam, Size size) { - final geometry = _geometryFor(cam, size); - final fieldSpace = geometry.fieldSpace; - if (!_seeded) { - _seedIn(fieldSpace); - _seeded = true; - } - _resize(geometry.particleCount, fieldSpace); - - // Local copies keep the particle loop on stack values while the cache - // avoids rebuilding the same viewport projection on every stationary - // frame. - final fieldStep = geometry.fieldStep; - final world = geometry.world; - final cx = geometry.cx; - final cy = geometry.cy; - final cosR = geometry.cosR; - final sinR = geometry.sinR; - final halfWidth = geometry.halfWidth; - final halfHeight = geometry.halfHeight; - final xLo = geometry.xLo; - final xHi = geometry.xHi; - final yLo = geometry.yLo; - final yHi = geometry.yHi; - final xOffset = geometry.xOffset; - final mercY = _mercY; - final secLat = _secLat; - - for (final p in particles) { - final (u, v) = _sampleUV(p.x, p.y); - p.speed = math.sqrt(u * u + v * v); - // 1/cos(latitude) comes from a LUT (see [_secLat]) — a `cos` per - // particle per frame was 6400 transcendentals on this same loop. - p.x = (p.x + u * _lutAt(secLat, p.y) * fieldStep) % 1.0; - p.y -= v * fieldStep; - - // Off the grid is nothing to stamp: mark it invisible and recycle - // without paying for a projection nobody will see. - if (p.y < 0 || p.y > 1) { - p.visible = false; - _respawn(p, fieldSpace); - continue; - } - - final dx = _wrapWorld(xOffset + p.x * world - cx, world); - final dy = _lutAt(mercY, p.y) * world - cy; - p.sx = halfWidth + dx * cosR - dy * sinR; - p.sy = halfHeight + dx * sinR + dy * cosR; - final inView = p.sx >= xLo && p.sx <= xHi && p.sy >= yLo && p.sy <= yHi; - p.visible = inView; - - // Recycle a particle that has left, and occasionally a healthy one — the - // field would otherwise empty out of wherever the density weighting is - // not putting anything back. - if (!inView || _random.nextDouble() < kWindDropRate) { - _respawn(p, fieldSpace); - } - } - } - - /// Projection values shared by every particle and unchanged while the map - /// camera and viewport size stay still. In the common stationary case this - /// removes `pow`, `log`, `tan`, `exp`, `atan`, `sin`, `cos`, four-corner - /// bounds work, and two short-lived objects from every animation frame. - _StepGeometry _geometryFor(WindCamera cam, Size size) { - final cached = _geometry; - if (cached != null && cached.camera == cam && cached.size == size) { - return cached; - } - - final world = _worldSize(cam.zoom); - final r = -cam.bearing * math.pi / 180; - final geometry = _StepGeometry( - camera: cam, - size: size, - fieldSpace: _fieldRect(viewportBounds(cam, size)), - particleCount: particleCountFor(cam.zoom), - fieldStep: fieldStepFor(cam.zoom), - world: world, - cx: (cam.centerLng + 180) / 360 * world, - cy: mercatorY(cam.centerLat) * world, - cosR: math.cos(r), - sinR: math.sin(r), - halfWidth: size.width / 2, - halfHeight: size.height / 2, - xLo: -0.1 * size.width, - xHi: 1.1 * size.width, - yLo: -0.1 * size.height, - yHi: 1.1 * size.height, - xOffset: (field.lon0 + 180) / 360 * world, - ); - _geometry = geometry; - return geometry; - } - - /// Grows or shrinks the population to [count], seeding anything new into the - /// current view so a zoom does not open a hole in the field. - void _resize(int count, _FieldRect r) { - if (!_seeded || count == particles.length) return; - if (count < particles.length) { - particles.removeRange(count, particles.length); - return; - } - while (particles.length < count) { - particles.add( - WindParticle( - _lerpX(r, _random.nextDouble()), - _lerp(r.y0, r.y1, _random.nextDouble()), - ), - ); - } - } - - static Float64List _buildMercY(WindField field) { - final lut = Float64List(_mercYEntries); - final latScale = field.dLat * field.height; - for (var i = 0; i < _mercYEntries; i++) { - lut[i] = mercatorY(field.lat0 + i / (_mercYEntries - 1) * latScale); - } - return lut; - } - - static Float64List _buildSecLat(WindField field) { - final lut = Float64List(_mercYEntries); - final latScale = field.dLat * field.height; - const degToRad = math.pi / 180; - for (var i = 0; i < _mercYEntries; i++) { - lut[i] = - 1 / - math.cos( - (field.lat0 + i / (_mercYEntries - 1) * latScale) * degToRad, - ); - } - return lut; - } - - /// Linear interpolation into a field-space-y LUT ([_buildMercY] / - /// [_buildSecLat]). Out-of-range y (a particle about to respawn) extends - /// the edge value linearly — the same way the raw trig it replaces behaved. - static double _lutAt(Float64List lut, double y) { - final t = y * (_mercYEntries - 1); - final i = math.min(_mercYEntries - 2, math.max(0, t.floor())); - final f = t - i; - return lut[i] + (lut[i + 1] - lut[i]) * f; - } - - /// The viewport's rectangle in field space (fractions of the field's - /// longitude / latitude span), for seeding and respawning. - _FieldRect _fieldRect(WindViewport vp) { - final latSpan = field.dLat * field.height; - final x0 = ((vp.westLng - field.lon0) / 360) % 1.0; - var x1 = ((vp.eastLng - field.lon0) / 360) % 1.0; - // Wrapping the two edges independently is what turns a viewport straddling - // the grid's seam into its own complement — west 0.98, east 0.02 describes - // the 96% of the world the viewer cannot see. Carrying the east edge past - // the seam keeps the interval the one that was meant; [_lerpX] folds the - // result back afterwards. ECMWF's seam is the antimeridian, so this is the - // difference between working and not for anyone looking at the Pacific. - if (x1 < x0) x1 += 1.0; - return _FieldRect( - x0: x0, - x1: x1, - y0: (vp.northLat - field.lat0) / latSpan, - y1: (vp.southLat - field.lat0) / latSpan, - ); - } - - /// A point drawn from a longitude interval that may reach past the seam, - /// folded back into the field's own `[0, 1)`. - double _lerpX(_FieldRect r, double t) => _lerp(r.x0, r.x1, t) % 1.0; - - void _seedIn(_FieldRect r) { - for (final p in particles) { - p.x = _lerpX(r, _random.nextDouble()); - p.y = _lerp(r.y0, r.y1, _random.nextDouble()); - } - } - - /// Rejection sampling by wind speed, weighted by [densityWeight]. - /// - /// One candidate, accepted with probability proportional to its weight, and - /// on rejection the particle simply stays where it is and tries again next - /// frame. Not best-of-N: when every candidate lands in calm air, best-of-N - /// returns calm whatever the weights say, which caps the reachable contrast - /// near three to one — nowhere near the eleven asked for here. Rejection has - /// no such ceiling, because the equilibrium density *is* the acceptance - /// probability: inflow to a region is its area times that probability, and - /// the lifetime is the same everywhere. - /// - /// A particle that had already left the view just stays invisible a few more - /// frames, which at sixty a second is nothing. - void _respawn(WindParticle p, _FieldRect r) { - final x = _lerpX(r, _random.nextDouble()); - final y = _lerp(r.y0, r.y1, _random.nextDouble()); - final (u, v) = _sampleUV(x, y); - final weight = densityWeight(math.sqrt(u * u + v * v)); - if (_random.nextDouble() < - weight / math.max(kWindDensityCalm, kWindDensityStrong)) { - p.x = x; - p.y = y; - } - } - - /// Bilinear sample of both wind components at field-space [x], [y] in one - /// pass — the four-cell geometry (which cells, how much of each) is shared by - /// u and v, so two separate lookups would redo every floor, wrap, and lerp - /// once per component. This is the hand-rolled texture sample the web shader - /// does (`lookup_wind`), reading the two quantised planes directly. - (double, double) _sampleUV(double x, double y) { - final fx = x * field.width; - final fy = y * field.height; - // Columns wrap — the grid's last column neighbours its first, and clamping - // there flattens the wind along the whole seam. Rows do not: there is no - // cell north of the north pole. - final fxFloor = fx.floor(); - final i0 = fxFloor % field.width; - final i1 = (i0 + 1) % field.width; - var j0 = fy.floor(); - if (j0 < 0) j0 = 0; - if (j0 >= field.height) j0 = field.height - 1; - final j1 = j0 + 1 < field.height ? j0 + 1 : j0; - final tx = fx - fxFloor.toDouble(); - final ty = (fy - j0).clamp(0.0, 1.0); - final row0 = j0 * field.width; - final row1 = j1 * field.width; - return ( - _lerpPlane(field.u, row0, row1, i0, i1, tx, ty, field.uMin, _uScale), - _lerpPlane(field.v, row0, row1, i0, i1, tx, ty, field.vMin, _vScale), - ); - } - - /// One plane's bilinear interpolation over the shared cell geometry. - double _lerpPlane( - Uint8List plane, - int row0, - int row1, - int i0, - int i1, - double tx, - double ty, - double lo, - double scale, - ) { - final a = plane[row0 + i0]; - final b = plane[row0 + i1]; - final c = plane[row1 + i0]; - final d = plane[row1 + i1]; - final top = a + (b - a) * tx; - final bottom = c + (d - c) * tx; - return lo + (top + (bottom - top) * ty) * scale; - } - - double _lerp(double a, double b, double t) => a + (b - a) * t; +/// What the camera coming to rest does to the field. +sealed class WindSettle { + const WindSettle(); } -class _StepGeometry { - const _StepGeometry({ - required this.camera, - required this.size, - required this.fieldSpace, - required this.particleCount, - required this.fieldStep, - required this.world, - required this.cx, - required this.cy, - required this.cosR, - required this.sinR, - required this.halfWidth, - required this.halfHeight, - required this.xLo, - required this.xHi, - required this.yLo, - required this.yHi, - required this.xOffset, - }); +/// The camera is where the field already is. +final class WindStay extends WindSettle { + const WindStay(); +} + +/// A pan: the trails and the particles move by [shift] (particle space, +/// whole trail texels) into the new view, so nothing visibly jumps. +final class WindShift extends WindSettle { + const WindShift(this.shift); - final WindCamera camera; - final Size size; - final _FieldRect fieldSpace; - final int particleCount; - final double fieldStep; - final double world; - final double cx; - final double cy; - final double cosR; - final double sinR; - final double halfWidth; - final double halfHeight; - final double xLo; - final double xHi; - final double yLo; - final double yHi; - final double xOffset; + final Offset shift; } -class _FieldRect { - const _FieldRect({ - required this.x0, - required this.x1, - required this.y0, - required this.y1, - }); +/// A zoom, a rotation or a resize: the old trails no longer line up with +/// anything, so they are cleared and grow back. +final class WindClear extends WindSettle { + const WindClear(); +} - final double x0; - final double x1; - final double y0; - final double y1; +/// `settleCamera` in both natives, for a camera that has held still at [cam] +/// while the field's reference is [ref], over a trail texture of +/// [trailWidth] × [trailHeight]. +WindSettle windSettle( + WindCamera ref, + WindCamera cam, { + required int trailWidth, + required int trailHeight, +}) { + if (cam == ref) return const WindStay(); + if (!cam.sameFrame(ref)) return const WindClear(); + final o = referenceToClip(ref, cam, Offset.zero); + return WindShift( + Offset( + ((o.dx + 1) * 0.5 * trailWidth).round() / trailWidth, + ((o.dy + 1) * 0.5 * trailHeight).round() / trailHeight, + ), + ); } diff --git a/pubspec.lock b/pubspec.lock index ceea73715..40d629014 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -721,29 +721,29 @@ packages: dependency: "direct main" description: path: maplibre_gl - ref: "48bce744277a96a5ed5d062f9f632dbfa06e797f" - resolved-ref: "48bce744277a96a5ed5d062f9f632dbfa06e797f" + ref: a368208e5c2c6262ba43a883da2f0768f1376c60 + resolved-ref: a368208e5c2c6262ba43a883da2f0768f1376c60 url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git - version: "0.26.2" + version: "0.27.1" maplibre_gl_platform_interface: dependency: "direct main" description: path: maplibre_gl_platform_interface - ref: "48bce744277a96a5ed5d062f9f632dbfa06e797f" - resolved-ref: "48bce744277a96a5ed5d062f9f632dbfa06e797f" + ref: a368208e5c2c6262ba43a883da2f0768f1376c60 + resolved-ref: a368208e5c2c6262ba43a883da2f0768f1376c60 url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git - version: "0.26.2" + version: "0.27.1" maplibre_gl_web: dependency: "direct overridden" description: path: maplibre_gl_web - ref: "48bce744277a96a5ed5d062f9f632dbfa06e797f" - resolved-ref: "48bce744277a96a5ed5d062f9f632dbfa06e797f" + ref: a368208e5c2c6262ba43a883da2f0768f1376c60 + resolved-ref: a368208e5c2c6262ba43a883da2f0768f1376c60 url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git" source: git - version: "0.26.2" + version: "0.27.1" markdown: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0c0b523f7..1ea006d22 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,28 +69,28 @@ dependencies: logging: ^1.3.0 # ExpTech fork — HTTPS intercept + Dart tile-cache bridge + Android gzip fix # + render lifecycle, batched layer updates, and compact tile-cache messages. - # Requires MapLibre Native 6.27.0 (ios/vendor/maplibre). + # Requires MapLibre Native 6.28.0, which the fork's Package.swift pins exactly. # https://github.com/ExpTechTW/flutter-maplibre-gl # Pin a commit (not floating `main`) so CI/`pub get` can't rewrite # pubspec.lock. Copy `pubspec_overrides.example.yaml` for a local fork; the # resulting `pubspec_overrides.yaml` stays gitignored, and any lockfile churn # from using it must be discarded. # The fork's controller.dart calls `setRenderPaused`, which exists only in - # the fork's interface/web packages — pub.dev's 0.26.2 lacks it. The - # overrides below force those two siblings (declared hosted ^0.26.2 inside + # the fork's interface/web packages — pub.dev's 0.27.1 lacks it. The + # overrides below force those two siblings (declared hosted ^0.27.1 inside # the fork) to resolve from the same fork commit, so `setRenderPaused` # exists on every platform. maplibre_gl: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl - ref: 48bce744277a96a5ed5d062f9f632dbfa06e797f + ref: a368208e5c2c6262ba43a883da2f0768f1376c60 # Direct (not just override) so tests can import the platform interface. maplibre_gl_platform_interface: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl_platform_interface - ref: 48bce744277a96a5ed5d062f9f632dbfa06e797f + ref: a368208e5c2c6262ba43a883da2f0768f1376c60 # LoRa mesh (Meshtastic) over BLE — off-grid emergency messaging. Requires # Bluetooth + location permissions (Android manifest / iOS Info.plist below). # Vendored (third_party/) with two upstream fixes: requestMtu is skipped off @@ -154,7 +154,7 @@ dev_dependencies: dependency_overrides: # Same fork commit as the direct git dependencies above — the fork's web - # package declares the interface as hosted ^0.26.2, which would resolve to + # package declares the interface as hosted ^0.27.1, which would resolve to # pub.dev (no setRenderPaused) and break compilation. Inline overrides live # in pubspec.yaml (not the gitignored pubspec_overrides.yaml) so CI's plain # `pub get` resolves identically and pubspec.lock stays stable. @@ -162,12 +162,12 @@ dependency_overrides: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl_platform_interface - ref: 48bce744277a96a5ed5d062f9f632dbfa06e797f + ref: a368208e5c2c6262ba43a883da2f0768f1376c60 maplibre_gl_web: git: url: https://github.com/ExpTechTW/flutter-maplibre-gl.git path: maplibre_gl_web - ref: 48bce744277a96a5ed5d062f9f632dbfa06e797f + ref: a368208e5c2c6262ba43a883da2f0768f1376c60 flutter: config: diff --git a/test/features/map/presentation/layers/wind_forecast_chrome_test.dart b/test/features/map/presentation/layers/wind_forecast_chrome_test.dart index 2242575ea..e143f834f 100644 --- a/test/features/map/presentation/layers/wind_forecast_chrome_test.dart +++ b/test/features/map/presentation/layers/wind_forecast_chrome_test.dart @@ -1,6 +1,5 @@ /// A wind-forecast layer names its model, caps the camera at the published -/// grid, and draws the shared speed legend. A gesture flags the particle -/// field as interacting for the whole finger-down. +/// grid, and draws the shared speed legend. library; import 'package:dpip/core/error/failure.dart'; @@ -32,9 +31,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); for (final model in WindForecastModel.values) { - testWidgets('${model.key} identity, zoom cap, legend, and gesture flag', ( - tester, - ) async { + testWidgets('${model.key} identity, zoom cap, and legend', (tester) async { final l10n = await AppLocalizations.delegate.load(const Locale('en')); final layer = WindForecastMapLayer( _WindRepo(), @@ -48,11 +45,6 @@ void main() { expect(layer.rasterBelowLayerId, townLabelLayerId); expect(layer.overlayFollowsCamera, isFalse); expect(layer.field.value, isNull); - expect(layer.interacting.value, isFalse); - layer.onMapGestureStart(); - expect(layer.interacting.value, isTrue); - layer.onMapGestureEnd(); - expect(layer.interacting.value, isFalse); await tester.pumpWidget( MaterialApp( diff --git a/test/features/map/wind_particle_native_test.dart b/test/features/map/wind_particle_native_test.dart index 6e46318b6..6bee17950 100644 --- a/test/features/map/wind_particle_native_test.dart +++ b/test/features/map/wind_particle_native_test.dart @@ -1,5 +1,4 @@ import 'package:dpip/features/map/presentation/layers/wind_particle_native.dart'; -import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; import 'package:dpip/features/weather/domain/wind_field.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -33,55 +32,6 @@ Uint8List _wnd1({int width = 4, int height = 3, String model = 'ecmwf'}) { } void main() { - group('the tuning wire', () { - test('carries every curve the simulation defines', () { - final tuning = windParticleTuning(); - expect(tuning['zoomLo'], kWindZoomLo); - expect(tuning['zoomHi'], kWindZoomHi); - expect(tuning['particlesLo'], kWindParticles.$1); - expect(tuning['particlesHi'], kWindParticles.$2); - for (var i = 0; i < kWindLineWidths.length; i++) { - expect(tuning['lineWidthZ${i + 3}'], kWindLineWidths[i]); - } - expect(tuning['particleWidth'], kWindParticleWidth); - expect(tuning['speedFactorLo'], kWindSpeedFactor.$1); - expect(tuning['speedFactorHi'], kWindSpeedFactor.$2); - expect(tuning['fadeOpacityLo'], kWindFadeOpacity.$1); - expect(tuning['fadeOpacityHi'], kWindFadeOpacity.$2); - expect(tuning['dropRate'], kWindDropRate); - expect(tuning['densityCalm'], kWindDensityCalm); - expect(tuning['densityStrong'], kWindDensityStrong); - expect(tuning['speedScale'], kWindSpeedScale); - }); - - test('sends endpoints, never a per-zoom evaluation', () { - final tuning = windParticleTuning(); - // Every curve must arrive as its two ends. A single value would mean - // Dart evaluated it — which puts a platform call on the camera path and - // leaves two copies of the curve to drift apart. - for (final base in const ['particles', 'speedFactor', 'fadeOpacity']) { - expect(tuning, contains('${base}Lo'), reason: base); - expect(tuning, contains('${base}Hi'), reason: base); - expect( - tuning.containsKey(base), - isFalse, - reason: '$base must not be pre-evaluated', - ); - } - }); - - test('every value is finite — a NaN would silently stop the field', () { - for (final entry in windParticleTuning().entries) { - expect(entry.value.isFinite, isTrue, reason: entry.key); - } - }); - - test('the point size scales with the device pixel ratio', () { - expect(windParticleTuning(pixelRatio: 3)['pixelRatio'], 3); - expect(windParticleTuning()['pixelRatio'], 1); - }); - }); - group('the field wire', () { test('hands over the undecoded body and the header beside it', () { final bytes = _wnd1(); @@ -150,7 +100,6 @@ void main() { group('platform support', () { WindParticleNative build(TargetPlatform platform) => WindParticleNative( field: ValueNotifier(null), - interacting: ValueNotifier(false), platform: platform, ); @@ -188,28 +137,10 @@ void main() { group('render-loop gating', () { final field = WindField.fromWnd1(_wnd1()); - test('runs only for a visible decoded field with a stable camera', () { - expect( - windParticleShouldPlay(visible: true, interacting: false, field: field), - isTrue, - ); - expect( - windParticleShouldPlay(visible: true, interacting: true, field: field), - isFalse, - reason: 'camera gestures pause GPU state and prevent projection jumps', - ); - expect( - windParticleShouldPlay( - visible: false, - interacting: false, - field: field, - ), - isFalse, - ); - expect( - windParticleShouldPlay(visible: true, interacting: false, field: null), - isFalse, - ); + test('runs for a visible decoded field, gestures or not', () { + expect(windParticleShouldPlay(visible: true, field: field), isTrue); + expect(windParticleShouldPlay(visible: false, field: field), isFalse); + expect(windParticleShouldPlay(visible: true, field: null), isFalse); }); }); } diff --git a/test/features/map/wind_particle_sim_test.dart b/test/features/map/wind_particle_sim_test.dart index 075b11e58..b3099fb34 100644 --- a/test/features/map/wind_particle_sim_test.dart +++ b/test/features/map/wind_particle_sim_test.dart @@ -1,333 +1,339 @@ +/// The numbers and signs the two native wind renderers must share. +/// +/// What fails quietly here is direction and placement. A flipped sign draws a +/// perfectly smooth field blowing the wrong way, and a trail quad placed +/// half a cell off looks like weather. So every convention is checked against +/// an independent statement of it: MapLibre's projection for placement, and +/// "east is right, north is up, bearing 90 puts east at the top" for +/// direction. +library; + import 'dart:math' as math; -import 'dart:typed_data'; import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; -import 'package:dpip/features/weather/domain/wind_field.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -/// A 2×2 global grid; [uValue] / [vValue] set every cell's quantised plane -/// (so all cells carry the same wind), or -1 for a full eastward gradient. -WindField _field({int uValue = 128, int vValue = 128, double lon0 = 0}) => - WindField( - width: 2, - height: 2, - lat0: 90, - lon0: lon0, - dLat: -90, - dLon: 180, - uMin: -1, - uMax: 1, - vMin: -1, - vMax: 1, - timeMs: 0, - model: 'gfs', - u: Uint8List.fromList([uValue, uValue, uValue, uValue]), - v: Uint8List.fromList([vValue, vValue, vValue, vValue]), - ); +const _taipei = WindCamera( + centerLat: 25.03, + centerLng: 121.56, + zoom: 5, + bearing: 0, + width: 393, + height: 852, +); -/// A wind field whose every cell blows due east at ~1 m/s. -WindField _eastField() => _field(uValue: 255, vValue: 128); - -const _camera = WindCamera(centerLat: 25, centerLng: 121, zoom: 5, bearing: 0); - -/// The latitude under a screen point — the inverse of [projectLatLng]'s -/// rotation, written independently here so the test does not simply repeat the -/// code it is checking. -double _unprojectLat(WindCamera cam, Offset point, Size size) { - final world = 512 * math.pow(2.0, cam.zoom).toDouble(); - final dx = point.dx - size.width / 2; - final dy = point.dy - size.height / 2; - final r = -cam.bearing * math.pi / 180; - // Inverse rotation: transpose of the forward one. - final wy = -dx * math.sin(r) + dy * math.cos(r); - final cy = mercatorY(cam.centerLat) * world; - return mercatorLat((cy + wy) / world); -} +WindCamera _with( + WindCamera c, { + double? lat, + double? lng, + double? zoom, + double? bearing, +}) => WindCamera( + centerLat: lat ?? c.centerLat, + centerLng: lng ?? c.centerLng, + zoom: zoom ?? c.zoom, + bearing: bearing ?? c.bearing, + width: c.width, + height: c.height, +); -const _size = Size(1000, 1000); +/// Clip space (y up, −1..1) to MapLibre's logical points (y down). +Offset _clipToScreen(WindCamera cam, Offset clip) => + Offset((clip.dx + 1) / 2 * cam.width, (1 - clip.dy) / 2 * cam.height); + +Matcher _near(Offset expected, [double tolerance = 1e-6]) => predicate( + (o) => (o - expected).distance < tolerance, + 'within $tolerance of $expected', +); void main() { - group('projectLatLng', () { - test('the camera target lands on the viewport centre', () { - final p = projectLatLng(_camera, 25, 121, _size); - expect(p.dx, closeTo(500, 0.001)); - expect(p.dy, closeTo(500, 0.001)); + group('the tuning tables', () { + test('tile zoom is MapLibre zoom plus one, not rounded', () { + expect(tileZoom(3), 4); + expect(tileZoom(4.67), closeTo(5.67, 1e-12)); + }); + + test('the stroke is lineWidth · 1.3 · DPR plus a feathered pixel', () { + // z5 → tile zoom 6 → 1.8. + expect(windStrokeWidth(5, 3), closeTo(1.8 * 1.3 * 3 + 1, 1e-9)); + // Never thinner than one pixel plus the feather. + expect(windStrokeWidth(-3, 1), 2); + }); + + test('particles slow down zoomed out', () { + expect(windFullSpeed(3, 1), 100 * 0.7); + expect(windFullSpeed(7, 1), 100 * 1.0); + expect(windFullSpeed(5, 2.625), closeTo(100 * 0.9 * 2.625, 1e-9)); + }); + + test('the count follows the viewport area, halved on mobile', () { + // 393 × 852 at z5 (tile zoom 6): 334,836 / (50 · 1.6^4) = 1022 → 511 + // particles, 32 per block. + expect(windParticlesPerBlock(393, 852, 5), 32); + // Four times the area, four times the particles. + expect(windParticlesPerBlock(786, 1704, 5), 128); + // One zoom in, 1.6× fewer. + expect(windParticlesPerBlock(393, 852, 6), 20); }); - test('a point north of the target sits above it', () { - final p = projectLatLng(_camera, 25.1, 121, _size); - expect(p.dy, lessThan(500)); - expect(p.dx, closeTo(500, 0.001)); + test('between whole zooms the field changes smoothly, not by 1.6×', () { + // Rounding made 4.49 → 4.51 a 1.6× drop in particles. + final below = windParticlesPerBlock(412, 924, 4.49); + final above = windParticlesPerBlock(412, 924, 4.51); + expect(below / above, closeTo(1, 0.03)); + expect( + windStrokeWidth(4.49, 2.625), + closeTo(windStrokeWidth(4.51, 2.625), 0.05), + ); + expect(windFullSpeed(4.49, 1), closeTo(windFullSpeed(4.51, 1), 0.5)); + // The framed-Taiwan view: between its two whole-zoom neighbours. + final framed = windParticlesPerBlock(412, 924, 4.67); + expect(framed, lessThan(windParticlesPerBlock(412, 924, 4))); + expect(framed, greaterThan(windParticlesPerBlock(412, 924, 5))); + // Tile zoom 5.67: two thirds of the way from width 1.6 to 1.8. + expect( + windStrokeWidth(4.67, 1), + closeTo((1.6 + 0.67 * 0.2) * 1.3 + 1, 1e-9), + ); }); - test('a bearing of 90 puts east up and north to the left', () { - // What "bearing" means: the compass direction that is *up*. MapLibre - // builds its projection with `rotateZ(-bearing)` for exactly this, and - // this projection has to agree with it or the field is drawn at twice - // the bearing off — which at 90° is upside down. - // - // This test used to assert the opposite (north to the right) because it - // was written from the code rather than from the definition, so the sign - // error it was meant to catch read as correct for as long as nobody - // rotated the map. - const cam = WindCamera(centerLat: 0, centerLng: 0, zoom: 2, bearing: 90); - final north = projectLatLng(cam, 1, 0, _size); - expect(north.dx, lessThan(500), reason: 'north belongs on the left'); - expect(north.dy, closeTo(500, 0.001)); - - final east = projectLatLng(cam, 0, 1, _size); - expect(east.dy, lessThan(500), reason: 'east is up at bearing 90'); - expect(east.dx, closeTo(500, 0.001)); + test('the count saturates at 15,000 and never falls below one a block', () { + expect(windParticlesPerBlock(1e5, 1e5, 0), (15000 * 0.5 / 16).round()); + expect(windParticlesPerBlock(1, 1, 20), 1); }); - test('a bearing of 180 turns the map upside down', () { - const cam = WindCamera(centerLat: 0, centerLng: 0, zoom: 2, bearing: 180); - final north = projectLatLng(cam, 1, 0, _size); - expect(north.dy, greaterThan(500), reason: 'north is down at 180'); - expect(north.dx, closeTo(500, 0.001)); + test('a particle fades in over 20 % of its life and out over 30 %', () { + final lut = windAlphaLut(); + expect(lut, hasLength(128)); + expect(lut.first, 0); + expect(lut[26], 1, reason: 'fully in after round(0.2 · 128) frames'); + // The fade-out spans round(0.3 · 128) = 38 frames and starts at 1. + expect(lut[90], 1); + expect(lut[91], lessThan(1)); + expect(lut.last, greaterThan(0)); + for (var i = 1; i < 26; i++) { + expect(lut[i], greaterThan(lut[i - 1]), reason: 'rising at $i'); + } }); - test('every bearing agrees with the bounds it is solved against', () { - // [viewportBounds] un-rotates the screen corners to decide where to - // respawn. If it turns the other way from [projectLatLng], particles are - // seeded outside the view and the field drains from where the user is - // looking — a rotation-only failure, invisible north-up. - for (final bearing in [0.0, 30.0, 45.0, 90.0, 135.0, 200.0, 315.0]) { - final cam = WindCamera( - centerLat: 23.5, - centerLng: 121, - zoom: 4, - bearing: bearing, + test( + 'the trail fade is 0.97, and the lift hides what 8 bits cannot fade', + () { + expect(kWindFade, 0.97); + // The darkest value an 8-bit multiply by 0.97 can no longer reduce. + var stuck = 255; + while ((stuck * kWindFade).round() < stuck) { + stuck--; + } + expect( + stuck / 255 * kWindCompositeGain + kWindCompositeLift, + lessThan(0), ); - final vp = viewportBounds(cam, _size); - // The centre of the bounds must project near the centre of the screen. - final middle = projectLatLng( + }, + ); + }); + + group('the speed curve', () { + test('(speed / 30)^0.7 of full speed', () { + expect(windSpeedCurve(30), 1); + expect(windSpeedCurve(10), closeTo(math.pow(1 / 3, 0.7), 1e-12)); + }); + + test('a breeze moves at no less than 1.5 m/s would', () { + expect(windSpeedCurve(0.2), closeTo(1.5 / 30, 1e-12)); + }); + + test('dead calm does not move at all', () { + expect(windSpeedCurve(0), 0); + }); + + test('a typhoon core is capped, not overflowed', () { + expect(windSpeedCurve(55), 1); + }); + }); + + group('direction', () { + const north = _taipei; + final east = _with(_taipei, bearing: 90); + + test('north-up: east is right, north is up', () { + expect(windToScreen(north, 10, 0), _near(const Offset(10, 0))); + expect(windToScreen(north, 0, 10), _near(const Offset(0, 10))); + }); + + test('bearing 90: east is up, north is left', () { + expect(windToScreen(east, 10, 0), _near(const Offset(0, 10))); + expect(windToScreen(east, 0, 10), _near(const Offset(-10, 0))); + }); + + test('agrees with where the map puts those directions', () { + for (final cam in [north, east, _with(_taipei, bearing: 237)]) { + final here = projectLatLng(cam, cam.centerLat, cam.centerLng); + final eastward = projectLatLng(cam, cam.centerLat, cam.centerLng + 0.1); + final northward = projectLatLng( cam, - (vp.northLat + vp.southLat) / 2, - (vp.westLng + vp.eastLng) / 2, - _size, + cam.centerLat + 0.1, + cam.centerLng, ); + // Screen y is down; particle space is up. + Offset up(Offset d) => Offset(d.dx, -d.dy) / d.distance; expect( - middle.dx, - closeTo(_size.width / 2, _size.width / 2), - reason: 'bearing $bearing puts the bounds off screen horizontally', + windToScreen(cam, 1, 0), + _near(up(eastward - here), 1e-3), + reason: 'east at bearing ${cam.bearing}', ); expect( - middle.dy, - closeTo(_size.height / 2, _size.height / 2), - reason: 'bearing $bearing puts the bounds off screen vertically', + windToScreen(cam, 0, 1), + _near(up(northward - here), 1e-3), + reason: 'north at bearing ${cam.bearing}', ); - // And every screen corner must fall inside the bounds it produced. - for (final corner in const [ - Offset(0, 0), - Offset(1000, 0), - Offset(0, 1000), - Offset(1000, 1000), - ]) { - final lat = _unprojectLat(cam, corner, _size); - expect( - lat, - inInclusiveRange(vp.southLat - 1e-6, vp.northLat + 1e-6), - reason: 'bearing $bearing: a visible corner is outside the bounds', - ); - } - } - }); - - test('a longitude expressed past 180 lands where its wrap does', () { - // Particle longitudes are `lon0 + x · 360`, so a grid whose first column - // is at 180 — ECMWF's — never produces one inside [-180, 180]. - final home = projectLatLng(_camera, 25, 121, _size); - for (final turns in [-2.0, -1.0, 1.0, 2.0]) { - final p = projectLatLng(_camera, 25, 121 + 360 * turns, _size); - expect(p.dx, closeTo(home.dx, 1e-6), reason: '121° + $turns turns'); - expect(p.dy, closeTo(home.dy, 1e-6), reason: '121° + $turns turns'); } }); + }); - test('a point past the antimeridian projects onto the near edge', () { - // Seen from 121°E, 175°W is 64° east, not 296° west. - final cam = WindCamera(centerLat: 0, centerLng: 121, zoom: 2, bearing: 0); - final p = projectLatLng(cam, 0, -175, _size); - final world = 512 * 4.0; - expect(p.dx - 500, closeTo(64 / 360 * world, 1e-6)); + group('the velocity texel', () { + test('calm is exactly byte 128 and decodes to exactly zero', () { + expect(encodeWindTexel(_taipei, 0, 0), (128, 128)); + expect(decodeWindTexel(128, 128), Offset.zero); }); - }); - group('viewportBounds', () { - test('an unbearing zoom-0 512×512 view shows the whole world', () { - final cam = WindCamera(centerLat: 0, centerLng: 0, zoom: 0, bearing: 0); - final vp = viewportBounds(cam, const Size(512, 512)); - expect(vp.westLng, closeTo(-180, 1e-9)); - expect(vp.eastLng, closeTo(180, 1e-9)); - expect(vp.northLat, closeTo(85.05, 0.01)); - expect(vp.southLat, closeTo(-85.05, 0.01)); + test('round-trips the curve within a byte', () { + final (r, g) = encodeWindTexel(_taipei, 10, 0); + final back = decodeWindTexel(r, g); + expect(back.dx, closeTo(windSpeedCurve(10), 1 / 127)); + expect(back.dy, 0); }); - test('shrinks with the size and zoom', () { - final cam = WindCamera( - centerLat: 25, - centerLng: 121, - zoom: 8, - bearing: 0, - ); - final vp = viewportBounds(cam, const Size(1000, 1000)); - // world is 512·2⁸ = 131072 px wide; 500 px is 1.373° of longitude. - expect(vp.eastLng - vp.westLng, closeTo(1000 / 131072 * 360, 1e-9)); + test('full strength stays inside a byte, in every direction', () { + for (var deg = 0; deg < 360; deg += 15) { + final a = deg * math.pi / 180; + final (r, g) = encodeWindTexel( + _taipei, + 60 * math.cos(a), + 60 * math.sin(a), + ); + final back = decodeWindTexel(r, g); + expect(back.distance, closeTo(1, 0.02), reason: '$deg°'); + expect( + math.atan2(back.dy, back.dx), + closeTo(math.atan2(math.sin(a), math.cos(a)), 0.02), + reason: 'direction kept at $deg°', + ); + } }); }); - group('WindParticleSim', () { - test('near-calm air barely moves a particle but leaves trails', () { - final sim = WindParticleSim(_field(), count: 20, random: math.Random(7)); - sim.step(_camera, _size); - final x0 = sim.particles.first.x; - final y0 = sim.particles.first.y; - for (var i = 0; i < 10; i++) { - sim.step(_camera, _size); - } - expect(sim.particles.first.x - x0, lessThan(1e-3)); - expect(sim.particles.first.y - y0, lessThan(1e-3)); - expect( - sim.particles.every((p) => p.visible), - isTrue, - reason: 'every seeded particle sits in the viewport, so all stamp', - ); + group('the grid sample', () { + const grid = ( + lat0: 90.0, + lon0: 180.0, + dLat: -0.25, + width: 1440, + height: 721, + ); + + Offset uv(double lat, double lng) => windFieldUv( + lat: lat, + lng: lng, + lat0: grid.lat0, + lon0: grid.lon0, + dLat: grid.dLat, + width: grid.width, + height: grid.height, + ); + + test('a grid node lands on its texel centre', () { + // Node (10, 20): lon 180 + 2.5, lat 90 − 5. + final at = uv(85, 182.5); + expect(at.dx * grid.width, closeTo(10.5, 1e-6)); + expect(at.dy * grid.height, closeTo(20.5, 1e-6)); }); - test('an eastward field advects particles east', () { - final sim = WindParticleSim( - _eastField(), - count: 40, - random: math.Random(7), - ); - sim.step(_camera, _size); // seeds the viewport - final x0 = [for (final p in sim.particles) p.x]; - sim.step(_camera, _size); // one advection frame - // The median, not the mean: a recycled particle jumps anywhere in the - // viewport, and at the web's drop rate a few dozen of them do so every - // frame — enough to swamp the sub-pixel drift of everyone else. - final drift = []; - for (var i = 0; i < sim.particles.length; i++) { - var dx = sim.particles[i].x - x0[i]; - if (dx > 0.5) dx -= 1; // unwind a wrap past longitude 0 - if (dx < -0.5) dx += 1; - drift.add(dx); - } - drift.sort(); - expect(drift[drift.length ~/ 2], greaterThan(0)); + test('a grid that starts at 180 wraps like one that starts at 0', () { + expect(uv(0, -177.5).dx, closeTo(uv(0, 182.5).dx, 1e-9)); }); + }); - test('a particle drifting off the field respawns into the viewport', () { - final sim = WindParticleSim(_field(), count: 1, random: math.Random(3)); - sim.step(_camera, _size); - final out = sim.particles.first - ..y = -1.5; // above the field's north edge, off the grid - sim.step(_camera, _size); + group('the trails follow the map', () { + test('at rest the reference viewport is the screen', () { expect( - out.visible, - isFalse, - reason: 'off the grid is nothing to stamp, whatever happens next', + referenceToClip(_taipei, _taipei, Offset.zero), + _near(const Offset(-1, -1)), ); - // Recycling is rejection-sampled, so a candidate in still air is turned - // down nine times in ten and the particle waits where it is for the next - // frame — exactly what the web renderer does. Give it those frames. - for (var i = 0; i < 100; i++) { - sim.step(_camera, _size); - } - expect(out.y, inInclusiveRange(0, 1)); - // The respawned position lies inside the current viewport's field rect. - final vp = viewportBounds(_camera, _size); - final east = ((vp.eastLng - 0) / 360) % 1.0; - final west = ((vp.westLng - 0) / 360) % 1.0; expect( - out.x, - inInclusiveRange(math.min(west, east), math.max(west, east)), + referenceToClip(_taipei, _taipei, const Offset(1, 1)), + _near(const Offset(1, 1)), ); }); - test('a grid whose first column is at 180 streaks like one at 0', () { - // ECMWF ships its global grid starting at longitude 180 where GFS starts - // at 0. Nothing else about the two differs, so the same view must draw - // the same number of streaks — it once drew none at all, because every - // ECMWF particle projected tens of thousands of pixels off screen and was - // recycled before it could trace anything. - int streaks(double lon0) { - final sim = WindParticleSim( - _field(uValue: 255, vValue: 128, lon0: lon0), - count: 40, - random: math.Random(7), - ); - for (var i = 0; i < 5; i++) { - sim.step(_camera, _size); + test('a point of the trails moves exactly as the map under it', () { + final ref = _with(_taipei, bearing: 30); + final cams = [ + _with(ref, lat: 26, lng: 123), // pan + _with(ref, zoom: 6.3), // pinch + _with(ref, bearing: 75), // rotate + _with(ref, lat: 22, lng: 119, zoom: 4.2, bearing: 300), + ]; + for (final cam in cams) { + for (final p in const [ + Offset(0.1, 0.2), + Offset(0.5, 0.5), + Offset(0.9, 0.7), + ]) { + final ll = particleToLatLng(ref, p); + expect( + _clipToScreen(cam, referenceToClip(ref, cam, p)), + _near(projectLatLng(cam, ll.lat, ll.lng), 1e-6), + reason: '$p under $cam', + ); } - return sim.particles.where((p) => p.visible).length; } + }); - expect(streaks(180), streaks(0)); - expect(streaks(180), greaterThan(30)); + test('a pan across the antimeridian keeps the trails on screen', () { + final ref = _with(_taipei, lng: 179.9); + final cam = _with(_taipei, lng: -179.9); + final centre = referenceToClip(ref, cam, const Offset(0.5, 0.5)); + expect(centre.dx.abs(), lessThan(0.1)); }); + }); - test('a viewport straddling the grid seam seeds inside itself', () { - // The seam is at lon0, which for ECMWF is the antimeridian. Wrapping the - // two edges into [0,1) independently would leave west > east and seed the - // complement of the view — everywhere the viewer is not looking. - final cam = WindCamera(centerLat: 0, centerLng: 179, zoom: 4, bearing: 0); - final sim = WindParticleSim( - _field(lon0: 180), - count: 200, - random: math.Random(11), + group('settling', () { + test('no move, no change', () { + expect( + windSettle(_taipei, _taipei, trailWidth: 800, trailHeight: 1700), + isA(), ); - sim.step(cam, _size); - final vp = viewportBounds(cam, _size); - for (final p in sim.particles) { - var lng = 180 + p.x * 360; - lng -= 360 * ((lng - vp.westLng) / 360).floorToDouble(); - expect(lng, lessThanOrEqualTo(vp.eastLng + 1e-9)); - } }); - test('on-screen pace stays put as the map zooms in', () { - // A field-space step covers twice the pixels for every zoom level in, so - // holding it fixed made particles 23× too fast at z7 — the "flying - // everywhere" that a fixed step always ends in. - double pixelsPerFrame(double zoom) { - final sim = WindParticleSim(_eastField(), random: math.Random(5)); - final cam = WindCamera( - centerLat: 25, - centerLng: 121, - zoom: zoom, - bearing: 0, - ); - sim.step(cam, _size); // seeds - Offset at(WindParticle p) => - projectLatLng(cam, 90 + p.y * -180, p.x * 360, _size); - final before = [for (final p in sim.particles) at(p)]; - sim.step(cam, _size); - // Median again: the handful recycled this frame moved by a respawn, - // not by the wind. - final moved = [ - for (var i = 0; i < sim.particles.length; i++) - (at(sim.particles[i]) - before[i]).distance, - ]..sort(); - return moved[moved.length ~/ 2]; - } + test('a pan shifts by whole texels, the way the map moved', () { + // 0.5° east puts the old view's origin to the left of the new one. + final cam = _with(_taipei, lng: _taipei.centerLng + 0.5); + final settle = windSettle( + _taipei, + cam, + trailWidth: 800, + trailHeight: 1700, + ); + expect(settle, isA()); + final shift = (settle as WindShift).shift; + expect(shift.dx, lessThan(0)); + expect(shift.dy, 0); + expect(shift.dx * 800, closeTo((shift.dx * 800).roundToDouble(), 1e-9)); + final exact = referenceToClip(_taipei, cam, Offset.zero); + expect(shift.dx, closeTo((exact.dx + 1) / 2, 1 / 800)); + }); - final slow = pixelsPerFrame(3); - for (final zoom in [4.0, 5.0, 6.0, 7.0]) { + test('a zoom or a rotation clears', () { + for (final cam in [ + _with(_taipei, zoom: 5.5), + _with(_taipei, bearing: 10), + ]) { expect( - pixelsPerFrame(zoom), - closeTo(slow, slow * 0.6), - reason: 'z$zoom should read at much the same pace as z3', + windSettle(_taipei, cam, trailWidth: 800, trailHeight: 1700), + isA(), ); } - // Past the tuned stops the web holds its values flat rather than - // extrapolating — "outside the stops there is no judgement behind the - // number, only arithmetic" — so the pace does climb again out there. The - // wind layer caps the map at z7, so nobody sees it; asserted only so the - // clamp is not mistaken for a bug next time someone measures z10. - expect(pixelsPerFrame(10), greaterThan(slow * 4)); }); }); } diff --git a/test/features/map/wind_web_parity_test.dart b/test/features/map/wind_web_parity_test.dart deleted file mode 100644 index 66f84fb6e..000000000 --- a/test/features/map/wind_web_parity_test.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart'; - -import 'dart:math' as math; - -import 'package:flutter_test/flutter_test.dart'; - -/// Every number here was computed by running `effective()` from -/// `satellite-tiles-go/web/index.html` over its own `DEFAULTS` and `TUNE`. -/// -/// The two renderers only agree because one copies the other, and values tuned -/// by dragging a slider cannot be re-derived from anything. So the agreement is -/// pinned rather than trusted: if the web's tuning moves, these fail and say -/// which way to move with it. -void main() { - group('particle count (TUNE: zoom log, rounded to a square)', () { - test('matches the web at every stop', () { - expect(particleCountFor(3), 6400); - expect(particleCountFor(4), 4096); - expect(particleCountFor(5), 2601); - expect(particleCountFor(6), 1600); - expect(particleCountFor(7), 1024); - }); - - test('is held flat outside the stops, not extrapolated', () { - expect(particleCountFor(1), 6400); - expect(particleCountFor(12), 1024); - }); - - test('is always a perfect square', () { - for (var z = 3.0; z <= 7.0; z += 0.25) { - final n = particleCountFor(z); - final edge = _isqrt(n); - expect(edge * edge, n, reason: 'z$z gave $n'); - } - }); - }); - - test('line width matches the reference integer-zoom table', () { - expect(lineWidthFor(3), closeTo(1, 1e-9)); - expect(lineWidthFor(4), closeTo(1.2, 1e-9)); - expect(lineWidthFor(5), closeTo(1.6, 1e-9)); - expect(lineWidthFor(6), closeTo(1.8, 1e-9)); - expect(lineWidthFor(7), closeTo(2, 1e-9)); - expect(lineWidthFor(4.5), closeTo(1.4, 1e-9)); - }); - - test('rendered stroke includes the reference physical-pixel fringe', () { - // z6: max(1, 1.8 × 1.3 × 2.625) + one physical AA pixel. - expect(pointSizeFor(6, pixelRatio: 2.625), closeTo(7.1425 / 2.625, 1e-9)); - expect(pointSizeFor(3), closeTo(2.3, 1e-9)); - }); - - test('field step matches the web (TUNE: zoom log)', () { - // Relative, because the reference values were printed to eight figures. - void expectStep(double zoom, double speedFactor) { - final want = 0.0001 * speedFactor; - expect(fieldStepFor(zoom), closeTo(want, want * 1e-7)); - } - - expectStep(3, 0.2); - expectStep(4, 0.10483752); - expectStep(5, 0.05495453); - expectStep(6, 0.02880648); - expectStep(7, 0.0151); - }); - - group('fade opacity', () { - // Deliberately NOT the reference's 0.97. DPIP renders the short dash the - // old Flutter overlay drew — 14 frames of history — rather than Windy's - // second-long streak, and 0.85 is where the exponential lands the same - // window (`0.85^14 = 0.10`). See kWindFadeOpacity for the reasoning. - test('is constant across the zoom range', () { - expect(fadeOpacityFor(3), closeTo(0.85, 1e-9)); - expect(fadeOpacityFor(5), closeTo(0.85, 1e-9)); - expect(fadeOpacityFor(7), closeTo(0.85, 1e-9)); - }); - - test('keeps a stroke visible for roughly the intended window', () { - // The look this replaced showed 14 frames of history and nothing older. - final f = fadeOpacityFor(5); - expect( - math.pow(f, 14), - lessThan(0.15), - reason: 'the tail should be gone', - ); - expect(math.pow(f, 5), greaterThan(0.3), reason: 'but not immediately'); - }); - - test('never reaches 1, at any zoom', () { - // A fade that does not fade accumulates every frame and the screen - // saturates to white — the web says so in TUNE and means it. - for (var z = 0.0; z <= 14.0; z += 0.5) { - expect(fadeOpacityFor(z), lessThan(1.0)); - expect(fadeOpacityFor(z), greaterThan(0.5)); - } - }); - }); - - group('density weighting', () { - test('matches the web ramp', () { - expect(densityWeight(0), closeTo(0.5, 1e-9)); - expect(densityWeight(4), closeTo(1.060619, 1e-6)); - expect(densityWeight(9.6), closeTo(3.0, 1e-9)); - expect(densityWeight(16), closeTo(5.129630, 1e-6)); - expect(densityWeight(19.2), closeTo(5.5, 1e-9)); - }); - - test('saturates rather than running away past the ramp', () { - expect(densityWeight(32), closeTo(5.5, 1e-9)); - expect(densityWeight(200), closeTo(5.5, 1e-9)); - }); - - test('favours strong wind by eleven to one', () { - // The direction matters and it is the opposite of what it once was: the - // Dart port still had the older ratio, six to one the other way. - expect(densityWeight(19.2) / densityWeight(0), closeTo(11, 1e-9)); - }); - }); -} - -int _isqrt(int n) { - var r = 0; - while ((r + 1) * (r + 1) <= n) { - r++; - } - return r; -}