diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 972535548..4e53ad25a 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -58,9 +58,9 @@ import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/basemap_overlay_sync.dart'; import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/geo_circle.dart'; +import 'package:dpip/shared/map/wavefront_cadence.dart'; import 'package:dpip/shared/map/map_compass.dart'; import 'package:dpip/shared/map/map_gsi_overlay.dart'; -import 'package:dpip/shared/map/wavefront_cadence.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/map/map_style.dart' show MapColors, countyFillLayerId, townFillLayerId, townLabelLayerId; @@ -803,7 +803,11 @@ class _ReplayMapState extends State<_ReplayMap> { _styleLoaded = true; // A style (re)load recreates every source below empty — whatever the box // source held before is gone, so the next [_updateBox] must not skip. + // The wavefront source is empty too; forgetting the radii would hold the + // new source on the sub-pixel gate and leave the rings off the map. _boxSignature = null; + _ringsOnMap.clear(); + _eewSourceEmpty = true; // The whole stack — sources, layers, badge icons, and the order they mount // in — is [addMonitorLayers], shared with the live monitor (`RtsMapLayer`). // This page used to carry its own hand-copied port, and the copies drifted @@ -939,11 +943,15 @@ class _ReplayMapState extends State<_ReplayMap> { /// the map for the rest of the replay. bool _eewSourceEmpty = true; + /// Radii last written to the EEW source. A sub-pixel step does not replace + /// them; the upload that does uses the replay clock's current radii. + final Map _ringsOnMap = {}; + /// Whether an [_updateEew] write is still on the platform channel. The - /// wave-front ticker fires every 16 ms and does not wait for the previous - /// write to land, so without this a slow frame let several ring uploads - /// queue up behind each other — each one a full polygon set the map would - /// render in turn, none of them the current one. + /// wavefront ticker does not wait for the previous write to land, so + /// without this a slow frame let several ring uploads queue up behind each + /// other — each one a full polygon set the map would render in turn, none + /// of them the current one. bool _eewUpdating = false; Future _updateEew() async { @@ -957,15 +965,36 @@ class _ReplayMapState extends State<_ReplayMap> { final empty = !widget.eew.alerts.hasUncancelled; // Nothing to draw and nothing drawn — skip the per-tick round trip. if (empty && _eewSourceEmpty) return; - try { - await controller.setGeoJsonSource( - _ids.eewSource, - empty ? monitorEmptyCollection : _eewGeoJson(), - ); - _eewSourceEmpty = empty; - } catch (_) { - // Source not on the map yet (mid style-reload) — the next update - // retries; the flag is untouched because the write never landed. + final now = widget.clock.now(); + final rings = _wavefrontRings(now); + // A sub-pixel step skips the ring upload only. The area fill below + // follows the card's selected alert, and cycling the card changes it + // while every ring stands still. + final upload = + empty || + wavefrontUploadDue( + next: rings, + onMap: _ringsOnMap, + zoom: controller.cameraPosition?.zoom, + ); + if (upload) { + try { + await controller.setGeoJsonSource( + _ids.eewSource, + empty ? monitorEmptyCollection : _eewGeoJson(now), + ); + _eewSourceEmpty = empty; + if (empty) { + _ringsOnMap.clear(); + } else { + _ringsOnMap + ..clear() + ..addEntries(rings.map((ring) => MapEntry(ring.id, ring))); + } + } catch (_) { + // Source not on the map yet (mid style-reload) — the next update + // retries; the flag is untouched because the write never landed. + } } await _updateAreaFill(controller); } finally { @@ -1228,9 +1257,37 @@ class _ReplayMapState extends State<_ReplayMap> { /// recomputed every tick so the rings keep expanding even between polls. /// No rings render until [_travelTimeTable] resolves (near-instant, but /// async since it's an asset load). - Map _eewGeoJson() { + List _wavefrontRings(DateTime now) { + final table = _travelTimeTable; + final rings = []; + for (final eew in widget.eew.alerts.uncancelled) { + final info = eew.info; + var p = 0.0; + var s = 0.0; + if (table != null) { + final elapsed = now.difference( + DateTime.fromMillisecondsSinceEpoch(info.time, isUtc: true), + ); + if (!elapsed.isNegative) { + final radius = table.waveRadius(info.depth, elapsed); + p = radius.p * 1000; + s = radius.s * 1000; + } + } + rings.add( + WavefrontRing( + id: '${eew.id}:${eew.serial}', + pMetres: p, + sMetres: s, + latitude: info.latitude, + ), + ); + } + return rings; + } + + Map _eewGeoJson(DateTime now) { final table = _travelTimeTable; - final now = widget.clock.now(); final features = >[]; // A cancelled alert draws nothing: its rings would show a wave the agency // has just said is not coming. diff --git a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart index 6a1c7fbee..51735d2f9 100644 --- a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart +++ b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart @@ -208,8 +208,8 @@ class CardWaterField { // Everything in `_solveIteration` is one iteration of // `b2ParticleSystem::Solve` with the stages plain water never reaches // (viscous, powder, tensile, rigid) removed. - const sub = _worldStep / _particleIterations; - for (var it = 0; it < _particleIterations; it++) { + const sub = _worldStep / particleIterations; + for (var it = 0; it < particleIterations; it++) { _solveIteration(sub, unit, preset, topEdge, gravityScale, surface); } @@ -220,8 +220,25 @@ class CardWaterField { } } - /// `world.step(..., particleIterations = 5)`. - static const int _particleIterations = 5; + /// `world.step` particle iterations. + /// + /// The reference steps five times. Three keeps the same spawn, lifetime and + /// collision order; the water stacks a little softer and the solver does + /// less work per drop. + static const int particleIterations = 3; + + /// Raster scale of the signed accumulation buffers. + /// + /// The coverage is blurred before it is read back, so half resolution is + /// the same film stretched over the card, at a quarter of the pixels. + static const double accumulationResolution = 0.5; + + /// [full] rounded up to at least one pixel on each edge, at + /// [accumulationResolution]. + static Size accumulationSize(Size full) => Size( + math.max(1, (full.width * accumulationResolution).ceilToDouble()), + math.max(1, (full.height * accumulationResolution).ceilToDouble()), + ); /// One `b2ParticleSystem` particle iteration. /// @@ -350,9 +367,10 @@ class CardWaterField { // // Taking the normal as up instead flips both: the gate fires on drops // already leaving, and `vy += damp * vn` becomes `vy *= (1 + damp)` — an - // amplifier of up to 1.75x per sub-step, five sub-steps a step. That is - // a geometric runaway, and it is what threw every drop clean out of the - // render buffer to be clipped at the same line, which reads as a bounce + // amplifier of up to 1.75x per sub-step, and the step repeats + // [particleIterations] times. That is a geometric runaway, and it is + // what threw every drop clean out of the render buffer to be clipped at + // the same line, which reads as a bounce // height that never varies. final vn = -_p.vy[i]; if (vn >= 0) continue; diff --git a/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart b/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart index f571968eb..1a7bbfc1c 100644 --- a/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart +++ b/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart @@ -38,6 +38,22 @@ import 'package:flutter/material.dart'; /// non-uniformly, so the atlas carries four width variants (widest first, /// baked by `tool/build_rain_sprite.py`) and depth indexes them directly. class PrecipitationField { + /// High-tier rain pool, one rung under the authored storm count of 1792. + /// + /// 1472 is the reference's 大雨 preset. The phone still draws a downpour; + /// it just does not allocate the storm's full set on every frame. + static const int highRainCapacity = 1472; + + /// Low-tier rain pool — the reference's 大雨 count was already the cap here. + static const int lowRainCapacity = 1024; + + /// High-tier snow pool, scaled by the same ratio as [highRainCapacity] + /// against the old 1792-drop storm (900 × 1472 / 1792). + static const int highSnowCapacity = 739; + + /// Low-tier snow pool. Unchanged: it was already the reduced budget. + static const int lowSnowCapacity = 640; + /// Sprite atlas: rain has [rainVariants] width variants side by side. final ui.Image atlas; diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart index 2bd3f7812..a482e4977 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -580,10 +580,14 @@ class _RainOnCardState extends State final (pointSize, threshold) = _pointSizeAndThreshold(screen); _pointSize = pointSize; _threshold = threshold; + // σ is in the half-resolution accumulation's pixels. Drawing that image + // back at [CardWaterField.accumulationResolution]'s inverse restores the + // on-screen blur the full-resolution buffer used to have. + final raster = CardWaterField.accumulationResolution; _blurPaint = Paint() ..imageFilter = ui.ImageFilter.blur( - sigmaX: _blurSigmaUv * screen.width, - sigmaY: _blurSigmaUv * screen.height, + sigmaX: _blurSigmaUv * screen.width * raster, + sigmaY: _blurSigmaUv * screen.height * raster, ); } @@ -741,17 +745,19 @@ class _CardWaterPainter extends CustomPainter { double headroom, { required bool negative, }) { + final raster = CardWaterField.accumulationSize(fieldSize); final recorder = ui.PictureRecorder(); final offscreen = Canvas(recorder); - offscreen.saveLayer(Offset.zero & fieldSize, blurPaint); + offscreen.saveLayer(Offset.zero & raster, blurPaint); + offscreen.scale(CardWaterField.accumulationResolution); offscreen.translate(0, headroom); primary.paintCoverage(offscreen, dropSize: pointSize, negative: negative); secondary.paintCoverage(offscreen, dropSize: pointSize, negative: negative); offscreen.restore(); final picture = recorder.endRecording(); final image = picture.toImageSync( - fieldSize.width.ceil(), - fieldSize.height.ceil(), + raster.width.ceil(), + raster.height.ceil(), ); picture.dispose(); return image; @@ -768,6 +774,7 @@ class _CardWaterPainter extends CustomPainter { // buffer. Spans the card plus the whole fall above it. final headroom = CardWaterField.spawnHeadroom(screenHeight); final fieldSize = Size(size.width, size.height + headroom); + final raster = CardWaterField.accumulationSize(fieldSize); final pos = _accumulate(fieldSize, headroom, negative: false); final neg = _accumulate(fieldSize, headroom, negative: true); @@ -776,8 +783,8 @@ class _CardWaterPainter extends CustomPainter { final hour = AppTime.utc8.hour.toDouble(); var i = 0; void set(double v) => shader.setFloat(i++, v); - set(fieldSize.width); - set(fieldSize.height); + set(raster.width); + set(raster.height); // The sky at the panel band — the reference's `uBgLightTex` row. Until the first // bake lands, a rainy overcast grey-blue stands in. final ambient = SkyLutCache.panelAmbient.value ?? const Color(0xFF5C6B7E); @@ -795,8 +802,9 @@ class _CardWaterPainter extends CustomPainter { canvas.save(); canvas.translate(0, -headroom); + canvas.scale(1 / CardWaterField.accumulationResolution); canvas.drawRect( - Rect.fromLTWH(0, 0, fieldSize.width, fieldSize.height), + Rect.fromLTWH(0, 0, raster.width, raster.height), Paint()..shader = shader, ); canvas.restore(); diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart index 9a87e8c87..d1ace4e02 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart @@ -3,6 +3,57 @@ import 'dart:ui' as ui; import 'package:dpip/core/logging/log.dart'; import 'package:flutter/material.dart'; +/// Edge of the droplet-coverage bake, in pixels. The refract pass samples +/// this instead of rebuilding the field at the card's resolution. +const int kRainOnGlassFieldEdge = 256; + +/// Writes coverage (R) and the one-step neighbour (G) for [shader] into a +/// [kRainOnGlassFieldEdge] square. [shader] must be `rain_on_glass.frag` +/// with `uMode` driven to the bake path. [dummy] satisfies the two samplers +/// the bake does not read. +ui.Image bakeRainOnGlassField({ + required ui.FragmentShader shader, + required ui.Image dummy, + required double time, + required double opacity, + required double staticSize, + required double staticAmount, + required double staticSpeed, + required double runningSize, + required double runningAmount, + required double runningSpeed, +}) { + final edge = kRainOnGlassFieldEdge.toDouble(); + var i = 0; + void set(double v) => shader.setFloat(i++, v); + set(edge); + set(edge); + set(1080); + set(1080); + set(time); + set(staticSize); + set(staticAmount); + set(staticSpeed); + set(runningSize); + set(runningAmount); + set(runningSpeed); + set(opacity); + set(1); + shader + ..setImageSampler(0, dummy) + ..setImageSampler(1, dummy); + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder) + .drawRect(Rect.fromLTWH(0, 0, edge, edge), Paint()..shader = shader); + final picture = recorder.endRecording(); + final image = picture.toImageSync( + kRainOnGlassFieldEdge, + kRainOnGlassFieldEdge, + ); + picture.dispose(); + return image; +} + /// Puts rain on the *content*: droplets cling to [child] and bend its pixels /// through each bead. /// @@ -59,6 +110,9 @@ class _RainOnGlassState extends State ); ui.FragmentShader? _shader; + ui.FragmentShader? _fieldShader; + ui.Image? _field; + ui.Image? _dummy; /// Set once `ImageFilter.shader` has been seen to fail, so the throw is not /// repeated every frame on a Skia device. @@ -74,7 +128,10 @@ class _RainOnGlassState extends State try { final program = await ui.FragmentProgram.fromAsset(_asset); if (!mounted) return; - setState(() => _shader = program.fragmentShader()); + setState(() { + _shader = program.fragmentShader(); + _fieldShader = program.fragmentShader(); + }); _syncRunning(); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'Failed to load shader $_asset'); @@ -118,10 +175,48 @@ class _RainOnGlassState extends State void dispose() { _ticker.dispose(); _shader?.dispose(); + _fieldShader?.dispose(); + _field?.dispose(); + _dummy?.dispose(); _clock.stop(); super.dispose(); } + ui.Image _dummyImage() { + final existing = _dummy; + if (existing != null) return existing; + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 1, 1), + Paint()..color = const Color(0x00000000), + ); + final picture = recorder.endRecording(); + final image = picture.toImageSync(1, 1); + picture.dispose(); + return _dummy = image; + } + + /// One coverage bake per frame. The previous image is dropped so the GPU + /// does not keep every frame of the glass. + ui.Image _coverage(_GlassGrade grade, double time) { + final shader = _fieldShader; + if (shader == null) return _dummyImage(); + final next = bakeRainOnGlassField( + shader: shader, + dummy: _dummyImage(), + time: time, + opacity: widget.opacity, + staticSize: grade.staticSize, + staticAmount: grade.staticAmount, + staticSpeed: grade.staticSpeed, + runningSize: grade.runningSize, + runningAmount: grade.runningAmount, + runningSpeed: grade.runningSpeed, + ); + _field?.dispose(); + return _field = next; + } + /// Builds the droplet filter, or `null` if shaders-as-filters are not /// available on this backend. ui.ImageFilter? _filter(double intensity) { @@ -149,6 +244,8 @@ class _RainOnGlassState extends State set(grade.runningSpeed); // `uAlpha` is the effect's own strength, not the rain amount. set(widget.opacity.clamp(0.0, 1.0)); + set(0); + shader.setImageSampler(1, _coverage(grade, time)); try { return ui.ImageFilter.shader(shader); diff --git a/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart b/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart index 5965e557a..432222238 100644 --- a/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart +++ b/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart @@ -251,3 +251,68 @@ CloudLighting cloudLighting({required double sunAngleY}) { whitePer: 0.1 * smoothstep(0.30, 1.0, day), ); } + +/// One light's colour in the shader's 0..1 RGB, which may exceed 1 before the +/// fragment clamps the sum. +typedef CloudRgb = ({double r, double g, double b}); + +/// Colours the cloud shader used to fetch per pixel, baked from the uniform +/// probe. [sun], [ground] and [ambient] already include gain, the warm mix, +/// intensity and fog. [backSky] is the sun probe only — the thin-edge gain +/// stays in the shader because it changes per pixel. +typedef CloudBakedLights = ({ + CloudRgb sun, + CloudRgb ground, + CloudRgb ambient, + CloudRgb backSky, +}); + +const CloudRgb _sunWarm = (r: 1.0, g: 0.74, b: 0.53); +const CloudRgb _groundWarm = (r: 0.85, g: 0.54, b: 0.37); +const CloudRgb _ambientWarm = (r: 0.96, g: 0.63, b: 0.42); + +CloudRgb _rgbScale(CloudRgb color, double gain) => + (r: color.r * gain, g: color.g * gain, b: color.b * gain); + +CloudRgb _rgbMix(CloudRgb a, CloudRgb b, double t) => ( + r: a.r + (b.r - a.r) * t, + g: a.g + (b.g - a.g) * t, + b: a.b + (b.b - a.b) * t, +); + +double _mix(double a, double b, double t) => a + (b - a) * t; + +/// The four directional lights, without the per-pixel probe nudge. +/// +/// [sunIntensity] is the shader's `iSunIntensity` (the frame's day amount). +/// [fog] is `iFogDensity`. [sunSky], [groundSky] and [ambientSky] are the LUT +/// colours at each light's own picker. +CloudBakedLights bakeCloudLights({ + required CloudLighting lighting, + required double sunIntensity, + required double fog, + required CloudRgb sunSky, + required CloudRgb groundSky, + required CloudRgb ambientSky, + required CloudRgb backSky, +}) { + final white = lighting.whitePer.clamp(0.0, 1.0); + final fogMulti = 1.0 - fog * fog; + CloudRgb lit(CloudRgb sky, double multi, CloudRgb warm, double intensity) { + final gained = _rgbMix(_rgbScale(sky, multi), warm, white); + final weight = _mix(0.3, intensity, sunIntensity); + return _rgbScale(gained, weight * fogMulti); + } + + return ( + sun: lit(sunSky, lighting.sun.$2, _sunWarm, lighting.sun.$3), + ground: lit(groundSky, lighting.ground.$2, _groundWarm, lighting.ground.$3), + ambient: lit( + ambientSky, + lighting.ambient.$2, + _ambientWarm, + lighting.ambient.$3, + ), + backSky: backSky, + ); +} diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart index 246b4c6bb..0a795fd07 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart @@ -277,11 +277,13 @@ class _WeatherSkyBackgroundState extends State ? null : PrecipitationField( atlas: rainAtlas, - // The reference's storm preset pool; lighter rain uses proportionally - // fewer, which is how it expresses intensity. The low tier caps at - // the 大雨 pool — the storm's full 1792-drop budget is the single - // most expensive layer a low-end GPU can be asked to draw. - capacity: lowEnd ? 1024 : 1792, + // Lighter rain uses a fraction of this pool. The high tier is one + // preset under the authored storm (see + // [PrecipitationField.highRainCapacity]); the low tier stays on + // the smaller pool. + capacity: lowEnd + ? PrecipitationField.lowRainCapacity + : PrecipitationField.highRainCapacity, variants: 4, cell: const Size(32, 68), seed: 7, @@ -290,7 +292,9 @@ class _WeatherSkyBackgroundState extends State ? null : PrecipitationField( atlas: snowAtlas, - capacity: lowEnd ? 640 : 900, + capacity: lowEnd + ? PrecipitationField.lowSnowCapacity + : PrecipitationField.highSnowCapacity, cell: const Size(40, 40), tumble: true, seed: 11, @@ -640,7 +644,8 @@ _Look _lookFor(WeatherMode mode) => switch (mode) { keyframes: rainyExtremeKeyframes, layout: CloudLayout.rain, coverage: 1.0, - // The 暴雨 rung — the full 1792-drop pool. + // The full pool. The pool itself is one preset under the authored + // storm count — see [PrecipitationField.highRainCapacity]. rain: 1.0, snow: 0.0, fog: 0.12, diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart index c712d3cc7..a9dacdd70 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart @@ -313,11 +313,27 @@ class WeatherSkyPainter extends CustomPainter { // The sun's direction swings across the sky with its height, so lit faces // move around the cloud through the day. final day = frame.dayAmount; + final lutU = frame.sky.sunAngleY.clamp(0.0, 1.0); + final fallback = SkyLutCache.panelAmbient.value ?? const Color(0xFF5C6B7E); + CloudRgb sampleSky(double picker) { + final color = lutCache.skyAt(lutU, _skyAtV(picker)) ?? fallback; + return (r: color.r, g: color.g, b: color.b); + } + + final baked = bakeCloudLights( + lighting: lighting, + sunIntensity: day, + fog: frame.fog * 0.5, + sunSky: sampleSky(lighting.sun.$1), + groundSky: sampleSky(lighting.ground.$1), + ambientSky: sampleSky(lighting.ambient.$1), + backSky: sampleSky(lighting.sun.$1), + ); final sunDirX = -0.55 + 1.1 * day; final sunDirY = 0.25 + 0.70 * day; const sunDirZ = 0.45; - // Only five of the forty-three uniform slots differ between instances — + // Only five of the fifty-five uniform slots differ between instances — // the sprite's size, its opacity, and the texture's own size. The rest // describe the frame, so they are written once here instead of forty // times a frame through a closure allocated per sprite. A draw call @@ -356,11 +372,9 @@ class WeatherSkyPainter extends CustomPainter { ..setFloat(30, frame.fog * 0.5) ..setFloat(31, frame.lightning.flash * 0.8) ..setFloat(32, lighting.whitePer) - // iLutSize. NOTE: `clouds.frag` reads `iLutSize.y`, and slot 34 is that - // `.y` — so the shader's manual bilinear divides by 1.0, not by the - // column's 141 rows. Carried over verbatim from the per-sprite version - // rather than corrected here: swapping these two changes what the cloud - // deck looks like, which is a rendering decision, not a refactor. + // iLutSize stays in 33..34 so iBaseSky and the baked lights do not move. + // The shader no longer samples a LUT; these values only keep the uniform + // live (see clouds.frag). ..setFloat(33, SkyLutCache.skyViewSize.height) ..setFloat(34, 1.0) ..setFloat(37, baseSky.r) @@ -369,7 +383,18 @@ class WeatherSkyPainter extends CustomPainter { ..setFloat(40, hazeSky.r) ..setFloat(41, hazeSky.g) ..setFloat(42, hazeSky.b) - ..setImageSampler(1, skyColumn) + ..setFloat(43, baked.sun.r) + ..setFloat(44, baked.sun.g) + ..setFloat(45, baked.sun.b) + ..setFloat(46, baked.ground.r) + ..setFloat(47, baked.ground.g) + ..setFloat(48, baked.ground.b) + ..setFloat(49, baked.ambient.r) + ..setFloat(50, baked.ambient.g) + ..setFloat(51, baked.ambient.b) + ..setFloat(52, baked.backSky.r) + ..setFloat(53, baked.backSky.g) + ..setFloat(54, baked.backSky.b) // Slot 0 is per-sprite and the loop below overwrites it — but // `Paint.shader=` refuses a shader with any sampler still unassigned, // and on the first frame after `fragmentShader()` slot 0 has never been diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 762ad45ca..4d0cc4bde 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -27,11 +27,11 @@ import 'package:dpip/features/map/presentation/widgets/rts_monitor_panel.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/color_hex.dart'; import 'package:dpip/shared/map/geo_circle.dart'; +import 'package:dpip/shared/map/wavefront_cadence.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_style.dart' show MapColors, countyFillLayerId, townFillLayerId; import 'package:dpip/shared/map/monitor_map_stack.dart'; -import 'package:dpip/shared/map/wavefront_cadence.dart'; import 'package:dpip/shared/seismic/intensity_circle_renderer.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/seismic/intensity_icon_renderer.dart'; @@ -278,6 +278,12 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { SeismicTravelTimeTable? _travelTime; Timer? _eewTicker; + /// Radii last successfully written to the EEW source, keyed by alert id. + final Map _ringsOnMap = {}; + + /// When the ticker last ran the S-wave box coverage test. + DateTime? _lastBoxCoverage; + /// Identity of the last payload pushed to the source: `null` when offline /// (empty collection was sent), else the feed's data object — skips the /// per-tick round trip when a status change re-notifies without new data. @@ -289,16 +295,11 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// replay map so a change to one surface can't silently skip the other. static const MonitorLayerIds _ids = rtsMonitorIds; - /// Wave-front rings keep expanding between RTS polls (a realtime channel only - /// emits on a real transition) — a fixed cadence redraws them against the - /// calibrated clock so the circles grow every frame regardless. Close to - /// display-refresh rate (~60 Hz), not the RTS feed's own ~1 Hz: the ring is - /// real polygon geometry ([circleFeature]), not a `circle-radius` paint - /// property MapLibre can tween on its own, so *this* is what stands between - /// a silky-smooth expansion and a visibly stepped one. Each push is small - /// (two ~64-point rings), so the extra platform-channel traffic is cheap — - /// and it only runs at all while an alert is actually live - /// ([_syncEewTicker]). A low-end phone runs it slower — see [wavefrontTick]. + /// Wave-front rings keep expanding between RTS polls. The ticker wakes at + /// [wavefrontTick] — display rate on a capable phone, 5 Hz on a low-end + /// one — but only while an alert is live ([_syncEewTicker]). Waking is not + /// uploading: [wavefrontUploadDue] holds a step smaller than one screen + /// pixel, and the upload that does happen draws the radius at that instant. Duration _eewTick = wavefrontTick(RenderTier.high); static const int _maxStationRetries = 8; static const double _liveOpacity = 1.0; @@ -365,6 +366,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { _appliedStatus = null; // [addMonitorLayers] has just seeded the EEW source empty. _eewSourceEmpty = true; + _ringsOnMap.clear(); // The box source was just re-added empty above. _boxOnMap = null; await _pushUpdate(); @@ -425,17 +427,18 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { final generation = ++_eewTickGeneration; _eewTickInFlight = true; _eewTicksSkipped = 0; + final now = AppTime.utc; + final coverBoxes = boxCoverageDue(_lastBoxCoverage, now); + if (coverBoxes) _lastBoxCoverage = now; unawaited( Future.wait([ _pushEew(), - // A box's S-wave coverage (see [_isBoxFullyCovered]) grows every - // tick even between RTS polls, so it has to be re-evaluated here - // too — not just on [_onFeed] — or a box stops blinking only - // whenever the next poll happens to land, well after the wavefront - // actually crossed it. - _pushBox(), + // A box's S-wave coverage grows between RTS polls, but the box is + // kilometres across. [kBoxCoverageInterval] is the cadence that + // still drops it as the wave passes; [_onFeed] does not wait. + if (coverBoxes) _pushBox(), ]).whenComplete(() { - // A write abandoned by [_eewTickMaxWait] must not clear the flag + // A write abandoned after [_eewTickMaxSkips] must not clear the flag // of the one that replaced it. if (generation == _eewTickGeneration) _eewTickInFlight = false; }), @@ -583,12 +586,32 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { final live = _eewActive; // Nothing to draw and nothing drawn — the overwhelmingly common case. if (!live && _eewSourceEmpty) return; + final now = AppTime.utc; + final alerts = live ? _eew.state.data! : const []; + final rings = _rings(alerts, now); + if (live && + !wavefrontUploadDue( + next: rings, + onMap: _ringsOnMap, + zoom: controller.cameraPosition?.zoom, + )) { + return; + } try { await controller.setGeoJsonSource( _ids.eewSource, - live ? _eewGeoJson() : monitorEmptyCollection, + live + ? eewWaveGeoJson(alerts, _travelTime, now) + : monitorEmptyCollection, ); _eewSourceEmpty = !live; + if (live) { + _ringsOnMap + ..clear() + ..addEntries(rings.map((ring) => MapEntry(ring.id, ring))); + } else { + _ringsOnMap.clear(); + } } catch (_) { // Source not on the map (mid style-reload); the next render re-adds it. // The flag is left alone: the write never landed, so whatever was on the @@ -596,8 +619,39 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { } } - Map _eewGeoJson() => - eewWaveGeoJson(_eew.state.data ?? const [], _travelTime, AppTime.utc); + /// P and S radii at [now], in metres. The same instant [eewWaveGeoJson] + /// uses, so a due upload draws these numbers and not an older sample. + List _rings(List alerts, DateTime now) { + final table = _travelTime; + final rings = []; + for (final eew in alerts) { + // [eewWaveGeoJson] draws nothing for a cancelled alert, so its radius + // must not keep the upload gate awake. + if (eew.isCancelled) continue; + final info = eew.info; + var p = 0.0; + var s = 0.0; + if (table != null) { + final elapsed = now.difference( + DateTime.fromMillisecondsSinceEpoch(info.time, isUtc: true), + ); + if (!elapsed.isNegative) { + final radius = table.waveRadius(info.depth, elapsed); + p = radius.p * 1000; + s = radius.s * 1000; + } + } + rings.add( + WavefrontRing( + id: '${eew.id}:${eew.serial}', + pMetres: p, + sMetres: s, + latitude: info.latitude, + ), + ); + } + return rings; + } /// Updates the box-grid overlay: a frame with alerting stations lights the /// boxes they stand in (see [RtsAlertTracker]) and draws those coloured grid @@ -616,8 +670,8 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { final (boxes, signature) = _survivingBoxes(grid); if (signature != _boxOnMap) { // Claimed before the await, not after: the wavefront ticker calls - // this every frame, and a tick landing while the write was in - // flight used to upload the same collection again. Two writes land + // this on the coverage cadence, and a tick landing while the write + // was in flight used to upload the same collection again. Two writes land // on the platform channel in call order, so the later call's set is // the one the source ends up holding — and it is the one recorded. _boxOnMap = signature; diff --git a/lib/shared/map/geo_circle.dart b/lib/shared/map/geo_circle.dart index fedec4d7f..e910785e8 100644 --- a/lib/shared/map/geo_circle.dart +++ b/lib/shared/map/geo_circle.dart @@ -14,6 +14,11 @@ import 'dart:math' as math; import 'package:dpip/core/geo/geo_math.dart'; import 'package:dpip/core/models/lat_lng.dart'; +/// Sides of an earthquake wavefront. A 48-gon's sagitta is about 0.21% of the +/// radius; the previous 64-gon was 0.12%. On a phone that difference is a +/// fraction of the line's own width. +const int kWavefrontSteps = 48; + /// A GeoJSON `Feature` (closed `LineString` geometry) tracing a [steps]-sided /// approximation of the circle of [radiusMetres] around [center]. /// @@ -22,7 +27,7 @@ import 'package:dpip/core/models/lat_lng.dart'; Map circleFeature( LatLng center, double radiusMetres, { - int steps = 64, + int steps = kWavefrontSteps, Map properties = const {}, }) => _lineFeature(_ring(center, radiusMetres, steps), properties); @@ -32,7 +37,7 @@ Map circleFeature( Map circleFillFeature( LatLng center, double radiusMetres, { - int steps = 64, + int steps = kWavefrontSteps, Map properties = const {}, }) => _fillFeature(_ring(center, radiusMetres, steps), properties); @@ -43,7 +48,7 @@ void addCircleFeatures( List> features, LatLng center, double radiusMetres, { - int steps = 64, + int steps = kWavefrontSteps, Map? fillProperties, required Map lineProperties, }) { diff --git a/lib/shared/map/wavefront_cadence.dart b/lib/shared/map/wavefront_cadence.dart index 497c11040..c8604716b 100644 --- a/lib/shared/map/wavefront_cadence.dart +++ b/lib/shared/map/wavefront_cadence.dart @@ -1,7 +1,13 @@ -/// How often the EEW wave-front rings are redrawn, by device class — shared by -/// the live monitor and the report replay so both surfaces animate alike. +/// How often an earthquake's drawn wavefront is refreshed. +/// +/// [wavefrontTick] is how often the ticker wakes, by device class. A wake is +/// not itself an upload: the ring is real polygon geometry, so a step smaller +/// than one screen pixel is held, and the next upload draws the radius at +/// that instant, never the one that was skipped. library; +import 'dart:math' as math; + import 'package:dpip/core/platform/render_tier.dart'; /// The redraw interval for the P/S wave-front rings on a [tier] device. @@ -26,3 +32,102 @@ Duration wavefrontTick(RenderTier tier) => switch (tier) { RenderTier.high => const Duration(milliseconds: 16), RenderTier.low => const Duration(milliseconds: 200), }; + +/// Web Mercator metres per pixel along the equator at zoom 0. +/// +/// MapLibre's zoom is defined on 512 px tiles, so the world is `512 · 2^z` +/// logical pixels wide. The familiar 156543 m is the 256 px figure; using it +/// would make every "one pixel" here two. +const double kMercatorMetresPerPixelZoom0 = 78271.51696; + +/// How often a detection box is tested against the S-wave disc. +/// +/// The disc itself follows [wavefrontUploadDue]. A box is tens of kilometres +/// across and the S wave takes seconds to cross it, so a fifth of a second +/// late on the moment it winks out is the same picture. On a low-end phone +/// the ticker already wakes this slowly ([wavefrontTick]); this keeps a +/// capable phone from testing the grid at display rate. +const Duration kBoxCoverageInterval = Duration(milliseconds: 200); + +/// One alert's P and S radii, in metres, at a single instant. +class WavefrontRing { + const WavefrontRing({ + required this.id, + required this.pMetres, + required this.sMetres, + required this.latitude, + }); + + /// Alert identity. A new id is a different quake and always uploads. + final String id; + + final double pMetres; + final double sMetres; + + /// Degrees. The pixel size of a metre depends on latitude. + final double latitude; +} + +/// Metres on the ground covered by one screen pixel at [zoom]. +double metresPerPixel({required double latitude, required double zoom}) { + final scale = math.pow(2, zoom).toDouble(); + if (scale == 0 || zoom.isNaN) return double.infinity; + final metres = + kMercatorMetresPerPixelZoom0 * + math.cos(latitude * math.pi / 180).abs() / + scale; + return metres <= 0 ? double.infinity : metres; +} + +/// Whether [nextMetres] is at least one pixel away from the radius on the map. +bool wavefrontMoved({ + required double previousMetres, + required double nextMetres, + required double latitude, + required double zoom, +}) { + final pixel = metresPerPixel(latitude: latitude, zoom: zoom); + return (nextMetres - previousMetres).abs() + 1e-6 >= pixel; +} + +/// Whether [next] should replace what [onMap] is showing. +/// +/// A missing camera uploads: without a zoom the pixel test cannot promise the +/// ring is within a pixel, and a skipped upload would be the wrong trade. +/// When this returns true the caller draws [next], not the radii in [onMap]. +bool wavefrontUploadDue({ + required List next, + required Map onMap, + required double? zoom, +}) { + if (zoom == null || zoom.isNaN || zoom.isInfinite) return true; + if (next.length != onMap.length) return true; + for (final ring in next) { + final previous = onMap[ring.id]; + if (previous == null) return true; + if (wavefrontMoved( + previousMetres: previous.pMetres, + nextMetres: ring.pMetres, + latitude: ring.latitude, + zoom: zoom, + )) { + return true; + } + if (wavefrontMoved( + previousMetres: previous.sMetres, + nextMetres: ring.sMetres, + latitude: ring.latitude, + zoom: zoom, + )) { + return true; + } + } + return false; +} + +/// Whether the S-wave coverage test should run again. +/// +/// [last] null is the first look and always runs. Feed-driven updates do not +/// consult this — a new intensity still draws immediately. +bool boxCoverageDue(DateTime? last, DateTime now) => + last == null || now.difference(last) >= kBoxCoverageInterval; diff --git a/shaders/cloud/clouds.frag b/shaders/cloud/clouds.frag index ac4c253f4..83afd2462 100644 --- a/shaders/cloud/clouds.frag +++ b/shaders/cloud/clouds.frag @@ -54,17 +54,19 @@ // iFogDensity (30) haze blending the sprite toward the sky // iInner (31) internal illumination 0..1 (lightning inside) // iWhitePer (32) noon whitening (the reference's `whitePer`) -// iLutSize (33..34) sky LUT column size — only `.y` (height) is used; -// the sampler is the CPU-extracted column at the -// keyframe's sun angle, so u is gone +// iLutSize (33..34) kept so the slots after it do not move. The LUT +// sampler is gone; the value is not read for colour // iTexSize (35..36) sprite size in texels // iBaseSky (37..39) sky colour at the base light's probe — baked once // per keyframe on the CPU (the probe is pixel- // independent), so the GPU skips that LUT fetch // iHazeSky (40..42) sky colour at the 0.95 haze probe, same reason +// iSunLit (43..45) sun light, gain and fog already applied +// iGroundLit (46..48) ground light, same +// iAmbientLit (49..51) ambient light, same +// iBackSky (52..54) sun-probe sky for the thin-edge light; its gain +// stays here because it changes per pixel // sampler 0: iSprite the cloud sprite -// sampler 1: iSkyView the 1×141 sky-LUT column (`sky/sky_lut.frag`, -// extracted by `SkyLutCache._bakeSkyColumn`) #include precision mediump float; @@ -91,33 +93,21 @@ uniform vec2 iLutSize; uniform vec2 iTexSize; uniform vec3 iBaseSky; uniform vec3 iHazeSky; +/// Sun, ground and ambient, already gained, warm-mixed and scaled by +/// intensity and fog. The per-pixel probe nudge is gone: it was a LUT fetch +/// per light, and at phone size the lit face reads the same without it. +uniform vec3 iSunLit; +uniform vec3 iGroundLit; +uniform vec3 iAmbientLit; +/// Sky colour at the sun's probe, before the per-pixel back-light gain. +uniform vec3 iBackSky; uniform sampler2D iSprite; -uniform sampler2D iSkyView; out vec4 fragColor; #define PI 3.14159265359 #define ONE_OVER_PI 0.3183098861837907 -/// Bilinear sky-LUT fetch, reduced to a **vertical** 2-tap. -/// -/// The LUT sampler is the CPU-extracted column at the keyframe's sun angle — -/// every probe's u is that same constant, so the horizontal filtering already -/// happened at bake time and a probe here is a 1D vertical fetch. Flutter -/// binds samplers as NEAREST with no way to request linear, so the vertical -/// interpolation is done by hand; without it the cloud lighting steps as the -/// sun moves. -vec3 sampleLut(float v) { - float texel = 1.0 / iLutSize.y; - float st = v * iLutSize.y - 0.5; - float base = floor(st); - float f = st - base; - float uv0 = (base + 0.5) * texel; - vec3 c0 = texture(iSkyView, vec2(0.5, uv0)).rgb; - vec3 c1 = texture(iSkyView, vec2(0.5, uv0 + texel)).rgb; - return mix(c0, c1, f); -} - /// Bilinear sprite fetch. /// /// Sprites are magnified roughly 2.7x on screen, and Flutter binds samplers as @@ -142,36 +132,6 @@ vec4 sampleSprite(vec2 uv) { return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y); } -/// The sky's colour at probe [picker], 0 = horizon … 1 = zenith. -/// -/// NOT yet the environment-light model. The engine's probe walks the **camera -/// frustum** — `texCoord2uv` does `normalize(mix(FrustumA, FrustumC, 1 - -/// probe))`, a band of roughly -0.4 to +19 degrees with its 20-degree lens — -/// so every probe lands near the horizon, the brightest part of the sky. -/// -/// That is correct, and it was tried: with the frustum band all four lights -/// return bright haze, the four-light sum passes 1 and the sprites clamp to -/// solid white. The reference keeps it in range because it weights those lights with -/// the **authored per-keyframe `cloudConfig`** (picker/multi/intensity per -/// light), while this port still synthesises them from one `day` scalar and -/// runs far hotter. -/// -/// So this deliberately keeps the wider 0..90-degree mapping, which stays in -/// range at the cost of taking fill light from zenith blue rather than -/// horizon haze. Switching to the real band is blocked on consuming the -/// authored cloudConfig — the same prerequisite as the real `whitePer` curve. -vec3 skyAt(float picker) { - // elevation = picker·(π/2), so `sqrt(elevation/(π/2))` is just `sqrt(picker)`. - float m = sqrt(clamp(picker, 0.0, 1.0)); - float v = clamp((m + 0.1015625) / 1.1015625, 0.0, 1.0); - return sampleLut(v); -} - -/// The reference's `readColorConfig`: sample the sky at the config's probe, then gain. -vec3 readColorConfig(vec3 cfg) { - return skyAt(cfg.x) * cfg.y; -} - /// The reference's `calcDepthAlpha` — the deck reveal sweeps a band through the /// sprite's depth channel, so a cloud materialises front-to-back. float calcDepthAlpha(float depth) { @@ -250,11 +210,9 @@ void main() { float backMulti = smoothstep(0.05, 1.0, mix(1.0 - iCloudDepth, 1.0, texBack)); - // --- the five-light rig, all sampled from the sky ----------------------- - // The base probe is pixel-independent (its picker is built from uniforms - // only), so its sky colour is baked on the CPU next to the keyframe and - // arrives as `iBaseSky` — one fewer LUT fetch per pixel. The gain below is - // still per-pixel (it rides `backMulti`), so only the colour is precomputed. + // --- the five-light rig ------------------------------------------------ + // Sun, ground and ambient colours are baked on the CPU from the uniform + // probe (no per-pixel LUT nudge). The base gain still rides `backMulti`. vec3 baseCfg = iBaseCfg; float baseEdgeMulti = mix(1.0, 2.1, backMulti * min(1.0, iSunCfg.z)); baseEdgeMulti = mix(baseEdgeMulti, 1.0, iWhitePer * 0.9); @@ -265,37 +223,16 @@ void main() { baseCfg.y = mix(1.0, baseCfg.y, iCloudDepth); vec3 baseColor = iBaseSky * baseCfg.y; - // Each light samples the sky between its own probe and the next one, blended - // by how strongly it hits — so a lit face reads the brighter sky. - // The probe is nudged by how strongly the light hits, but only gently: - // moving it the full way makes neighbouring pixels read different LUT rows - // and speckles the cloud with stray hues. - vec3 sunCfg = - vec3(mix(iSunCfg.x, iGroundCfg.x, l0 * 0.35), iSunCfg.y, iSunCfg.z); - vec3 sunColor = readColorConfig(sunCfg); - float sunI = mix(0.3, sunCfg.z, iSunIntensity); - - vec3 groundCfg = vec3(mix(iGroundCfg.x, iAmbientCfg.x, l1 * 0.35), - iGroundCfg.y, iGroundCfg.z); - vec3 groundColor = readColorConfig(groundCfg); - float groundI = mix(0.3, groundCfg.z, iSunIntensity); + vec3 sunColor = iSunLit; + vec3 groundColor = iGroundLit; + vec3 ambientColor = iAmbientLit; - vec3 ambientCfg = vec3(mix(iAmbientCfg.x, 1.0, l2 * 0.35), iAmbientCfg.y, - iAmbientCfg.z); - vec3 ambientColor = readColorConfig(ambientCfg); - float ambientI = mix(0.3, ambientCfg.z, iSunIntensity); - - vec3 backCfg = vec3(mix(iSunCfg.x, iGroundCfg.x, l3 * 0.35), - mix(0.0, 1.3, backMulti), iSunCfg.z); - vec3 backColor = readColorConfig(backCfg); - float backI = mix(1.5, 1.0, iSunIntensity) * backCfg.z; - - // The reference's authored noon tint — at high sun the sky probes go too blue, so it - // pulls the lights toward warm white. - sunColor = mix(sunColor, vec3(1.0, 0.74, 0.53), iWhitePer); - groundColor = mix(groundColor, vec3(0.85, 0.54, 0.37), iWhitePer); - ambientColor = mix(ambientColor, vec3(0.96, 0.63, 0.42), iWhitePer); - backColor = mix(backColor, vec3(1.0, 0.74, 0.53), iWhitePer); + // The back light's gain is the thin-edge term, which is per pixel. Its sky + // colour is the sun probe, baked. The warm mix happens after the gain, the + // same order as mixing the gained colour toward white. + float backGain = mix(0.0, 1.3, backMulti); + vec3 backColor = mix(iBackSky * backGain, vec3(1.0, 0.74, 0.53), iWhitePer); + float backI = mix(1.5, 1.0, iSunIntensity) * iSunCfg.z; // --- composite ---------------------------------------------------------- vec3 diffuse = mix(vec3(texDepth), vec3(1.0), 0.8); @@ -305,9 +242,9 @@ void main() { float fogMulti = 1.0 - fog * fog; vec3 color = baseColor; - color += l0 * sunColor * sunI * fogMulti; - color += l1 * groundColor * groundI * fogMulti; - color += l2 * ambientColor * ambientI * fogMulti; + color += l0 * sunColor; + color += l1 * groundColor; + color += l2 * ambientColor; color += l3 * backColor * backI * fogMulti; // Lightning inside the cloud, masked by the sprite's inner-glow channel. @@ -320,6 +257,13 @@ void main() { // pixel-independent, so its colour arrives baked in `iHazeSky` (see // `iBaseSky`). color = mix(color, iHazeSky, iFogDensity); + // iLutSize still occupies slots 33..34 so the baked colours after it stay + // put. A multiply by zero is deleted and the uniform goes with it, which + // would slide every later slot. The painter's values are a positive height + // and 1, so this branch never fires. + if (iLutSize.x + iLutSize.y < 0.0) { + color = vec3(1.0); + } float a = clamp(alpha * depthAlpha, 0.0, 1.0); fragColor = vec4(clamp(color, 0.0, 1.0) * a, a); // premultiplied diff --git a/shaders/weather/rain_on_glass.frag b/shaders/weather/rain_on_glass.frag index 92ea19df8..e39ee4091 100644 --- a/shaders/weather/rain_on_glass.frag +++ b/shaders/weather/rain_on_glass.frag @@ -54,7 +54,11 @@ uniform float uRunningDropSize; uniform float uRunningDropAmount; uniform float uRunningDropSpeed; uniform float uAlpha; +/// 0 refracts the card using [uField]. 1 writes the droplet coverage (R) and +/// the coverage one gradient step to the right (G) into a 256² bake. +uniform float uMode; uniform sampler2D uTex; +uniform sampler2D uField; out vec4 fragColor; @@ -152,6 +156,12 @@ float Drops(vec2 uv, vec3 dropN, vec3 staticN, float t, float rs, float ra, void main() { vec2 xy = FlutterFragCoord().xy; + // The coverage bake is a 256² image of the fixed 1080 frame. Map its pixels + // onto that frame before the field is built; the refract pass keeps the + // real fragment coordinate and only samples the bake. + if (uMode > 0.5) { + xy = xy / uSize * uResolution; + } // The OpenGL ES backend hands image filters a y-flipped texture, so the // sampler needs the mirrored row. The **field** must not be mirrored with // it: flipping `xy` before the field is built reverses `uv.y`, and the @@ -181,14 +191,23 @@ void main() { float rainAmount = sin(UV.x * UV.y) * 0.3 + 0.7; float staticDrops = smoothstep(-0.5, 1.0, rainAmount) * 2.0; - vec4 dropN = GenDropLayerN(uv, t); - vec3 staticN = GenStaticLayerN(uv); - float c = Drops(uv, dropN.xyz, staticN, t, staticDrops, rainAmount, dropN.w); + float c; + float cx; + if (uMode > 0.5) { + vec4 dropN = GenDropLayerN(uv, t); + vec3 staticN = GenStaticLayerN(uv); + c = Drops(uv, dropN.xyz, staticN, t, staticDrops, rainAmount, dropN.w); + vec2 e = vec2(0.0005, 0.0); + cx = Drops(uv + e, dropN.xyz, staticN, t, staticDrops, rainAmount, dropN.w); + fragColor = vec4(c, cx, 0.0, 1.0); + return; + } + vec2 fieldUv = clamp(xy / uResolution, vec2(0.0), vec2(1.0)); + vec2 baked = texture(uField, fieldUv).rg; + c = baked.x; + cx = baked.y; if (c > 0.001) { - vec2 e = vec2(0.0005, 0.0); - float cx = - Drops(uv + e, dropN.xyz, staticN, t, staticDrops, rainAmount, dropN.w); vec2 n = vec2(c - cx, cx - c); // `uTex.eval(texUV * uResolution)`: the displacement is `n` in the // 1080-frame, i.e. `n * uResolution` device pixels. It is not small — the diff --git a/test/features/earthquake/presentation/pages/report_replay_page_test.dart b/test/features/earthquake/presentation/pages/report_replay_page_test.dart index dda79b943..243a8429f 100644 --- a/test/features/earthquake/presentation/pages/report_replay_page_test.dart +++ b/test/features/earthquake/presentation/pages/report_replay_page_test.dart @@ -709,12 +709,9 @@ void main() { } }); - for (final (tier, low, high) in [ - (RenderTier.high, 40, 70), - (RenderTier.low, 4, 6), - ]) { + for (final tier in [RenderTier.high, RenderTier.low]) { testWidgets( - 'a ${tier.name}-tier phone redraws the rings $low–$high a second', + 'a ${tier.name}-tier phone does not redraw a sub-pixel wavefront every frame', (tester) async { final settings = SettingsStore.inMemory(); final archive = _Archive(RegionSelection(settings)) @@ -801,12 +798,14 @@ void main() { await icons(tester); expect(find.text('花蓮縣'), findsOneWidget); + // The replay clock is a stopwatch, so pumping frames does not move + // the wave. A tick that has not moved a pixel is not uploaded. A + // high-tier phone that pushed every wake would land far above this. final before = platform.eewWrites; for (var i = 0; i < 50; i++) { await tester.pump(const Duration(milliseconds: 20)); } - final perSecond = platform.eewWrites - before; - expect(perSecond, inInclusiveRange(low, high)); + expect(platform.eewWrites - before, lessThan(10)); } finally { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(milliseconds: 50)); diff --git a/test/features/home/presentation/widgets/weather_sky/sky_clouds_test.dart b/test/features/home/presentation/widgets/weather_sky/sky_clouds_test.dart index 850304fec..0be086e2d 100644 --- a/test/features/home/presentation/widgets/weather_sky/sky_clouds_test.dart +++ b/test/features/home/presentation/widgets/weather_sky/sky_clouds_test.dart @@ -74,4 +74,121 @@ void main() { expect(noon.sun.$3, greaterThan(night.sun.$3)); expect(noon.base.$1, greaterThan(night.base.$1)); }); + + test('baked lights warm the probe, then scale by intensity and fog', () { + final lighting = cloudLighting(sunAngleY: 0.5); + const sky = (r: 0.4, g: 0.5, b: 0.6); + const sunWarm = (r: 1.0, g: 0.74, b: 0.53); + const groundWarm = (r: 0.85, g: 0.54, b: 0.37); + const ambientWarm = (r: 0.96, g: 0.63, b: 0.42); + const sunIntensity = 0.8; + const fog = 0.5; + final baked = bakeCloudLights( + lighting: lighting, + sunIntensity: sunIntensity, + fog: fog, + sunSky: sky, + groundSky: sky, + ambientSky: sky, + backSky: sky, + ); + + // whitePer has to be in play, or this would not be testing the warm mix. + final white = lighting.whitePer.clamp(0.0, 1.0); + expect(white, greaterThan(0)); + final fogMulti = 1.0 - fog * fog; + double channel({ + required double sample, + required double multi, + required double warm, + required double intensity, + required double fogScale, + }) { + final gained = sample * multi; + final mixed = gained + (warm - gained) * white; + final weight = 0.3 + (intensity - 0.3) * sunIntensity; + return mixed * weight * fogScale; + } + + expect( + baked.sun.r, + closeTo( + channel( + sample: sky.r, + multi: lighting.sun.$2, + warm: sunWarm.r, + intensity: lighting.sun.$3, + fogScale: fogMulti, + ), + 1e-9, + ), + ); + expect( + baked.sun.g, + closeTo( + channel( + sample: sky.g, + multi: lighting.sun.$2, + warm: sunWarm.g, + intensity: lighting.sun.$3, + fogScale: fogMulti, + ), + 1e-9, + ), + ); + expect( + baked.sun.b, + closeTo( + channel( + sample: sky.b, + multi: lighting.sun.$2, + warm: sunWarm.b, + intensity: lighting.sun.$3, + fogScale: fogMulti, + ), + 1e-9, + ), + ); + expect( + baked.ground.r, + closeTo( + channel( + sample: sky.r, + multi: lighting.ground.$2, + warm: groundWarm.r, + intensity: lighting.ground.$3, + fogScale: fogMulti, + ), + 1e-9, + ), + ); + expect( + baked.ambient.b, + closeTo( + channel( + sample: sky.b, + multi: lighting.ambient.$2, + warm: ambientWarm.b, + intensity: lighting.ambient.$3, + fogScale: fogMulti, + ), + 1e-9, + ), + ); + expect( + baked.sun.r, + lessThan( + channel( + sample: sky.r, + multi: lighting.sun.$2, + warm: sunWarm.r, + intensity: lighting.sun.$3, + fogScale: 1, + ), + ), + reason: 'fog squares away part of the sun', + ); + // The thin-edge gain stays in the shader, so this probe is untouched. + expect(baked.backSky, sky); + }); } diff --git a/test/features/home/weather_sky/card_water_field_test.dart b/test/features/home/weather_sky/card_water_field_test.dart index 250592e75..bfbbf6947 100644 --- a/test/features/home/weather_sky/card_water_field_test.dart +++ b/test/features/home/weather_sky/card_water_field_test.dart @@ -73,6 +73,17 @@ void main() { } }); + test( + 'the solver takes three iterations and the accumulation is half size', + () { + expect(CardWaterField.particleIterations, 3); + expect(CardWaterField.accumulationResolution, 0.5); + final raster = CardWaterField.accumulationSize(const ui.Size(360, 201)); + expect(raster.width, 180); + expect(raster.height, 101); + }, + ); + test('drops are born just above the edge, not a third of a screen up', () { // the emitter's spawn y is 0.91, but that is an absolute world y — the collision box // puts the card's top face at +0.852, so the spawn line is only 0.058 above diff --git a/test/features/home/weather_sky/precipitation_field_test.dart b/test/features/home/weather_sky/precipitation_field_test.dart index 7d9304a89..32b732fdb 100644 --- a/test/features/home/weather_sky/precipitation_field_test.dart +++ b/test/features/home/weather_sky/precipitation_field_test.dart @@ -53,6 +53,14 @@ void main() { return last; } + test('the high rain pool is one rung under the storm count', () { + expect(PrecipitationField.highRainCapacity, 1472); + expect(PrecipitationField.highRainCapacity, lessThan(1792)); + expect(PrecipitationField.lowRainCapacity, 1024); + expect(PrecipitationField.highSnowCapacity, 739); + expect(PrecipitationField.lowSnowCapacity, 640); + }); + test('particles fall downward', () { final f = field(); final before = meanY(f, frames: 2); diff --git a/test/features/home/weather_sky/rain_on_glass_field_test.dart b/test/features/home/weather_sky/rain_on_glass_field_test.dart new file mode 100644 index 000000000..9795c9b76 --- /dev/null +++ b/test/features/home/weather_sky/rain_on_glass_field_test.dart @@ -0,0 +1,49 @@ +import 'dart:ui' as ui; + +import 'package:dpip/features/home/presentation/widgets/weather_sky/rain_on_glass.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'the glass field is baked at 256 and its coverage is not empty', + () async { + expect(kRainOnGlassFieldEdge, 256); + final program = await ui.FragmentProgram.fromAsset( + 'shaders/weather/rain_on_glass.frag', + ); + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 1, 1), + Paint()..color = const Color(0x00000000), + ); + final dummy = recorder.endRecording().toImageSync(1, 1); + final field = bakeRainOnGlassField( + shader: program.fragmentShader(), + dummy: dummy, + time: 6, + opacity: 1, + staticSize: 1, + staticAmount: 1, + staticSpeed: 1, + runningSize: 1, + runningAmount: 1, + runningSpeed: 1, + ); + expect(field.width, kRainOnGlassFieldEdge); + expect(field.height, kRainOnGlassFieldEdge); + + final bytes = await field.toByteData(format: ui.ImageByteFormat.rawRgba); + final px = bytes!.buffer.asUint8List(); + var covered = 0; + for (var i = 0; i < px.length; i += 4) { + if (px[i] > 0) covered++; + } + expect(covered, greaterThan(0), reason: 'R is droplet coverage'); + field.dispose(); + dummy.dispose(); + }, + ); +} diff --git a/test/features/map/presentation/layers/rts_layer_eew_push_test.dart b/test/features/map/presentation/layers/rts_layer_eew_push_test.dart index c84ddf3ff..e37ea3477 100644 --- a/test/features/map/presentation/layers/rts_layer_eew_push_test.dart +++ b/test/features/map/presentation/layers/rts_layer_eew_push_test.dart @@ -8,9 +8,9 @@ /// pausing the render loop does not stop the Dart listener). /// /// The guard must be exactly one-sided. Skipping a redundant *empty* push is -/// free; skipping a *live* one is not, because the wavefront geometry is a -/// function of the calibrated clock — every tick genuinely differs, and a -/// dropped one is a ring that stops expanding during an earthquake. +/// free. A live ring is skipped only while it has moved less than a screen +/// pixel; the upload that follows draws the radius at that instant, so the +/// ring never stays behind the wave. library; import 'dart:async'; @@ -18,7 +18,10 @@ import 'dart:async'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/platform/render_tier.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/clock.dart'; +import 'package:dpip/core/realtime/server_clock.dart'; +import 'package:dpip/core/realtime/server_time_source.dart'; import 'package:dpip/core/realtime/elapsed.dart'; import 'package:dpip/core/realtime/realtime_channel.dart'; import 'package:dpip/core/realtime/realtime_config.dart'; @@ -73,6 +76,51 @@ class _SlowEewController extends _EewRecordingController { } } +/// [AppTime] reads [DateTime.now], which a [FakeAsync] zone does not move. +/// The wavefront is measured against [AppTime], so the ticker tests install +/// a clock that follows [FakeAsync.elapsed]. Otherwise the radius sits still +/// and the pixel gate holds every tick. +FakeAsync? _fake; + +class _ZoneClock implements Clock { + @override + DateTime now() { + final fake = _fake; + if (fake == null) return DateTime.now().toUtc(); + return fake + .getClock(DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)) + .now(); + } +} + +void _during(void Function(FakeAsync async) body) { + fakeAsync((async) { + _fake = async; + try { + body(async); + } finally { + _fake = null; + } + }); +} + +class _UnusedServerTime implements ServerTimeSource { + @override + Future> serverTimeMs() async => const Ok(0); +} + +void _followFakeTime() { + AppTime.install( + ServerClock(_ZoneClock(), _FakeElapsed(), _UnusedServerTime()), + ); +} + +void _restoreWallClock() { + AppTime.install( + ServerClock(const SystemClock(), _FakeElapsed(), _UnusedServerTime()), + ); +} + class _FakeClock implements Clock { _FakeClock(this.current); DateTime current; @@ -124,14 +172,14 @@ class _EmptyStations implements TremStationRepository { const Ok({}); } -Eew _alert() => Eew( +Eew _alert({DateTime? time}) => Eew( agency: 'CWA', id: 'a', serial: 1, status: 0, isFinal: false, info: EewInfo( - time: DateTime.utc(2026, 8, 12, 12).millisecondsSinceEpoch, + time: (time ?? DateTime.utc(2026, 8, 12, 12)).millisecondsSinceEpoch, longitude: 121.5, latitude: 23.5, depth: 10, @@ -322,6 +370,9 @@ void main() { ); group('the wavefront ticker', () { + setUp(_followFakeTime); + tearDown(_restoreWallClock); + /// Renders a live alert inside [async] and returns the layer, settled. RtsMapLayer attach( FakeAsync async, @@ -329,8 +380,14 @@ void main() { RenderTier? tier, }) { late RtsMapLayer layer; + // Origin is this fake clock, so elapse grows the wave. The bundled + // fixture would already be past the table and the radius would sit still. _build( - alerts: [_alert()], + // Two seconds in, the fixture is between its two rows and the + // radius is actually moving. Before the first row it sits still. + alerts: [ + _alert(time: _ZoneClock().now().subtract(const Duration(seconds: 2))), + ], eewElapsed: _FakeElapsed(), renderTier: tier == null ? null : Future.value(tier), ).then((built) => layer = built.layer); @@ -340,9 +397,24 @@ void main() { return layer; } + test('a sub-pixel step is not uploaded', () { + _during((async) { + final controller = _EewRecordingController(); + final layer = attach(async, controller); + + final before = controller.eewPushes.length; + // At zoom 7 one pixel is about 560 m here (512 px tiles, 23.5°N); + // this fixture moves about 400 m in 80 ms, under one pixel. + async.elapse(const Duration(milliseconds: 16 * 5)); + expect(controller.eewPushes, hasLength(before)); + layer.clear(controller); + async.flushMicrotasks(); + }); + }); + test('waits for the previous write instead of queueing behind it', () { - fakeAsync((async) { - final controller = _SlowEewController(); + _during((async) { + final controller = _SlowEewController()..reportCamera(zoom: 14); final layer = attach(async, controller); final gate = controller.gate = Completer(); @@ -370,8 +442,8 @@ void main() { }); test('treats a write that never answers as lost, not slow', () { - fakeAsync((async) { - final controller = _SlowEewController(); + _during((async) { + final controller = _SlowEewController()..reportCamera(zoom: 14); final layer = attach(async, controller); controller.gate = Completer(); // never completed @@ -388,8 +460,11 @@ void main() { }); test('a low-end phone redraws the rings five times a second', () { - fakeAsync((async) { - final controller = _EewRecordingController(); + _during((async) { + // Zoomed in until one 200 ms step of this wave is more than a pixel. + // Farther out the pixel gate holds the tick and the count is not the + // ticker rate. + final controller = _EewRecordingController()..reportCamera(zoom: 14); final layer = attach(async, controller, tier: RenderTier.low); final before = controller.eewPushes.length; @@ -401,8 +476,8 @@ void main() { }); test('a low-end phone still gives up on a lost write after a second', () { - fakeAsync((async) { - final controller = _SlowEewController(); + _during((async) { + final controller = _SlowEewController()..reportCamera(zoom: 14); final layer = attach(async, controller, tier: RenderTier.low); controller.gate = Completer(); // never completed @@ -418,8 +493,8 @@ void main() { }); test('writes on every tick while the map keeps up', () { - fakeAsync((async) { - final controller = _EewRecordingController(); + _during((async) { + final controller = _EewRecordingController()..reportCamera(zoom: 14); final layer = attach(async, controller); final before = controller.eewPushes.length; diff --git a/test/shaders/preview_render_test.dart b/test/shaders/preview_render_test.dart index 6cfa6b7f6..5f7fc936b 100644 --- a/test/shaders/preview_render_test.dart +++ b/test/shaders/preview_render_test.dart @@ -504,8 +504,30 @@ Future _renderScene( f(hazeSky.r / 255.0); f(hazeSky.g / 255.0); f(hazeSky.b / 255.0); + CloudRgb rgb(({int r, int g, int b}) sample) => + (r: sample.r / 255.0, g: sample.g / 255.0, b: sample.b / 255.0); + final baked = bakeCloudLights( + lighting: lighting, + sunIntensity: el, + fog: 0.0, + sunSky: rgb(_sampleLut(lutBytes, lutU, _skyAtV(lighting.sun.$1))), + groundSky: rgb(_sampleLut(lutBytes, lutU, _skyAtV(lighting.ground.$1))), + ambientSky: rgb(_sampleLut(lutBytes, lutU, _skyAtV(lighting.ambient.$1))), + backSky: rgb(_sampleLut(lutBytes, lutU, _skyAtV(lighting.sun.$1))), + ); + f(baked.sun.r); + f(baked.sun.g); + f(baked.sun.b); + f(baked.ground.r); + f(baked.ground.g); + f(baked.ground.b); + f(baked.ambient.r); + f(baked.ambient.g); + f(baked.ambient.b); + f(baked.backSky.r); + f(baked.backSky.g); + f(baked.backSky.b); shader.setImageSampler(0, sprite); - shader.setImageSampler(1, lut); canvas.save(); canvas.translate(p.left, p.top); @@ -519,7 +541,7 @@ Future _renderScene( if (rain > 0.002) { final field = PrecipitationField( atlas: rainAtlas, - capacity: 1792, + capacity: PrecipitationField.highRainCapacity, variants: 4, cell: const Size(32, 68), seed: 7, @@ -563,7 +585,7 @@ Future _renderScene( if (snow > 0.002) { final field = PrecipitationField( atlas: snowAtlas, - capacity: 900, + capacity: PrecipitationField.highSnowCapacity, cell: const Size(40, 40), tumble: true, seed: 11, diff --git a/test/shaders/rain_on_glass_test.dart b/test/shaders/rain_on_glass_test.dart index b892e1bdf..20da112fb 100644 --- a/test/shaders/rain_on_glass_test.dart +++ b/test/shaders/rain_on_glass_test.dart @@ -4,6 +4,7 @@ library; import 'dart:io'; import 'dart:ui' as ui; +import 'package:dpip/features/home/presentation/widgets/weather_sky/rain_on_glass.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -44,25 +45,50 @@ void main() { final prog = await ui.FragmentProgram.fromAsset( 'shaders/weather/rain_on_glass.frag', ); + final dummyRec = ui.PictureRecorder(); + ui.Canvas(dummyRec).drawRect( + const Rect.fromLTWH(0, 0, 1, 1), + Paint()..color = const Color(0x00000000), + ); + final dummy = dummyRec.endRecording().toImageSync(1, 1); Future render(double refraction, double time) async { + final field = bakeRainOnGlassField( + shader: prog.fragmentShader(), + dummy: dummy, + time: time, + opacity: refraction, + staticSize: 1, + staticAmount: 1, + staticSpeed: 1, + runningSize: 1, + runningAmount: 1, + runningSpeed: 1, + ); final sh = prog.fragmentShader(); var i = 0; void f(double v) => sh.setFloat(i++, v); f(w.toDouble()); - f(h.toDouble()); // engine normally sets this + f(h.toDouble()); + f(1080); + f(1080); f(time); f(1.0); f(1.0); - f(1.0); // static size/amount/speed f(1.0); f(1.0); - f(1.0); // running size/amount/speed + f(1.0); + f(1.0); f(refraction); - sh.setImageSampler(0, content); + f(0); + sh + ..setImageSampler(0, content) + ..setImageSampler(1, field); final r = ui.PictureRecorder(); ui.Canvas(r) .drawRect(const Rect.fromLTWH(0, 0, 360, 300), Paint()..shader = sh); - return r.endRecording().toImageSync(w, h); + final image = r.endRecording().toImageSync(w, h); + field.dispose(); + return image; } final off = await render(0.0, 6.0); diff --git a/test/shaders/weather_sky_shader_test.dart b/test/shaders/weather_sky_shader_test.dart index ff37310f1..c3d1f1263 100644 --- a/test/shaders/weather_sky_shader_test.dart +++ b/test/shaders/weather_sky_shader_test.dart @@ -80,12 +80,16 @@ void main() { 0.8, // iSunIntensity 0.0, 0.0, // fog, inner 0.1, // iWhitePer - 16, 16, // iLutSize — the sampler is the 1×N sky column; `.y` is height + 16, 16, // iLutSize — slot kept; the LUT sampler is gone 256, 288, // iTexSize 0.44, 0.50, 0.56, // iBaseSky — CPU-baked base probe 0.46, 0.52, 0.58, // iHazeSky — CPU-baked haze probe + 0.40, 0.45, 0.50, // iSunLit + 0.30, 0.32, 0.34, // iGroundLit + 0.35, 0.38, 0.42, // iAmbientLit + 0.50, 0.55, 0.60, // iBackSky ], - samplers: 2, + samplers: 1, ), ( asset: 'shaders/weather/sun_flare.frag', @@ -179,8 +183,9 @@ void main() { 1.0, 1.0, 2.0, // static size / amount / speed (the rain set, 4..9) 1.4, 1.0, 1.3, // running size / amount / speed 0.6, // uAlpha + 0.0, // uMode — refract, sampling the coverage bake ], - samplers: 1, + samplers: 2, ), ( asset: 'shaders/weather/card_water.frag', diff --git a/test/shared/map/geo_circle_test.dart b/test/shared/map/geo_circle_test.dart index 115bd3b93..adf913d9e 100644 --- a/test/shared/map/geo_circle_test.dart +++ b/test/shared/map/geo_circle_test.dart @@ -45,4 +45,21 @@ void main() { expect(againCoords, hasLength(coords.length)); expect(againCoords.first, isNot(equals(coords.first))); }); + + test('the default wavefront is a 48-gon on the circle', () { + expect(kWavefrontSteps, 48); + const centre = LatLng(23.5, 121.5); + const radius = 80000.0; + final line = circleFeature(centre, radius); + final coords = (line['geometry'] as Map)['coordinates'] as List; + expect(coords, hasLength(kWavefrontSteps + 1)); + expect(coords.first, coords.last); + for (final raw in coords) { + final point = raw as List; + final distance = centre.distanceTo( + LatLng(point[1] as double, point[0] as double), + ); + expect(distance, closeTo(radius, radius * 0.002)); + } + }); } diff --git a/test/shared/map/wavefront_cadence_test.dart b/test/shared/map/wavefront_cadence_test.dart index 3eafd55dd..5c384e840 100644 --- a/test/shared/map/wavefront_cadence_test.dart +++ b/test/shared/map/wavefront_cadence_test.dart @@ -10,4 +10,75 @@ void main() { test('a low-end phone redraws them at 5 Hz', () { expect(wavefrontTick(RenderTier.low), const Duration(milliseconds: 200)); }); + + const latitude = 23.0; + const zoom = 8.0; + + WavefrontRing ring(String id, double p, double s) => + WavefrontRing(id: id, pMetres: p, sMetres: s, latitude: latitude); + + test('a pixel is measured on 512 px tiles, as MapLibre zooms', () { + // Earth's circumference over a 512 px world at zoom 0. + expect( + metresPerPixel(latitude: 0, zoom: 0), + closeTo(40075016.686 / 512, 0.01), + ); + expect( + metresPerPixel(latitude: 0, zoom: 1), + closeTo(40075016.686 / 1024, 0.01), + ); + }); + + test('a sub-pixel step is held and a later step uploads that radius', () { + final pixel = metresPerPixel(latitude: latitude, zoom: zoom); + final onMap = {'a': ring('a', 10000, 20000)}; + final held = [ring('a', 10000 + pixel * 0.5, 20000 + pixel * 0.5)]; + expect( + wavefrontUploadDue(next: held, onMap: onMap, zoom: zoom), + isFalse, + reason: 'half a pixel is the same ring on screen', + ); + + final later = [ring('a', 10000 + pixel * 1.5, 20000 + pixel * 1.5)]; + expect(wavefrontUploadDue(next: later, onMap: onMap, zoom: zoom), isTrue); + expect(later.single.sMetres, greaterThan(onMap['a']!.sMetres)); + expect(later.single.pMetres, greaterThan(onMap['a']!.pMetres)); + }); + + test('a shrink of a pixel or more uploads the smaller current radius', () { + final pixel = metresPerPixel(latitude: latitude, zoom: zoom); + final onMap = {'a': ring('a', 20000, 40000)}; + final now = [ring('a', 20000 - pixel * 2, 40000 - pixel * 2)]; + expect(wavefrontUploadDue(next: now, onMap: onMap, zoom: zoom), isTrue); + expect(now.single.pMetres, lessThan(onMap['a']!.pMetres)); + expect(now.single.sMetres, lessThan(onMap['a']!.sMetres)); + }); + + test('a missing camera, a bad zoom, or a new alert always uploads', () { + final ringA = ring('a', 1, 1); + final same = {'a': ringA}; + expect(wavefrontUploadDue(next: [ringA], onMap: same, zoom: null), isTrue); + expect( + wavefrontUploadDue(next: [ringA], onMap: same, zoom: double.nan), + isTrue, + ); + expect( + wavefrontUploadDue(next: [ringA], onMap: const {}, zoom: zoom), + isTrue, + ); + expect( + wavefrontUploadDue(next: [ring('b', 1, 1)], onMap: same, zoom: zoom), + isTrue, + ); + }); + + test('box coverage runs on the first look and then every 200 ms', () { + final start = DateTime.utc(2026, 8, 12, 12); + expect(boxCoverageDue(null, start), isTrue); + expect( + boxCoverageDue(start, start.add(const Duration(milliseconds: 199))), + isFalse, + ); + expect(boxCoverageDue(start, start.add(kBoxCoverageInterval)), isTrue); + }); }