From 1d914336632561f34627db9750ce16c9fbeffef1 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 14 Sep 2026 23:11:27 +0800 Subject: [PATCH 1/3] fix(earthquake): align report day headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 地震報告日期與筆數固定對齊 Fix(en-US): align earthquake report dates and counts consistently --- .../presentation/pages/report_list_page.dart | 77 ++++++++++--------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/lib/features/earthquake/presentation/pages/report_list_page.dart b/lib/features/earthquake/presentation/pages/report_list_page.dart index 15f8fafe4..1d21a38d0 100644 --- a/lib/features/earthquake/presentation/pages/report_list_page.dart +++ b/lib/features/earthquake/presentation/pages/report_list_page.dart @@ -244,39 +244,47 @@ class _DaySection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only( - left: AppSpacing.xs, - bottom: AppSpacing.sm, - ), - child: Row( - children: [ - Flexible( - child: Text( - _dayLabel(day, l10n, locale), - style: theme.textTheme.titleSmall?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, + SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.only( + left: AppSpacing.xs, + bottom: AppSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + _dayLabel(day, l10n, locale), + softWrap: false, + style: theme.textTheme.titleSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), ), ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Divider( - height: 1, - thickness: 1, - color: colors.outlineVariant.withValues(alpha: 0.55), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Divider( + height: 1, + thickness: 1, + color: colors.outlineVariant.withValues(alpha: 0.55), + ), ), - ), - const SizedBox(width: AppSpacing.sm), - Text( - l10n.reportListDayCount(reports.length), - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onSurfaceVariant, - fontFeatures: const [FontFeature.tabularFigures()], + const SizedBox(width: AppSpacing.sm), + Text( + l10n.reportListDayCount(reports.length), + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), - ), - ], + ], + ), ), ), Material( @@ -310,20 +318,13 @@ class _DaySection extends StatelessWidget { } else if (day == today.subtract(const Duration(days: 1))) { relative = l10n.reportListYesterday; } - if (relative != null) { - final date = _relativeDayFormats - .putIfAbsent(locale, () => DateFormat.yMMMd(locale)) - .format(day); - return '$relative ($date)'; - } - // Parsing a locale's pattern is not free — memoised per locale. - return _dayFormats + final date = _dayFormats .putIfAbsent(locale, () => DateFormat.yMMMEd(locale)) .format(day); + return relative == null ? date : '$date ($relative)'; } static final Map _dayFormats = {}; - static final Map _relativeDayFormats = {}; } class _ReportTile extends StatelessWidget { From 63827295c29edf875b02984b792b6beede521f04 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 19 Sep 2026 14:00:43 +0800 Subject: [PATCH 2/3] fix(earthquake): close the gap between the report day and its rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 地震報告列表的日期標題不再在日期後面留一段空白 Fix(en-US): the report list's day heading no longer leaves a gap after the date --- .../presentation/pages/report_list_page.dart | 193 ++++++++++----- .../earthquake/report_day_header_test.dart | 220 ++++++++++++++++++ 2 files changed, 353 insertions(+), 60 deletions(-) create mode 100644 test/features/earthquake/report_day_header_test.dart diff --git a/lib/features/earthquake/presentation/pages/report_list_page.dart b/lib/features/earthquake/presentation/pages/report_list_page.dart index 1d21a38d0..f2a86b9d2 100644 --- a/lib/features/earthquake/presentation/pages/report_list_page.dart +++ b/lib/features/earthquake/presentation/pages/report_list_page.dart @@ -236,57 +236,12 @@ class _DaySection extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - final l10n = AppLocalizations.of(context); - final locale = intlDateLocale(Localizations.localeOf(context)); + final colors = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: double.infinity, - child: Padding( - padding: const EdgeInsets.only( - left: AppSpacing.xs, - bottom: AppSpacing.sm, - ), - child: Row( - children: [ - Expanded( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - _dayLabel(day, l10n, locale), - softWrap: false, - style: theme.textTheme.titleSmall?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Divider( - height: 1, - thickness: 1, - color: colors.outlineVariant.withValues(alpha: 0.55), - ), - ), - const SizedBox(width: AppSpacing.sm), - Text( - l10n.reportListDayCount(reports.length), - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onSurfaceVariant, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - ), - ), + ReportDayHeader(day: day, count: reports.length), Material( color: colors.surfaceContainer, borderRadius: AppRadius.medium, @@ -309,24 +264,142 @@ class _DaySection extends StatelessWidget { ], ); } +} - static String _dayLabel(DateTime day, AppLocalizations l10n, String locale) { - final today = taipeiCalendarDay(AppTime.utc); - String? relative; - if (day == today) { - relative = l10n.reportListToday; - } else if (day == today.subtract(const Duration(days: 1))) { - relative = l10n.reportListYesterday; - } - final date = _dayFormats - .putIfAbsent(locale, () => DateFormat.yMMMEd(locale)) - .format(day); - return relative == null ? date : '$date ($relative)'; +/// One day's heading: the date on the left, the report count on the right, and +/// a rule filling whatever is left between them. +/// +/// Test-visible so the geometry can be pinned without standing up the page's +/// repository — the flex arithmetic below is the whole reason this is a widget +/// of its own. +@visibleForTesting +class ReportDayHeader extends StatelessWidget { + const ReportDayHeader({required this.day, required this.count, super.key}); + + /// Calendar day at midnight Taipei, as [taipeiCalendarDay] returns it. + final DateTime day; + + /// How many reports this day holds. + final int count; + + /// Width held back from the date for everything to its right: the two gaps, a + /// stub of rule so the row never reads as a bare date, and room for a + /// three-digit count. + /// + /// Past this the date scales down rather than pushing the count off the row. + /// Deliberately a reserve and not a fraction of the row: a half-the-row cap + /// shrinks a Taipei date with its weekday on any phone, which is the same + /// disease as the flex layout it replaced. At phone width this leaves the date + /// well over what it needs. + static const double _trailingReserve = AppSpacing.sm * 3 + 32; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final l10n = AppLocalizations.of(context); + final locale = intlDateLocale(Localizations.localeOf(context)); + + return Padding( + padding: const EdgeInsets.only( + left: AppSpacing.xs, + bottom: AppSpacing.sm, + ), + child: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + // Bounded, but *not* flexible. An Expanded here would hand the + // date a tight half of the row: a short date then sits in a hole + // with the rule starting at the midpoint, and a long one gets + // scaled down to half width with empty space beside it. A + // Flexible has the mirror flaw — Row splits the free space evenly + // between flex children and never gives a tight child what a + // loose sibling left over, so the rule would stop at the midpoint + // and the count would drift in from the right edge. Keeping the + // date rigid leaves the rule as the only flex child, which is + // what makes it fill the real remainder. + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: (constraints.maxWidth - _trailingReserve).clamp( + 0.0, + double.infinity, + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + reportDayLabel( + day, + todayTaipei: taipeiCalendarDay(AppTime.utc), + l10n: l10n, + locale: locale, + ), + softWrap: false, + style: theme.textTheme.titleSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Divider( + height: 1, + thickness: 1, + color: colors.outlineVariant.withValues(alpha: 0.55), + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + l10n.reportListDayCount(count), + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ); } +} - static final Map _dayFormats = {}; +/// The heading's date text: the localized date, with a relative hint appended +/// in parentheses when [day] is [todayTaipei] or the day before it. +/// +/// The date always leads and is always the same format — `今天` alone answers +/// "which day is this" only for someone who already knows today's date, and a +/// relative-only heading changes meaning overnight while the list is open. +/// +/// [todayTaipei] is a parameter rather than a read of [AppTime] so this stays a +/// pure function of its inputs: "today" is the part worth testing, and a +/// process-wide clock cannot be wound forward for one test without leaking into +/// the next. +@visibleForTesting +String reportDayLabel( + DateTime day, { + required DateTime todayTaipei, + required AppLocalizations l10n, + required String locale, +}) { + String? relative; + if (day == todayTaipei) { + relative = l10n.reportListToday; + } else if (day == todayTaipei.subtract(const Duration(days: 1))) { + relative = l10n.reportListYesterday; + } + final date = _dayFormats + .putIfAbsent(locale, () => DateFormat.yMMMEd(locale)) + .format(day); + return relative == null ? date : '$date ($relative)'; } +/// One [DateFormat] per locale — building one is not cheap and a scrolling list +/// rebuilds these headings constantly. +final Map _dayFormats = {}; + class _ReportTile extends StatelessWidget { const _ReportTile({required this.report}); diff --git a/test/features/earthquake/report_day_header_test.dart b/test/features/earthquake/report_day_header_test.dart new file mode 100644 index 000000000..85b763661 --- /dev/null +++ b/test/features/earthquake/report_day_header_test.dart @@ -0,0 +1,220 @@ +/// The report list's day heading: what its date text says, and where the rule +/// and the count land. +/// +/// Both halves have already been wrong in ways nothing caught. The text used to +/// lead with `今天` and drop the weekday for today and yesterday only, so two +/// adjacent headings were in different formats. The layout then made the date a +/// flex child, which hands it a *tight* share of the row: a short date sat in a +/// hole with the rule starting at the midpoint, and a long one was scaled down +/// to half width with empty space beside it. Neither throws, neither fails a +/// build, and both look deliberate in a screenshot — hence the geometry +/// assertions below. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/earthquake/presentation/pages/report_list_page.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +/// A Saturday, so the weekday in the expected strings is not the same word in +/// two languages by accident. +final _day = DateTime(2026, 9, 19); + +Future _l10n(Locale locale) => + AppLocalizations.delegate.load(locale); + +/// The heading at a fixed [width], so the geometry assertions have a frame of +/// reference the screen size cannot move. +Widget _wrap(Widget child, {required Locale locale, required double width}) => + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: locale, + home: Scaffold( + body: Center( + child: SizedBox(width: width, child: child), + ), + ), + ); + +void main() { + group('reportDayLabel', () { + // The widget path gets this from the Material localizations delegate; a + // plain unit test has to load the date symbols itself. + setUpAll(initializeDateFormatting); + + test( + 'leads with the date and appends the relative day in parentheses', + () async { + final l10n = await _l10n(const Locale('en')); + expect( + reportDayLabel(_day, todayTaipei: _day, l10n: l10n, locale: 'en'), + 'Sat, Sep 19, 2026 (Today)', + ); + expect( + reportDayLabel( + _day.subtract(const Duration(days: 1)), + todayTaipei: _day, + l10n: l10n, + locale: 'en', + ), + 'Fri, Sep 18, 2026 (Yesterday)', + ); + }, + ); + + test('an older day is the date alone — no empty parentheses', () async { + final l10n = await _l10n(const Locale('en')); + expect( + reportDayLabel( + _day.subtract(const Duration(days: 2)), + todayTaipei: _day, + l10n: l10n, + locale: 'en', + ), + 'Thu, Sep 17, 2026', + ); + }); + + test('today carries the weekday, exactly like every older heading', () async { + // The regression this pins: today used to be formatted without the + // weekday, so it was the one heading in the list shaped differently from + // all the others sitting right under it. + final l10n = await _l10n(const Locale('en')); + String label(DateTime day) => + reportDayLabel(day, todayTaipei: _day, l10n: l10n, locale: 'en'); + + expect(label(_day), startsWith('Sat, ')); + expect( + label(_day.subtract(const Duration(days: 1))), + startsWith('Fri, '), + ); + expect( + label(_day.subtract(const Duration(days: 30))), + startsWith('Thu, '), + ); + }); + + test('the relative word comes from l10n, not a hardcoded string', () async { + final zh = await _l10n(const Locale('zh', 'TW')); + final en = await _l10n(const Locale('en')); + expect( + reportDayLabel(_day, todayTaipei: _day, l10n: zh, locale: 'zh_TW'), + endsWith('(今天)'), + ); + expect( + reportDayLabel(_day, todayTaipei: _day, l10n: en, locale: 'en'), + endsWith('(Today)'), + ); + }); + + test('a day two years back is still just a date', () async { + final l10n = await _l10n(const Locale('en')); + final label = reportDayLabel( + DateTime(2024, 4, 3), + todayTaipei: _day, + l10n: l10n, + locale: 'en', + ); + expect(label, 'Wed, Apr 3, 2024'); + expect(label, isNot(contains('('))); + }); + }); + + group('ReportDayHeader layout', () { + /// Rect of the date's own box — the [FittedBox] sizes itself to the text, + /// so this is the width the date actually occupies, not its flex share. + Rect labelRect(WidgetTester tester) => + tester.getRect(find.byType(FittedBox)); + + Rect ruleRect(WidgetTester tester) => tester.getRect(find.byType(Divider)); + + testWidgets( + 'the rule starts right after a short date, not at the midpoint', + (tester) async { + await tester.pumpWidget( + _wrap( + ReportDayHeader(day: _day, count: 3), + locale: const Locale('zh', 'TW'), + width: 800, + ), + ); + + final label = labelRect(tester); + final rule = ruleRect(tester); + // The whole point: one gap between them and the leftover is the rule's. + // A flex date would put `rule.left` near the row's middle instead. + expect(rule.left, moreOrLessEquals(label.right + AppSpacing.sm)); + expect(rule.width, greaterThan(label.width)); + }, + ); + + testWidgets('the count sits at the far right, one gap past the rule', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + ReportDayHeader(day: _day, count: 12), + locale: const Locale('zh', 'TW'), + width: 400, + ), + ); + + final rule = ruleRect(tester); + final count = tester.getRect(find.text('12')); + final row = tester.getRect(find.byType(Row)); + expect(count.left, moreOrLessEquals(rule.right + AppSpacing.sm)); + expect(count.right, moreOrLessEquals(row.right)); + }); + + testWidgets('a long date on a narrow row shrinks instead of overflowing', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + // German runs long: "Sa., 19. Sept. 2026 (Heute)". + ReportDayHeader(day: _day, count: 999), + locale: const Locale('de'), + width: 240, + ), + ); + + expect(tester.takeException(), isNull); + final row = tester.getRect(find.byType(Row)); + // Nothing was pushed off the end: the rule survives and the count is + // still inside the row. + expect(ruleRect(tester).width, greaterThan(0)); + expect(find.text('999'), findsOneWidget); + expect( + tester.getRect(find.text('999')).right, + lessThanOrEqualTo(row.right + 0.5), + ); + }); + + testWidgets('the date is the same width on a phone row and a tablet one', ( + tester, + ) async { + // Both directions of the old bug in one assertion. A flex date grows with + // the row (hole after a short date); a fraction-of-the-row cap shrinks it + // on the narrow row (scaled-down text with space to spare beside it). + // Neither may happen: only the rule may change width. + final labels = []; + final rules = []; + for (final rowWidth in const [324.0, 800.0]) { + await tester.pumpWidget( + _wrap( + ReportDayHeader(day: _day, count: 3), + locale: const Locale('zh', 'TW'), + width: rowWidth, + ), + ); + labels.add(labelRect(tester).width); + rules.add(ruleRect(tester).width); + } + expect(labels[0], moreOrLessEquals(labels[1])); + expect(rules[1] - rules[0], moreOrLessEquals(800 - 324)); + }); + }); +} From d26e4b0af0b907461db457c8cdde1a58c0cb3724 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 19 Sep 2026 14:06:01 +0800 Subject: [PATCH 3/3] fix(map): draw township names under the monitor's station readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 強震監視器與報告回放地圖的鄉鎮名稱不再蓋住測站讀數 Fix(en-US): township names no longer cover the station readings on the monitor and replay maps --- .../pages/report_replay_page.dart | 329 ++------------ .../map/presentation/layers/rts_layer.dart | 406 ++--------------- lib/shared/map/monitor_map_stack.dart | 420 ++++++++++++++++++ .../layers/rts_layer_demo_test.dart | 63 ++- 4 files changed, 555 insertions(+), 663 deletions(-) create mode 100644 lib/shared/map/monitor_map_stack.dart diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index c614f91a8..87a683204 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -13,7 +13,6 @@ library; import 'dart:async'; -import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/geo/town_directory.dart'; @@ -37,7 +36,6 @@ import 'package:dpip/core/settings/eew_spoken_announcement_settings.dart'; import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; import 'package:dpip/core/geo/location_service.dart'; import 'package:dpip/shared/seismic/intensity.dart'; -import 'package:dpip/shared/seismic/intensity_circle_renderer.dart'; import 'package:dpip/features/earthquake/domain/rts_box_grid.dart'; import 'package:dpip/features/earthquake/domain/seismic_station.dart'; import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; @@ -48,7 +46,6 @@ import 'package:dpip/features/earthquake/presentation/pages/report_list_page.dar show ReportListPage; import 'package:dpip/features/earthquake/presentation/widgets/eew_card.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; -import 'package:dpip/shared/seismic/intensity_icon_renderer.dart'; import 'package:dpip/features/earthquake/replay_session.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/color_hex.dart'; @@ -59,15 +56,10 @@ import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/geo_circle.dart'; import 'package:dpip/shared/map/map_compass.dart'; import 'package:dpip/shared/map/map_gsi_overlay.dart'; -import 'package:dpip/shared/map/map_station_labels.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/map/map_style.dart' - show - MapColors, - countyFillLayerId, - landLayerId, - townFillLayerId, - townLabelLayerId; + show MapColors, countyFillLayerId, townFillLayerId, townLabelLayerId; +import 'package:dpip/shared/map/monitor_map_stack.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/widgets/frosted_surface.dart'; import 'package:dpip/shared/widgets/collapsible_map_legend.dart'; @@ -79,11 +71,6 @@ import 'package:intl/intl.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; -const Map _emptyCollection = { - 'type': 'FeatureCollection', - 'features': [], -}; - /// Replays RTS + EEW starting at [replayTimestamp] (Unix ms). class ReportReplayPage extends StatefulWidget { const ReportReplayPage({super.key, required this.replayTimestamp}); @@ -441,86 +428,11 @@ class _ReplayMap extends StatefulWidget { } class _ReplayMapState extends State<_ReplayMap> { - static const String _crossIcon = 'replay-cross'; - static const String _rtsSourceId = 'replay-rts-src'; - static const String _rtsCircleId = 'replay-rts-circle'; - static const String _rtsLabelId = 'replay-rts-label'; - - /// Per-station discrete-reading badge — a circular version of the legacy - /// monitor's square `intensity` layer (see [IntensityCircleRenderer]): - /// while a large event's detection boxes are up, each shaking station gets - /// a numbered badge over its dot instead of the plain colour, but the - /// shape stays a circle. `icon` is empty for a station with nothing to - /// badge, so a plain dot underneath just keeps showing through. - static const String _rtsIntensityCircleId = 'replay-rts-intensity-circle'; - - /// Shared by the dot layer's `circleSortKey` and the badge layer's - /// `symbolSortKey`: higher effective intensity draws on top in both, so a - /// calmer, overlapping station never hides a hotter one. Reads `sort` (see - /// [_rtsGeoJson]), the alert-aware value the badge is actually drawn from, - /// not the raw `i` — a sort key stuck on `i` let a lower badge draw over a - /// higher one the moment an alert's discrete reading diverged from the - /// station's own raw sensor value. - static const List _rtsSortKey = [ - 'coalesce', - ['get', 'sort'], - -5, - ]; - - /// The discrete-reading badge's on-map scale of its 64px artwork. The - /// legacy monitor's own badge layer used 0.2 at z5 → 0.8 at z10, but that - /// assumed native-resolution PNG assets — applied to a baked canvas here it - /// renders at only device-pixel size, ~13px on a 3x display and effectively - /// invisible. [ReportDetailPage] already solved this for the same 64px - /// canvas class ([IntensityIconRenderer]); reusing its scale here. - static const List _badgeIconSize = [ - 'interpolate', - ['linear'], - ['zoom'], - 5, - 0.75, - 15, - 1.7, - ]; - - static const String _boxSourceId = 'replay-box-src'; - static const String _boxLineLayerId = 'replay-box-line'; - static const String _eewSourceId = 'replay-eew-src'; - static const String _pWaveLayerId = 'replay-eew-p'; - static const String _sWaveFillLayerId = 'replay-eew-s-fill'; - static const String _sWaveLayerId = 'replay-eew-s'; - static const String _epicenterLayerId = 'replay-eew-epicenter'; - - /// Box-grid border colour by intensity `i`: red ≥4, yellow 2–3, green below - /// — ported from the legacy monitor's box colour scheme. Border only (no - /// fill) so the boxes don't obscure the map underneath. - static const List _boxColorExpression = [ - 'case', - [ - '>=', - ['get', 'i'], - 4, - ], - '#FF0000', - [ - '>=', - ['get', 'i'], - 2, - ], - '#EAC100', - '#00DB00', - ]; - - /// Dot radius by zoom — same scale [RtsMapLayer] uses (2px at z4 → 8px z12). - static const List _rtsRadiusExpression = [ - 'interpolate', - ['linear'], - ['zoom'], - 4, - 2.0, - 12, - 8.0, - ]; + /// Every source/layer/image id this map owns — `replay-rts-circle`, + /// `replay-eew-epicenter` and the rest, derived from the `replay` prefix so + /// they can never collide with the live monitor's `rts-` stack. The shape and + /// paint of the stack are [addMonitorLayers], shared with that monitor. + static const MonitorLayerIds _ids = replayMonitorIds; MapLibreMapController? _controller; Map _stations = const {}; @@ -666,22 +578,22 @@ class _ReplayMapState extends State<_ReplayMap> { final hasBox = widget.rts.box.isNotEmpty; if (hasBox) { _boxVisible = !_boxVisible; - await controller.setLayerVisibility(_boxLineLayerId, _boxVisible); + await controller.setLayerVisibility(_ids.boxLine, _boxVisible); } else if (_boxVisible) { _boxVisible = false; - await controller.setLayerVisibility(_boxLineLayerId, false); + await controller.setLayerVisibility(_ids.boxLine, false); } final hasEew = widget.eew.alerts.isNotEmpty; if (hasEew) { _epicenterVisible = !_epicenterVisible; await controller.setLayerVisibility( - _epicenterLayerId, + _ids.eewEpicenter, _epicenterVisible, ); } else if (_epicenterVisible) { _epicenterVisible = false; - await controller.setLayerVisibility(_epicenterLayerId, false); + await controller.setLayerVisibility(_ids.eewEpicenter, false); } } catch (_) { // Layers gone mid style-reload — the next blink retries. @@ -776,185 +688,13 @@ class _ReplayMapState extends State<_ReplayMap> { // A style (re)load recreates every source below empty — whatever the box // source held before is gone, so the next [_updateBox] must not skip. _boxSignature = null; - try { - final data = await IntensityIconRenderer.render('cross'); - await controller.addImage(_crossIcon, data); - - await controller.addSource( - _rtsSourceId, - GeojsonSourceProperties(data: _emptyCollection), - ); - await controller.addCircleLayer( - _rtsSourceId, - _rtsCircleId, - CircleLayerProperties( - // A `grey`-flagged feature (see [_rtsGeoJson]) paints the discrete - // scale's own 0-grey instead of the continuous ramp: a station on - // a large event's alert list reading a flat 0 stays visibly part - // of the network rather than fading into whatever pale colour the - // ramp gives a near-zero reading — ported from the legacy - // monitor's separate `intensity0` grey layer. - circleColor: [ - 'case', - [ - '==', - ['get', 'grey'], - 1, - ], - IntensityColors.discrete(0).toHexRgb(), - InstrumentalIntensityColors.mapLibreInterpolate, - ], - circleRadius: _rtsRadiusExpression, - circleStrokeColor: '#9E9E9E', - circleStrokeWidth: 1, - // Higher intensity draws on top of a calmer, overlapping dot — - // ported from the legacy monitor's `circleSortKey: coalesce(get('i'), - // -5)`; missing this let station dots stack in whatever order the - // feed happened to list them, same class of bug as the box layer's - // missing sort key. - circleSortKey: _rtsSortKey, - ), - // Station dots under the township names — the wavefront must never - // hide where you are. - belowLayerId: townLabelLayerId, - ); - await controller.addSymbolLayer( - _rtsSourceId, - _rtsLabelId, - stationLabelProps( - textField: const ['get', 'label'], - textSize: 10, - ), - minzoom: 10, - // Township names stay the top-most text; station labels give way on - // collision (the layer order decides who wins placement). - belowLayerId: townLabelLayerId, - ); - await _loadIntensityCircleIcons(controller); - // The discrete-reading badge — always on top of the plain dot (added - // after the circle/label above, same anchor); `icon` is empty for - // most stations most of the time, so this is a no-op render for them. - await controller.addSymbolLayer( - _rtsSourceId, - _rtsIntensityCircleId, - const SymbolLayerProperties( - iconImage: ['get', 'icon'], - // The baked artwork is a fixed 64px canvas — left at the default - // 1.0 it drew full-size at every zoom, badge circles swallowing - // whole townships. Scales with zoom instead, same stops as the - // legacy monitor's own badge layer. - iconSize: _badgeIconSize, - iconAllowOverlap: true, - iconIgnorePlacement: true, - // Same "stronger wins" rule as the dot layer's circleSortKey above - // — two badges can overlap just like two dots can, and a low - // reading must never paint over a high one. (`symbol-z-order` - // defaults to `auto`, which honours the sort key; naming it - // `source` here would silently drop back to feed-iteration order.) - symbolSortKey: _rtsSortKey, - ), - belowLayerId: townLabelLayerId, - ); - } catch (e, st) { - Log.handle(e, st, 'replay map render failed'); - } - // RTS box grid, in its own try/catch — before the EEW wave/epicentre - // setup below, so it stacks *below* the epicentre cross and the P/S wave - // rings once both are anchored at the same [townLabelLayerId] (each - // insertion goes directly below its anchor, so the later one ends up on - // top) — matching the legacy monitor's insertion order: box, then wave - // rings, then epicentre last/topmost. Isolated so a failure here can - // never take down the station dots / EEW wave circles. - try { - await controller.addSource( - _boxSourceId, - GeojsonSourceProperties(data: _emptyCollection), - ); - await controller.addLineLayer( - _boxSourceId, - _boxLineLayerId, - const LineLayerProperties( - lineColor: _boxColorExpression, - lineWidth: 2, - visibility: 'none', - // Draw order for overlapping boxes — red (`i` highest) always on - // top, then yellow, then green, matching the legacy monitor's box - // layer (`lineSortKey: [Expressions.get, 'i']`). Without this, - // overlapping boxes stack in whatever order the feed happened to - // list them, so a low-intensity box could paint over a red one. - lineSortKey: ['get', 'i'], - ), - // Detection-box borders stay under the township names. - belowLayerId: townLabelLayerId, - ); - } catch (e, st) { - Log.handle(e, st, 'replay box layer render failed'); - } - // EEW epicentre + P/S wave rings, isolated so a failure here can never - // take down the station dots / box grid set up above. - try { - await controller.addSource( - _eewSourceId, - GeojsonSourceProperties(data: _emptyCollection), - ); - // The S wave's translucent disc ("inner circle") — the damaging, - // already-shaking zone. Anchored below [landLayerId] (below the whole - // land/county/town area, not just its borders) so the wash only shows - // over open sea, never over Taiwan itself. The P wave is a heads-up - // leading edge only, no fill. Both outline rings are added with no - // `belowLayerId`, so they stack on top of the map as normal. - await controller.addFillLayer( - _eewSourceId, - _sWaveFillLayerId, - FillLayerProperties(fillColor: '#FF3B30'.vision, fillOpacity: 0.16), - belowLayerId: landLayerId, - filter: const [ - '==', - ['get', 'type'], - 's-fill', - ], - ); - await controller.addLineLayer( - _eewSourceId, - _pWaveLayerId, - LineLayerProperties(lineColor: '#00E5FF'.vision, lineWidth: 2), - belowLayerId: townLabelLayerId, - filter: const [ - '==', - ['get', 'type'], - 'p-line', - ], - ); - await controller.addLineLayer( - _eewSourceId, - _sWaveLayerId, - LineLayerProperties(lineColor: '#FF3B30'.vision, lineWidth: 2), - belowLayerId: townLabelLayerId, - filter: const [ - '==', - ['get', 'type'], - 's-line', - ], - ); - await controller.addSymbolLayer( - _eewSourceId, - _epicenterLayerId, - const SymbolLayerProperties( - iconImage: _crossIcon, - iconSize: 1.0, - iconAllowOverlap: true, - iconIgnorePlacement: true, - ), - belowLayerId: townLabelLayerId, - filter: const [ - '==', - ['get', 'type'], - 'x', - ], - ); - } catch (e, st) { - Log.handle(e, st, 'replay map render failed'); - } + // 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 + // exactly where drift is invisible: a stacking change made on one surface + // simply did not happen on the other, and nothing failed — the map just + // looked wrong on one page. + await addMonitorLayers(controller, _ids, logTag: 'replay'); _ready = true; await _ensureStations(); unawaited(_updateRts()); @@ -1004,23 +744,12 @@ class _ReplayMapState extends State<_ReplayMap> { if (controller == null || !_ready) return; if (_stations.isEmpty) await _ensureStations(); try { - await controller.setGeoJsonSource(_rtsSourceId, _rtsGeoJson()); + await controller.setGeoJsonSource(_ids.stationSource, _rtsGeoJson()); } catch (_) { // Source not on the map yet (mid style-reload) — the next update retries. } } - /// Registers the 18 circular discrete-reading badges (1–9 light + dark) — - /// drawn in code (see [IntensityCircleRenderer]), loaded once per style load. - Future _loadIntensityCircleIcons( - MapLibreMapController controller, - ) async { - final icons = await IntensityCircleRenderer.renderAll(); - for (final entry in icons.entries) { - await controller.addImage(entry.key, entry.value); - } - } - /// Updates the box-grid overlay: a large event the feed reports at /// box-grid resolution (`rts.box` non-empty) draws the coloured grid cells /// *alongside* the per-station dots (not a replacement) — the box only @@ -1044,7 +773,7 @@ class _ReplayMapState extends State<_ReplayMap> { // source ends up holding — and it must be the one recorded here. _boxSignature = signature; try { - await controller.setGeoJsonSource(_boxSourceId, geoJson); + await controller.setGeoJsonSource(_ids.boxSource, geoJson); } catch (_) { // Source/layer not on the map yet (mid style-reload) — the next update // retries; the claim is dropped because the write never landed. @@ -1052,13 +781,13 @@ class _ReplayMapState extends State<_ReplayMap> { } } - /// The feature set [_boxSourceId] last received — the box ids that survived + /// The feature set [_ids.boxSource] last received — the box ids that survived /// the coverage check with their intensities, in feed order (see /// [_boxGeoJson]). Null whenever the source has just been (re)created, so /// the first upload after a style load always lands. String? _boxSignature; - /// Whether [_eewSourceId] currently holds the empty collection — mirrors + /// Whether [_ids.eewSource] currently holds the empty collection — mirrors /// the live monitor's flag. The old blanket `alerts.isEmpty` skip made the /// one *clearing* write unreachable: once the replayed alert expired, the /// last P/S wavefront rings and the county shaking fill stayed frozen on @@ -1083,8 +812,8 @@ class _ReplayMapState extends State<_ReplayMap> { if (empty && _eewSourceEmpty) return; try { await controller.setGeoJsonSource( - _eewSourceId, - empty ? _emptyCollection : _eewGeoJson(), + _ids.eewSource, + empty ? monitorEmptyCollection : _eewGeoJson(), ); _eewSourceEmpty = empty; } catch (_) { @@ -1192,7 +921,7 @@ class _ReplayMapState extends State<_ReplayMap> { // Large event: the feed also carries box-grid data. The legacy monitor // decluttered to just the stations that registered something and badged // each with its discrete reading — ported here as a circular badge (see - // [_rtsIntensityCircleId]), never a shape swap: the dot underneath is + // [MonitorLayerIds.stationBadge]), never a shape swap: the dot underneath is // still the same circle, the badge is just a fuller circle drawn over it. final hasBox = widget.rts.box.isNotEmpty; final dark = Theme.of(context).brightness == Brightness.dark; @@ -1218,11 +947,11 @@ class _ReplayMapState extends State<_ReplayMap> { 'properties': { 'i': data.intensityRaw, // Sort key for both the dot and the badge layer — see - // [_rtsSortKey] for why this must be [effective], not the raw `i`. + // [monitorSortKey] for why this must be [effective], not the raw `i`. 'sort': effective, 'label': '${entry.key}\n${data.intensityRaw.toStringAsFixed(1)}', 'icon': hasBox && level > 0 - ? _intensityCircleIcon(level, dark: dark) + ? monitorBadgeIcon(level, dark: dark) : '', // Only reachable when `alert` is true (the filter above already // dropped a calm zero) — an alerting station reading a flat 0 @@ -1235,10 +964,6 @@ class _ReplayMapState extends State<_ReplayMap> { return {'type': 'FeatureCollection', 'features': features}; } - /// The circular badge icon for scale index 1–9, dark or light artwork. - static String _intensityCircleIcon(int level, {required bool dark}) => - dark ? 'circle-$level-dark' : 'circle-$level'; - /// One polygon per box id present in the live feed's `rts.box`, joined /// against the static [grid] for its geometry — dropping any box the /// S-wave has already fully swept past (see [_isBoxFullyCovered]) so it diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 85163203a..888daab96 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -6,9 +6,7 @@ library; import 'dart:async'; import 'dart:math' as math; -import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/core/geo/town_directory.dart'; -import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/models/lat_lng.dart' as geo; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; @@ -25,14 +23,9 @@ 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/map_layer.dart'; -import 'package:dpip/shared/map/map_station_labels.dart'; import 'package:dpip/shared/map/map_style.dart' - show - MapColors, - countyFillLayerId, - landLayerId, - townFillLayerId, - townLabelLayerId; + show MapColors, countyFillLayerId, townFillLayerId; +import 'package:dpip/shared/map/monitor_map_stack.dart'; import 'package:dpip/shared/seismic/intensity.dart'; import 'package:dpip/shared/seismic/intensity_circle_renderer.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; @@ -110,8 +103,8 @@ Map eewWaveGeoJson( /// detection boxes (see [_pushBox]) mirror the legacy monitor and the replay /// page. Station dots stay circles always: while a large event's box grid is /// up, a shaking station gets a circular discrete-reading badge (see -/// [_intensityCircleId]/[IntensityCircleRenderer]) drawn over its dot, but -/// that badge is still a circle — the discrete-intensity *square* badges +/// [MonitorLayerIds.stationBadge]/[IntensityCircleRenderer]) drawn over its +/// dot, but that badge is still a circle — the discrete-intensity *square* badges /// ([IntensityIconRenderer]) are 震度速報/地震報告 artwork, a different data /// product from this feed's live instrumental reading, and this layer never /// reaches for those instead. Not tap- or timeline-driven; its "sheet" is a @@ -197,7 +190,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// next push always lands. String? _boxOnMap; - /// Whether the EEW source on the map currently holds [_emptyCollection]. + /// Whether the EEW source on the map is currently the empty collection. /// /// [_pushUpdate] ends with an unconditional [_pushEew], and the RTS feed /// notifies about once a second, so a **calm** feed was re-uploading the same @@ -221,46 +214,11 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// per-tick round trip when a status change re-notifies without new data. Object? _lastSent; - static const String _sourceId = 'rts-src'; - static const String _circleId = 'rts-circle'; - static const String _labelId = 'rts-label'; - - /// Per-station discrete-reading badge — a circular version of the legacy - /// monitor's square `intensity` layer (see [IntensityCircleRenderer]): - /// while a large event's detection boxes are up, each shaking station gets - /// a numbered badge over its dot instead of the plain colour, but the - /// shape stays a circle — this is still live instrumental data, never the - /// report/rapid-report square. `icon` is empty for a station with nothing - /// to badge, so a plain dot underneath just keeps showing through. - static const String _intensityCircleId = 'rts-intensity-circle'; - static const String _eewSourceId = 'rts-eew-src'; - static const String _eewPWaveId = 'rts-eew-p'; - static const String _eewSWaveId = 'rts-eew-s'; - static const String _eewSWaveFillId = 'rts-eew-s-fill'; - static const String _eewEpicenterId = 'rts-eew-epicenter'; - static const String _eewCrossIcon = 'rts-eew-cross'; - - static const String _boxSourceId = 'rts-box-src'; - static const String _boxLineId = 'rts-box-line'; - - /// Box-grid border colour by intensity `i`: red ≥4, yellow 2–3, green - /// below — ported from the legacy monitor's box colour scheme. - static const List _boxColorExpression = [ - 'case', - [ - '>=', - ['get', 'i'], - 4, - ], - '#FF0000', - [ - '>=', - ['get', 'i'], - 2, - ], - '#EAC100', - '#00DB00', - ]; + /// Every source/layer/image id this overlay owns — `rts-src`, `rts-circle`, + /// `rts-eew-epicenter` and the rest, derived from the `rts` prefix. The + /// stack's shape and paint live in [addMonitorLayers], shared with the report + /// 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 @@ -276,20 +234,6 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { static const double _liveOpacity = 1.0; static const double _staleOpacity = 0.35; - /// A neutral hairline separating overlapping dots — legacy uses the theme's - /// outlineVariant, but render() has no BuildContext, so a mid-grey that reads - /// on both light and dark tiles stands in. - /// - /// A getter, not a `const`: the colour-vision transform runs at the - /// definition and isn't a compile-time constant. (It is the identity on a - /// pure grey — routing it anyway keeps the rule uniform for whoever tints - /// this later.) - static String get _strokeColor => '#9E9E9E'.vision; - static const Map _emptyCollection = { - 'type': 'FeatureCollection', - 'features': [], - }; - /// This layer's `MapLayer.id` — 強震監視器. /// /// A constant as well as the getter because a caller outside the map needs @@ -326,87 +270,23 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { Future render(MapLibreMapController controller) async { _controller = controller; await _ensureStations(); - await _removeFromMap(controller); - await controller.addSource( - _sourceId, - GeojsonSourceProperties(data: _geoJson()), + await removeMonitorLayers(controller, _ids); + // The whole stack — sources, layers, badge icons, and the order they mount + // in — is [addMonitorLayers], shared with the report replay map. The + // stacking rules it enforces (everything appended above the base style's + // township names, only the EEW S-wave disc anchored) are documented there, + // once, so they cannot be changed on one surface and silently missed on + // the other. + await addMonitorLayers( + controller, + _ids, + stationData: _geoJson(), + dotOpacity: _liveOpacity, + logTag: 'rts', ); - await controller.addCircleLayer( - _sourceId, - _circleId, - _circleProps(_liveOpacity), - // Station dots under the township names — a live reading must never - // hide where you are. - belowLayerId: townLabelLayerId, - ); - // Station id over its raw intensity, pinned under the dot; the sort key - // lets hot stations win placement (see [stationLabelProps]). - await controller.addSymbolLayer( - _sourceId, - _labelId, - _labelProps(_liveOpacity), - minzoom: 10, - // Township names stay the top-most text — station labels give way to - // them on collision (the layer order decides who wins placement). - belowLayerId: townLabelLayerId, - ); - await _loadIntensityCircleIcons(controller); - // The discrete-reading badge — always on top of the plain dot (added - // after [_circleId]/[_labelId], same anchor); `icon` is empty for most - // stations most of the time, so this is a no-op render for them. - await controller.addSymbolLayer( - _sourceId, - _intensityCircleId, - const SymbolLayerProperties( - iconImage: ['get', 'icon'], - // The baked artwork is a fixed 64px canvas — left at the default - // 1.0 it drew full-size at every zoom, badge circles swallowing - // whole townships. Scales with zoom instead, same stops as the - // legacy monitor's own badge layer. - iconSize: _badgeIconSize, - iconAllowOverlap: true, - iconIgnorePlacement: true, - // Same "stronger wins" rule as the dot layer's circleSortKey below — - // two badges can overlap just like two dots can, and a low reading - // must never paint over a high one. (`symbol-z-order` defaults to - // `auto`, which honours the sort key; naming it `source` here would - // silently drop back to feed-iteration order.) - symbolSortKey: _sortKey, - ), - belowLayerId: townLabelLayerId, - ); - // RTS box grid, in its own try/catch — before the EEW wave/epicentre - // setup below, so it stacks *below* the epicentre cross and the P/S wave - // rings once both are anchored at the same [townLabelLayerId] (each - // insertion goes directly below its anchor, so the later one ends up on - // top) — matching the legacy monitor's insertion order: box, then wave - // rings, then epicentre last/topmost. Isolated so a failure here can - // never take down the station dots / EEW layers. - try { - await controller.addSource( - _boxSourceId, - GeojsonSourceProperties(data: _emptyCollection), - ); - await controller.addLineLayer( - _boxSourceId, - _boxLineId, - const LineLayerProperties( - lineColor: _boxColorExpression, - lineWidth: 2, - visibility: 'none', - // Red always draws over yellow/green — ported from the legacy - // monitor's box layer (`lineSortKey: [Expressions.get, 'i']`). - lineSortKey: ['get', 'i'], - ), - belowLayerId: townLabelLayerId, - ); - } catch (e, st) { - Log.handle(e, st, 'rts box layer render failed'); - } - await _setupEew(controller); _added = true; _appliedStatus = null; - // [_setupEew] has just seeded the source with [_emptyCollection]. + // [addMonitorLayers] has just seeded the EEW source empty. _eewSourceEmpty = true; // The box source was just re-added empty above. _boxOnMap = null; @@ -503,8 +383,8 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { if (!identical(payloadKey, _lastSent)) { _lastSent = payloadKey; await controller.setGeoJsonSource( - _sourceId, - offline ? _emptyCollection : _geoJson(), + _ids.stationSource, + offline ? monitorEmptyCollection : _geoJson(), ); } if (status != _appliedStatus) { @@ -512,8 +392,14 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { final opacity = status == RealtimeStatus.live ? _liveOpacity : _staleOpacity; - await controller.setLayerProperties(_circleId, _circleProps(opacity)); - await controller.setLayerProperties(_labelId, _labelProps(opacity)); + await controller.setLayerProperties( + _ids.stationDot, + monitorDotProps(opacity: opacity), + ); + await controller.setLayerProperties( + _ids.stationLabel, + monitorLabelProps(opacity: opacity), + ); } } catch (_) { // Source not on the map (mid style-reload); the next render re-adds it. @@ -536,8 +422,8 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { if (!live && _eewSourceEmpty) return; try { await controller.setGeoJsonSource( - _eewSourceId, - live ? _eewGeoJson() : _emptyCollection, + _ids.eewSource, + live ? _eewGeoJson() : monitorEmptyCollection, ); _eewSourceEmpty = !live; } catch (_) { @@ -550,88 +436,6 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { Map _eewGeoJson() => eewWaveGeoJson(_eew.state.data ?? const [], _travelTime, AppTime.utc); - /// Adds the EEW source + layers: the S wave's translucent disc ("inner - /// circle") — the damaging, already-shaking zone — anchored below the land - /// layer so the wash only shows over open sea, never over Taiwan itself. The - /// P wave is a heads-up leading edge only, no fill; the epicentre cross sits - /// on top. The cross artwork is drawn in code, like every other map icon — - /// the legacy PNG `assets/map/icons/cross.png` does not exist and must not - /// be loaded. Isolated in its own try/catch so a failure here can never take - /// down the station dots set up above. - Future _setupEew(MapLibreMapController controller) async { - try { - final data = await IntensityIconRenderer.render('cross'); - await controller.addImage(_eewCrossIcon, data.buffer.asUint8List()); - await controller.addSource( - _eewSourceId, - GeojsonSourceProperties(data: _emptyCollection), - ); - await controller.addFillLayer( - _eewSourceId, - _eewSWaveFillId, - // Vector geometry we draw ourselves, so it recolours with the app. - FillLayerProperties(fillColor: '#FF3B30'.vision, fillOpacity: 0.16), - belowLayerId: landLayerId, - filter: const [ - '==', - ['get', 'type'], - 's-fill', - ], - ); - await controller.addLineLayer( - _eewSourceId, - _eewPWaveId, - LineLayerProperties(lineColor: '#00E5FF'.vision, lineWidth: 2), - belowLayerId: townLabelLayerId, - filter: const [ - '==', - ['get', 'type'], - 'p-line', - ], - ); - await controller.addLineLayer( - _eewSourceId, - _eewSWaveId, - LineLayerProperties(lineColor: '#FF3B30'.vision, lineWidth: 2), - belowLayerId: townLabelLayerId, - filter: const [ - '==', - ['get', 'type'], - 's-line', - ], - ); - await controller.addSymbolLayer( - _eewSourceId, - _eewEpicenterId, - const SymbolLayerProperties( - iconImage: _eewCrossIcon, - iconSize: 1.0, - iconAllowOverlap: true, - iconIgnorePlacement: true, - ), - belowLayerId: townLabelLayerId, - filter: const [ - '==', - ['get', 'type'], - 'x', - ], - ); - } catch (e, st) { - Log.handle(e, st, 'rts EEW layer render failed'); - } - } - - /// Registers the 18 circular discrete-reading badges (1–9 light + dark) — - /// drawn in code (see [IntensityCircleRenderer]), loaded once per render. - Future _loadIntensityCircleIcons( - MapLibreMapController controller, - ) async { - final icons = await IntensityCircleRenderer.renderAll(); - for (final entry in icons.entries) { - await controller.addImage(entry.key, entry.value); - } - } - /// Updates the box-grid overlay: a large event the feed reports at /// box-grid resolution (`rts.box` non-empty) draws the coloured grid cells /// *alongside* the per-station dots (not a replacement) — the box only @@ -648,13 +452,13 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { if (hasBox) { final (geoJson, signature) = _boxGeoJson(grid); if (signature != _boxOnMap) { - await controller.setGeoJsonSource(_boxSourceId, geoJson); + await controller.setGeoJsonSource(_ids.boxSource, geoJson); _boxOnMap = signature; } } if (hasBox != _boxVisible) { _boxVisible = hasBox; - await controller.setLayerVisibility(_boxLineId, hasBox); + await controller.setLayerVisibility(_ids.boxLine, hasBox); } } catch (_) { // Source/layer not on the map yet (mid style-reload) — the next update retries. @@ -760,10 +564,10 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { final hasBox = (_feed.state.data?.box.isNotEmpty) ?? false; if (hasBox) { _boxVisible = !_boxVisible; - await controller.setLayerVisibility(_boxLineId, _boxVisible); + await controller.setLayerVisibility(_ids.boxLine, _boxVisible); } else if (_boxVisible) { _boxVisible = false; - await controller.setLayerVisibility(_boxLineId, false); + await controller.setLayerVisibility(_ids.boxLine, false); } final hasEew = @@ -772,12 +576,12 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { if (hasEew) { _epicenterVisible = !_epicenterVisible; await controller.setLayerVisibility( - _eewEpicenterId, + _ids.eewEpicenter, _epicenterVisible, ); } else if (!_epicenterVisible) { _epicenterVisible = true; - await controller.setLayerVisibility(_eewEpicenterId, true); + await controller.setLayerVisibility(_ids.eewEpicenter, true); } } catch (_) { // Layers gone mid style-reload — the next blink retries. @@ -881,44 +685,6 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { } } - /// The full circle style at [opacity] — passed whole (not a partial update), - /// since setLayerProperties resets any property left null. Colour comes from - /// the shared instrumental-intensity palette, so the dots and the legend can - /// never drift — except a `grey`-flagged feature (see [_geoJson]), which - /// paints the discrete scale's own 0-grey instead: a station on a large - /// event's alert list reading a flat 0 stays visibly part of the network - /// rather than fading into whatever pale colour the continuous ramp gives - /// a near-zero reading — ported from the legacy monitor's separate - /// `intensity0` grey layer. - CircleLayerProperties _circleProps(double opacity) => CircleLayerProperties( - circleColor: [ - 'case', - [ - '==', - ['get', 'grey'], - 1, - ], - IntensityColors.discrete(0).toHexRgb(), - InstrumentalIntensityColors.mapLibreInterpolate, - ], - circleRadius: _radiusExpression, - circleStrokeColor: _strokeColor, - circleStrokeWidth: 1, - circleOpacity: opacity, - // Stronger stations sort above weaker ones so a hot dot is never hidden. - circleSortKey: _sortKey, - ); - - /// The full label style at [opacity] — station id over its raw intensity. - /// Passed whole (setLayerProperties nulls anything omitted); the sort key - /// places the strongest stations first so a hot reading never loses. - SymbolLayerProperties _labelProps(double opacity) => stationLabelProps( - textField: const ['get', 'label'], - textSize: 10, - opacity: opacity, - sortKey: _labelSortKey, - ); - @override Widget buildSheet(BuildContext context) { _captureBrightness(context); @@ -960,7 +726,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { // this layer's own — leaving it tinted would bleed into whichever layer // becomes active next. [_updateAreaFill] no-ops if nothing was applied. await _updateAreaFill(controller, const []); - await _removeFromMap(controller); + await removeMonitorLayers(controller, _ids); _controller = null; } @@ -1001,8 +767,8 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { // Large event: the feed also carries box-grid data. The legacy monitor // decluttered to just the stations that registered something and badged // each with its discrete reading — ported here as a circular badge (see - // [_intensityCircleId]), never a shape swap: the dot underneath is still - // the same circle, the badge is just a fuller circle drawn over it. + // [MonitorLayerIds.stationBadge]), never a shape swap: the dot underneath + // is still the same circle, the badge is just a fuller one drawn over it. final hasBox = (_feed.state.data?.box.isNotEmpty) ?? false; final features = >[]; for (final entry in live.entries) { @@ -1033,7 +799,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { 'sort': effective, 'label': '${entry.key}\n${data.intensityRaw.toStringAsFixed(1)}', 'icon': hasBox && level > 0 - ? _intensityCircleIcon(level, dark: _dark) + ? monitorBadgeIcon(level, dark: _dark) : '', // Only reachable when `alert` is true (the filter above already // dropped a calm zero) — an alerting station reading a flat 0 @@ -1045,84 +811,4 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { } return {'type': 'FeatureCollection', 'features': features}; } - - /// The circular badge icon for scale index 1–9, dark or light artwork. - static String _intensityCircleIcon(int level, {required bool dark}) => - dark ? 'circle-$level-dark' : 'circle-$level'; - - Future _removeFromMap(MapLibreMapController controller) async { - // Layers before their sources; tolerate any not currently on the map. - for (final layerId in [ - _circleId, - _labelId, - _intensityCircleId, - _boxLineId, - _eewEpicenterId, - _eewSWaveId, - _eewPWaveId, - _eewSWaveFillId, - ]) { - try { - await controller.removeLayer(layerId); - } catch (_) { - // Expected when the layer isn't on the map yet. - } - } - for (final sourceId in [_sourceId, _eewSourceId, _boxSourceId]) { - try { - await controller.removeSource(sourceId); - } catch (_) { - // Expected when the source isn't on the map yet. - } - } - } - - /// Dots scale with zoom so they stay legible zoomed in (legacy: 2px at z4 → - /// 8px at z12). - static const List _radiusExpression = [ - 'interpolate', - ['linear'], - ['zoom'], - 4, - 2.0, - 12, - 8.0, - ]; - - /// The discrete-reading badge's on-map scale of its 64px artwork. The - /// legacy monitor's own badge layer used 0.2 at z5 → 0.8 at z10, but that - /// assumed native-resolution PNG assets — applied to a baked canvas here it - /// renders at only device-pixel size, ~13px on a 3x display and effectively - /// invisible. [ReportDetailPage] already solved this for the same 64px - /// canvas class ([IntensityIconRenderer]); reusing its scale here. - static const List _badgeIconSize = [ - 'interpolate', - ['linear'], - ['zoom'], - 5, - 0.75, - 15, - 1.7, - ]; - - /// Higher effective intensity draws on top (dot, badge, and label all key off - /// this) — reads `sort` (see [_geoJson]), the alert-aware value the badge is - /// actually drawn from, not the raw `i`. Stations without a `sort` sink. - static const List _sortKey = [ - 'coalesce', - ['get', 'sort'], - -5, - ]; - - /// Labels place strongest-first: a symbol's *lower* sort key wins a collision, - /// so negate the intensity — a hot station's reading never loses to a calm one. - static const List _labelSortKey = [ - '-', - 0, - [ - 'coalesce', - ['get', 'sort'], - -5, - ], - ]; } diff --git a/lib/shared/map/monitor_map_stack.dart b/lib/shared/map/monitor_map_stack.dart new file mode 100644 index 000000000..78f874924 --- /dev/null +++ b/lib/shared/map/monitor_map_stack.dart @@ -0,0 +1,420 @@ +/// The 強震監視器 map stack, defined once: the layer ids, the paint +/// expressions, and — the part that actually bites — the order the layers are +/// mounted in. +/// +/// Two surfaces draw this stack: the map tab's live monitor (`RtsMapLayer`, +/// `features/map`) and the report replay map (`ReportReplayPage`, +/// `features/earthquake`). Features must not import each other's internals +/// (see ARCHITECTURE.md), so the replay map used to carry a hand-copied port +/// of the monitor's rendering. The copies drifted exactly where drift is +/// invisible: a stacking change made on one surface simply did not happen on +/// the other, and nothing failed — the map just looked wrong on one page. +/// +/// So the ids, the expressions and [addMonitorLayers] live here, and both +/// surfaces call the same function. What stays with each caller is the part +/// that genuinely differs: where the station readings come from, and how each +/// page drives its own timers. Only the *shape* of the stack is shared. +library; + +import 'package:dpip/core/a11y/color_vision.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/shared/color_hex.dart'; +import 'package:dpip/shared/map/map_station_labels.dart'; +import 'package:dpip/shared/map/map_style.dart' show landLayerId; +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'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// Every source, layer and image id one monitor stack occupies, derived from a +/// [prefix] so two surfaces can mount the stack without colliding. +/// +/// The ids are spelled out rather than generated ad-hoc because tests pin the +/// monitor's (`rts-circle`, `rts-box-line`, `rts-eew-epicenter`, …) and a +/// renamed layer is a silently dead `setLayerVisibility`, not a crash. +class MonitorLayerIds { + const MonitorLayerIds(this.prefix); + + /// Namespace for this surface's copy of the stack — `rts` for the live + /// monitor, `replay` for the report replay map. + final String prefix; + + /// GeoJSON source holding one point per reporting station. + String get stationSource => '$prefix-src'; + + /// The station dot, coloured by the continuous instrumental ramp. + String get stationDot => '$prefix-circle'; + + /// Station id over its raw reading, pinned under the dot. + String get stationLabel => '$prefix-label'; + + /// Per-station discrete-reading badge — a circular version of the legacy + /// monitor's square `intensity` layer (see [IntensityCircleRenderer]): while + /// a large event's detection boxes are up, each shaking station gets a + /// numbered badge over its dot instead of the plain colour, but the shape + /// stays a circle — this is still live instrumental data, never the + /// report/rapid-report square. `icon` is empty for a station with nothing to + /// badge, so the plain dot underneath just keeps showing through. + String get stationBadge => '$prefix-intensity-circle'; + + /// GeoJSON source for a large event's detection-box grid. + String get boxSource => '$prefix-box-src'; + + /// Detection-box borders (no fill, so the map stays readable under them). + String get boxLine => '$prefix-box-line'; + + /// GeoJSON source for the EEW overlay — wave fronts plus epicentre. + String get eewSource => '$prefix-eew-src'; + + /// P wave-front ring: a heads-up leading edge, outline only. + String get eewPWave => '$prefix-eew-p'; + + /// S wave-front ring. + String get eewSWave => '$prefix-eew-s'; + + /// The S wave's translucent disc — the already-shaking zone. + String get eewSWaveFill => '$prefix-eew-s-fill'; + + /// The epicentre cross. + String get eewEpicenter => '$prefix-eew-epicenter'; + + /// Registered image id for the epicentre cross artwork. + String get eewCrossIcon => '$prefix-eew-cross'; + + /// Every layer, topmost first — the order [removeMonitorLayers] tears down + /// and the reverse of the order [addMonitorLayers] mounts. + List get layers => [ + eewEpicenter, + eewSWave, + eewPWave, + eewSWaveFill, + boxLine, + stationBadge, + stationLabel, + stationDot, + ]; + + /// Every source this stack owns. + List get sources => [stationSource, eewSource, boxSource]; +} + +/// The map tab's live 強震監視器. +const MonitorLayerIds rtsMonitorIds = MonitorLayerIds('rts'); + +/// The report replay map's own copy of the stack. +const MonitorLayerIds replayMonitorIds = MonitorLayerIds('replay'); + +/// An empty FeatureCollection — what every source is seeded with. +const Map monitorEmptyCollection = { + 'type': 'FeatureCollection', + 'features': [], +}; + +/// Dots scale with zoom so they stay legible zoomed in (legacy: 2px at z4 → +/// 8px at z12). +const List monitorDotRadius = [ + 'interpolate', + ['linear'], + ['zoom'], + 4, + 2.0, + 12, + 8.0, +]; + +/// The discrete-reading badge's on-map scale of its 64px artwork. The legacy +/// monitor's own badge layer used 0.2 at z5 → 0.8 at z10, but that assumed +/// native-resolution PNG assets — applied to a baked canvas here it renders at +/// only device-pixel size, ~13px on a 3x display and effectively invisible. +/// `ReportDetailPage` already solved this for the same 64px canvas class (see +/// [IntensityIconRenderer]); this is its scale. +const List monitorBadgeIconSize = [ + 'interpolate', + ['linear'], + ['zoom'], + 5, + 0.75, + 15, + 1.7, +]; + +/// Higher effective intensity draws on top (dot, badge and label all key off +/// this) — reads `sort`, the alert-aware value the badge is actually drawn +/// from, not the raw `i`. Stations without a `sort` sink. +const List monitorSortKey = [ + 'coalesce', + ['get', 'sort'], + -5, +]; + +/// Labels place strongest-first: a symbol's *lower* sort key wins a collision, +/// so negate the intensity — a hot station's reading never loses to a calm one. +const List monitorLabelSortKey = [ + '-', + 0, + [ + 'coalesce', + ['get', 'sort'], + -5, + ], +]; + +/// Box-grid border colour by intensity `i`: red ≥4, yellow 2–3, green below — +/// ported from the legacy monitor's box colour scheme. +const List monitorBoxColor = [ + 'case', + [ + '>=', + ['get', 'i'], + 4, + ], + '#FF0000', + [ + '>=', + ['get', 'i'], + 2, + ], + '#EAC100', + '#00DB00', +]; + +/// A neutral hairline separating overlapping dots — legacy uses the theme's +/// outlineVariant, but the render path has no `BuildContext`, so a mid-grey +/// that reads on both light and dark tiles stands in. +/// +/// A getter, not a `const`: the colour-vision transform runs at the definition +/// and isn't a compile-time constant. (It is the identity on a pure grey — +/// routing it anyway keeps the rule uniform for whoever tints this later.) +String get monitorDotStroke => '#9E9E9E'.vision; + +/// The circular badge icon id for scale index 1–9, dark or light artwork. +String monitorBadgeIcon(int level, {required bool dark}) => + dark ? 'circle-$level-dark' : 'circle-$level'; + +/// The full station-dot style at [opacity] — passed whole (not a partial +/// update), since `setLayerProperties` resets any property left null. +/// +/// Colour comes from the shared instrumental-intensity palette, so the dots +/// and the legend can never drift — except a `grey`-flagged feature, which +/// paints the discrete scale's own 0-grey instead: a station on a large +/// event's alert list reading a flat 0 stays visibly part of the network +/// rather than fading into whatever pale colour the continuous ramp gives a +/// near-zero reading (ported from the legacy monitor's separate `intensity0` +/// grey layer). +CircleLayerProperties monitorDotProps({double opacity = 1}) => + CircleLayerProperties( + circleColor: [ + 'case', + [ + '==', + ['get', 'grey'], + 1, + ], + IntensityColors.discrete(0).toHexRgb(), + InstrumentalIntensityColors.mapLibreInterpolate, + ], + circleRadius: monitorDotRadius, + circleStrokeColor: monitorDotStroke, + circleStrokeWidth: 1, + circleOpacity: opacity, + // Stronger stations sort above weaker ones so a hot dot is never hidden. + circleSortKey: monitorSortKey, + ); + +/// The full station-label style at [opacity] — station id over its raw +/// reading. Passed whole (`setLayerProperties` nulls anything omitted); the +/// sort key places the strongest stations first so a hot reading never loses. +SymbolLayerProperties monitorLabelProps({double opacity = 1}) => + stationLabelProps( + textField: const ['get', 'label'], + textSize: 10, + opacity: opacity, + sortKey: monitorLabelSortKey, + ); + +/// Mounts the whole stack on [controller], in the one order both surfaces use. +/// +/// **The order is the contract.** Everything here is *appended* (no +/// `belowLayerId`), so the stack sits over the base style's township names +/// rather than under them: on this overlay the live readings are the content +/// and the names are the backdrop. Exactly one layer stays anchored — the EEW +/// S-wave disc, below [landLayerId], so its wash covers open sea only and +/// never Taiwan itself. The estimated-shaking wash is not mounted here at all: +/// it *is* the base style's `town` fill, recoloured in place, so it too stays +/// under the names. Township names therefore end up second from the bottom — +/// above the wash, below every reading. +/// +/// Appending means insertion order alone decides the stacking (each call goes +/// to the very top, so the later one wins), and the resulting bottom-to-top +/// order is: dots → station labels → discrete badges → detection boxes → wave +/// fronts → epicentre cross. That matches the legacy monitor, and the +/// epicentre must stay last: it is the one mark that may never be buried. +/// +/// MapLibre places symbols from the top layer down, so with the labels above +/// the township names it is the station labels that win a collision — which is +/// the point: a live reading must not be dropped to keep a place name. The +/// badge and cross layers set `iconIgnorePlacement`, so they never take part +/// in placement and never suppress a name. +/// +/// [stationData] seeds the station source — live GeoJSON where the caller +/// already has a frame, [monitorEmptyCollection] otherwise. The box grid and +/// the EEW overlay are each isolated in their own try/catch, so a failure in +/// one can never take down the station dots; [logTag] names the surface in +/// whatever is logged. +Future addMonitorLayers( + MapLibreMapController controller, + MonitorLayerIds ids, { + Map stationData = monitorEmptyCollection, + double dotOpacity = 1, + required String logTag, +}) async { + await controller.addSource( + ids.stationSource, + GeojsonSourceProperties(data: stationData), + ); + await controller.addCircleLayer( + ids.stationSource, + ids.stationDot, + monitorDotProps(opacity: dotOpacity), + ); + await controller.addSymbolLayer( + ids.stationSource, + ids.stationLabel, + monitorLabelProps(opacity: dotOpacity), + minzoom: 10, + ); + // The 18 circular discrete-reading badges (1–9 light + dark), drawn in code + // and registered before the layer that names them. + final badges = await IntensityCircleRenderer.renderAll(); + for (final entry in badges.entries) { + await controller.addImage(entry.key, entry.value); + } + // Always on top of the plain dot (added after it); `icon` is empty for most + // stations most of the time, so this is a no-op render for them. + await controller.addSymbolLayer( + ids.stationSource, + ids.stationBadge, + const SymbolLayerProperties( + iconImage: ['get', 'icon'], + iconSize: monitorBadgeIconSize, + iconAllowOverlap: true, + iconIgnorePlacement: true, + // Same "stronger wins" rule as the dot layer's circleSortKey — two + // badges can overlap just like two dots can, and a low reading must + // never paint over a high one. (`symbol-z-order` defaults to `auto`, + // which honours the sort key; naming it `source` here would silently + // drop back to feed-iteration order.) + symbolSortKey: monitorSortKey, + ), + ); + try { + await controller.addSource( + ids.boxSource, + GeojsonSourceProperties(data: monitorEmptyCollection), + ); + await controller.addLineLayer( + ids.boxSource, + ids.boxLine, + const LineLayerProperties( + lineColor: monitorBoxColor, + lineWidth: 2, + visibility: 'none', + // Red always draws over yellow/green — ported from the legacy + // monitor's box layer (`lineSortKey: [Expressions.get, 'i']`). + // Without this, overlapping boxes stack in whatever order the feed + // happened to list them, so a low-intensity box could paint over a + // red one. + lineSortKey: ['get', 'i'], + ), + ); + } catch (e, st) { + Log.handle(e, st, '$logTag box layer render failed'); + } + try { + // The cross artwork is drawn in code, like every other map icon — the + // legacy PNG `assets/map/icons/cross.png` does not exist and must not be + // loaded. + await controller.addImage( + ids.eewCrossIcon, + await IntensityIconRenderer.render('cross'), + ); + await controller.addSource( + ids.eewSource, + GeojsonSourceProperties(data: monitorEmptyCollection), + ); + await controller.addFillLayer( + ids.eewSource, + ids.eewSWaveFill, + // Vector geometry we draw ourselves, so it recolours with the app. + FillLayerProperties(fillColor: '#FF3B30'.vision, fillOpacity: 0.16), + // The one anchored layer in the stack: below the whole land/county/town + // area, not just its borders, so the wash shows over open sea only. + belowLayerId: landLayerId, + filter: const [ + '==', + ['get', 'type'], + 's-fill', + ], + ); + await controller.addLineLayer( + ids.eewSource, + ids.eewPWave, + LineLayerProperties(lineColor: '#00E5FF'.vision, lineWidth: 2), + filter: const [ + '==', + ['get', 'type'], + 'p-line', + ], + ); + await controller.addLineLayer( + ids.eewSource, + ids.eewSWave, + LineLayerProperties(lineColor: '#FF3B30'.vision, lineWidth: 2), + filter: const [ + '==', + ['get', 'type'], + 's-line', + ], + ); + await controller.addSymbolLayer( + ids.eewSource, + ids.eewEpicenter, + SymbolLayerProperties( + iconImage: ids.eewCrossIcon, + iconSize: 1.0, + iconAllowOverlap: true, + iconIgnorePlacement: true, + ), + filter: const [ + '==', + ['get', 'type'], + 'x', + ], + ); + } catch (e, st) { + Log.handle(e, st, '$logTag EEW layer render failed'); + } +} + +/// Takes the stack back off [controller] — layers before their sources, +/// tolerating anything not currently mounted (a style reload wipes runtime +/// layers, so a caller re-rendering after one finds most of this already gone). +Future removeMonitorLayers( + MapLibreMapController controller, + MonitorLayerIds ids, +) async { + for (final layerId in ids.layers) { + try { + await controller.removeLayer(layerId); + } catch (_) { + // Expected when the layer isn't on the map yet. + } + } + for (final sourceId in ids.sources) { + try { + await controller.removeSource(sourceId); + } catch (_) { + // Expected when the source isn't on the map yet. + } + } +} diff --git a/test/features/map/presentation/layers/rts_layer_demo_test.dart b/test/features/map/presentation/layers/rts_layer_demo_test.dart index d62c67c8d..ef79aa601 100644 --- a/test/features/map/presentation/layers/rts_layer_demo_test.dart +++ b/test/features/map/presentation/layers/rts_layer_demo_test.dart @@ -24,7 +24,7 @@ import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/features/earthquake/domain/trem_station_repository.dart'; import 'package:dpip/features/map/presentation/layers/rts_layer.dart'; import 'package:dpip/shared/map/map_style.dart' - show countyFillLayerId, townFillLayerId; + show countyFillLayerId, landLayerId, townFillLayerId; import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter_test/flutter_test.dart'; @@ -426,6 +426,67 @@ void main() { ); }); + test('every monitor layer is appended above the township names, except the ' + 'S-wave disc anchored below the land', () async { + final origin = DateTime.now().toUtc().subtract(const Duration(seconds: 5)); + final built = await _build( + rts: Rts( + time: origin.millisecondsSinceEpoch, + box: {'1': 4}, + station: const { + 'TWD001': RtsStation( + pga: 40, + pgv: 8, + intensityRaw: 4.0, + intensity: 4.0, + alert: true, + ), + }, + ), + alerts: [_alert(origin: origin)], + table: table, + grid: grid, + ); + final controller = _RecordingController(); + await built.layer.render(controller); + await pumpEventQueue(); + + // A null anchor means `addLayer` appended at the very top of the style, so + // the layer lands over the base style's `town-label`. Anchoring any of + // these below the names again — the state this stack shipped in — puts a + // place name over a live reading, which is the one thing it must not do. + // The names are meant to end up second from the bottom of the whole map: + // above the estimated-shaking wash (which *is* the base style's `town` + // fill, recoloured in place, so it is never added here) and below + // everything below. + for (final layerId in const [ + 'rts-circle', + 'rts-label', + 'rts-intensity-circle', + 'rts-box-line', + 'rts-eew-p', + 'rts-eew-s', + 'rts-eew-epicenter', + ]) { + expect( + controller.calls.where( + (call) => call.startsWith('add') && call.endsWith(':$layerId'), + ), + isNotEmpty, + reason: '$layerId was never added, so its anchor proves nothing', + ); + expect( + controller.belowOf(layerId), + isNull, + reason: '$layerId must be appended above the township names', + ); + } + // The one exception, and the reason it is one: the disc washes open sea + // only, so it goes under the whole land/county/town area rather than over + // the names. + expect(controller.belowOf('rts-eew-s-fill'), landLayerId); + }); + test( 'a large event declutters to shaking stations, badged with the discrete ' 'reading on a circle — legacy behaviour, ported without the square badge',