Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 73 additions & 16 deletions lib/features/earthquake/presentation/pages/report_replay_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String, WavefrontRing> _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<void> _updateEew() async {
Expand All @@ -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 {
Expand Down Expand Up @@ -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<String, dynamic> _eewGeoJson() {
List<WavefrontRing> _wavefrontRings(DateTime now) {
final table = _travelTimeTable;
final rings = <WavefrontRing>[];
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<String, dynamic> _eewGeoJson(DateTime now) {
final table = _travelTimeTable;
final now = widget.clock.now();
final features = <Map<String, dynamic>>[];
// A cancelled alert draws nothing: its rings would show a wave the agency
// has just said is not coming.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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.
///
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -580,10 +580,14 @@ class _RainOnCardState extends State<RainOnCard>
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,
);
}

Expand Down Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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);
Expand All @@ -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();
Expand Down
Loading