Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cddb861
fix(highlights): name the cycle, not the train that is installed
whes1015 Sep 2, 2026
471914e
fix(map): stop the monitor's EEW countdown ticking behind other tabs
whes1015 Sep 7, 2026
b45adfc
perf: stop paying for paint and layout nothing on screen can show
whes1015 Sep 7, 2026
97afa0e
fix(home): blur the map the sheet climbs over, not the scrim above it
whes1015 Sep 7, 2026
1bdb0bd
perf(map): draw the chrome over the map as a flat tint on Android
whes1015 Sep 11, 2026
f2efcdb
fix(map): preload timeline frames at an opacity MapLibre will fetch
whes1015 Sep 11, 2026
0099077
perf(map): let the camera settle before warming the timeline band
whes1015 Sep 11, 2026
c9b6a16
perf(map): stop re-uploading an unchanged detection-box grid
whes1015 Sep 11, 2026
348cad4
perf(home): stop recomputing the sky backdrop's constants per frame
whes1015 Sep 11, 2026
3708fea
perf(home): rebuild less of the sheet while it moves
whes1015 Sep 11, 2026
282510c
perf(eew): stop re-sending unchanged replay boxes and stacking waves
whes1015 Sep 11, 2026
12b86d5
perf(mesh): pause the radio strip's clock while the page is hidden
whes1015 Sep 11, 2026
76289ac
perf(weather): rank stations once per data set, not per rebuild
whes1015 Sep 11, 2026
68cc13d
perf(mesh): reuse the traced route's geometry across animation frames
whes1015 Sep 11, 2026
990a25a
refactor(map): get the Mercator tile row from one transcendental call
whes1015 Sep 11, 2026
16ca3aa
perf(map): keep timeline neighbours from re-fetching on every pan
whes1015 Sep 11, 2026
a3ad01c
perf(eew): stop re-deriving the alert colour and estimate every second
whes1015 Sep 11, 2026
f62399e
perf(data): stop re-solving the moon calendar on every scrub tick
whes1015 Sep 11, 2026
6fb263c
perf: compile patterns and formatters once instead of per rebuild
whes1015 Sep 11, 2026
987a147
perf(home): look the region label up once per badge, not per pixel
whes1015 Sep 11, 2026
6cb63a5
perf(map): parse each tile directory once instead of every URL
whes1015 Sep 11, 2026
425b00a
perf(map): stop rebuilding formatters and era colours per tick
whes1015 Sep 11, 2026
d9a31e0
perf(eew): decode the seismic feed from its bytes without a copy
whes1015 Sep 11, 2026
70f2078
perf: stop re-parsing URLs and copying bodies on the cache path
whes1015 Sep 11, 2026
55f71f9
perf: cap the log table without materialising thousands of ids
whes1015 Sep 11, 2026
814bbfa
perf: compile patterns and prepare SQL once instead of per call
whes1015 Sep 11, 2026
b5ae7dc
perf(data): propagate each satellite step once when finding passes
whes1015 Sep 11, 2026
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
7 changes: 7 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ itself (sky colour at 20 % alpha, HSL-lightness shifted by time of day — see
`skyCardTint`). Ink follows the sky, not the theme. Shared surfaces built from
it: `shared/widgets/frosted_surface.dart`, `sheet_surface.dart`.

Frost over the **map** is iOS-only. On Android the map is a platform view and a
`BackdropFilter` over it is either blind (HCPP) or the reason every map frame
re-rasterises the whole Flutter scene (virtual display), so every map-chrome
blur is gated on `mapChromeBlursBackdrop` (`frosted_surface.dart`) and Android
draws the same panel as a slightly stronger flat tint. Do not add a blur over
the map without going through that gate.

## Shared components — `lib/shared/widgets/`

- `SectionHeader(title)` — the small primary-tinted header above a settings/menu
Expand Down
33 changes: 29 additions & 4 deletions lib/app/shell/main_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,30 @@ class _MainShellState extends State<MainShell> with RouteAware {
/// [shellRouteObserver]. Held so the subscription can be dropped again.
ModalRoute<void>? _shellRoute;

/// The bottom bar's dismissal, derived from [HomeSheetExtent] — not the raw
/// extent. The sheet publishes every scroll tick; the bar only moves while
/// Home is the visible branch *and* the extent crosses [HomeChrome.navDismiss]'s
/// ramp, so listening to the extent directly rebuilt the bar on every tick
/// from every tab for a value that was 0 the whole time. This is assigned
/// only when the derived value changes (see [_syncNavDismiss]).
final ValueNotifier<double> _navDismiss = ValueNotifier(0);
late final HomeSheetExtent _sheetExtent;

@override
void initState() {
super.initState();
_trace(() => 'init current=${widget.navigationShell.currentIndex}');
_sheetExtent = context.read<HomeSheetExtent>()
..addListener(_syncNavDismiss);
}

/// Only Home dismisses the bar; every other tab keeps it (dismiss 0).
void _syncNavDismiss() {
final dismiss = widget.navigationShell.currentIndex == 0
? HomeChrome.navDismiss(_sheetExtent.value)
: 0.0;
if (dismiss == _navDismiss.value) return;
_navDismiss.value = dismiss;
}

@override
Expand Down Expand Up @@ -104,6 +124,8 @@ class _MainShellState extends State<MainShell> with RouteAware {
void dispose() {
_trace(() => 'dispose');
if (_shellRoute != null) shellRouteObserver.unsubscribe(this);
_sheetExtent.removeListener(_syncNavDismiss);
_navDismiss.dispose();
_visibleTab.dispose();
super.dispose();
}
Expand Down Expand Up @@ -149,6 +171,11 @@ class _MainShellState extends State<MainShell> with RouteAware {
});
}
_lastIndex = index;
// The branch is an input to the bar's dismissal too — pin it to 0 the
// moment another branch is on screen, regardless of where Home's sheet
// was left. Safe mid-build: the only listener is the builder below, a
// descendant that this build re-creates anyway.
_syncNavDismiss();
// Publish after the frame: pages listening to this rebuild on the edge, and
// a notify during build would land mid-build for them.
if (_visibleTab.value != index) {
Expand Down Expand Up @@ -216,11 +243,9 @@ class _MainShellState extends State<MainShell> with RouteAware {
),
],
),
// Only Home dismisses the bar; every other tab keeps it (dismiss 0).
bottomNavigationBar: ValueListenableBuilder<double>(
valueListenable: context.read<HomeSheetExtent>(),
builder: (context, extent, child) {
final dismiss = index == 0 ? HomeChrome.navDismiss(extent) : 0.0;
valueListenable: _navDismiss,
builder: (context, dismiss, child) {
// Slide the bar down by its own height and fade it out; stop it
// catching taps once it is mostly gone so the sheet behind gets them.
return IgnorePointer(
Expand Down
43 changes: 41 additions & 2 deletions lib/app/theme/app_glass.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,39 @@ Color glassSurface(ColorScheme colors, double reveal, {Color? sky, int? hour}) {
if (colors.brightness == Brightness.light) return colors.surfaceContainerLow;
final revealed = sky == null
? colors.surface.withValues(alpha: 0.92)
: skyCardTint(sky, hour: hour ?? AppTime.utc8.hour);
: _skyCardTintMemo(sky, hour ?? _taipeiHour());
return Color.lerp(
colors.surfaceContainerHighest.withValues(alpha: 0.55),
revealed,
reveal,
)!;
}

/// The current Taipei wall-clock hour, from the calibrated instant's epoch
/// arithmetic: `AppTime.utc8` is exactly `utc + 8 h`, and a UTC-flagged
/// `DateTime`'s `hour` is `(ms ~/ 1 h) % 24` for any post-epoch instant — so
/// this is the same integer without the second `DateTime` per call.
int _taipeiHour() =>
((AppTime.utc.millisecondsSinceEpoch + 8 * Duration.millisecondsPerHour) ~/
Duration.millisecondsPerHour) %
24;

/// [skyCardTint] behind a one-entry memo. Every glass card on Home asks for
/// the same `(panelAmbient, hour)` pair on every sheet-drag rebuild, and the
/// two HSL round trips behind it are pure — the answer only moves when the
/// sky re-bakes (once a minute) or the hour bucket turns.
Color _skyCardTintMemo(Color sky, int hour) {
final cached = _cardTintMemo;
if (cached != null && cached.sky == sky && cached.hour == hour) {
return cached.tint;
}
final tint = skyCardTint(sky, hour: hour);
_cardTintMemo = (sky: sky, hour: hour, tint: tint);
return tint;
}

({Color sky, int hour, Color tint})? _cardTintMemo;

/// Ink for content **inside** a [glassSurface] card.
///
/// Dark theme: at rest the card is its own plate and the theme's on-surface
Expand Down Expand Up @@ -135,11 +160,25 @@ bool weatherSkyIsLight(WeatherMode mode) => switch (mode) {
/// luminance cutoff — it is the same "is this background light or dark"
/// judgment Flutter already ships and tunes, so a border-hue sky (dawn, a
/// hazy overcast) resolves the way the rest of the framework would resolve it.
///
/// Memoised on the last [sky] seen: `estimateBrightnessForColor` is a
/// relative-luminance computation (three `pow` calls), and every widget on
/// the sheet asks about the *same* `SkyLutCache.panelAmbient` value on every
/// rebuild of a drag — the answer changes once a minute, when the sky
/// re-bakes, and the memo turns the rest into one colour comparison.
/// (`SkyLutCache.panelAmbientIsLight` publishes the same verdict at the
/// source for callers that already listen there.)
bool skyIsLightFrom(Color? sky, WeatherMode fallbackMode) {
if (sky == null) return weatherSkyIsLight(fallbackMode);
return ThemeData.estimateBrightnessForColor(sky) == Brightness.light;
final cached = _skyIsLightMemo;
if (cached != null && cached.sky == sky) return cached.isLight;
final isLight = ThemeData.estimateBrightnessForColor(sky) == Brightness.light;
_skyIsLightMemo = (sky: sky, isLight: isLight);
return isLight;
}

({Color sky, bool isLight})? _skyIsLightMemo;

/// Ink for content drawn **on** the weather sky (header, region badges).
///
/// As [reveal] rises, shifts toward dark ink on a light sky (critical in dark
Expand Down
11 changes: 8 additions & 3 deletions lib/core/a11y/color_vision.dart
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ abstract final class ColorVisionFilter {
? c * 12.92
: 1.055 * math.pow(c, 1 / 2.4).toDouble() - 0.055;

/// `rgba(r, g, b, a)` / `rgb(r, g, b)`. Compiled once: [transformHex] runs
/// per paint value each time a map layer's style is built, and an inline
/// `RegExp(...)` compiles a fresh pattern on every call.
static final RegExp _rgbaFunctional = RegExp(
r'^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$',
);

/// `(r, g, b, a, wasFunctional)` in 0–255 / 0–1, or null if unrecognised.
static (int, int, int, double, bool)? _parseRgba(String value) {
final text = value.trim();
Expand All @@ -201,9 +208,7 @@ abstract final class ColorVisionFilter {
return null;
}
}
final match = RegExp(
r'^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$',
).firstMatch(text);
final match = _rgbaFunctional.firstMatch(text);
if (match == null) return null;
try {
return (
Expand Down
60 changes: 34 additions & 26 deletions lib/core/astro/satellite.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class TleSet {
final exponent = field.substring(6).trim();
if (mantissa.isEmpty || mantissa == '00000') return 0;
final sign = mantissa.startsWith('-') ? -1 : 1;
final digits = mantissa.replaceAll(RegExp('[+-]'), '');
final digits = mantissa.replaceAll(_signChars, '');
return sign *
double.parse('0.$digits') *
math.pow(10, int.parse(exponent)).toDouble();
Expand Down Expand Up @@ -128,6 +128,10 @@ class TleSet {
);
}

/// Compiled once: [parseAll] runs [parse] per set in a catalogue file, and
/// a `RegExp` literal inside [parse] re-compiled it for each.
static final RegExp _signChars = RegExp('[+-]');

/// Every element set in a standard TLE file.
static List<TleSet> parseAll(String text) {
final lines = text
Expand Down Expand Up @@ -500,37 +504,39 @@ class Sgp4 {
///
/// TEME is an inertial frame, so the Earth is rotated under it by the
/// sidereal angle before the observer's position is subtracted.
///
/// [state] is the satellite's TEME state at [utc] when the caller already
/// has it; otherwise it is propagated here. The pass search hands it in so
/// each step propagates once — it used to propagate here *and* again in the
/// sunlit test for the same instant, and SGP4 is the whole cost of a step.
Horizontal lookFrom(
DateTime utc, {
required double latitude,
required double longitude,
SatelliteState? state,
}) {
final state = at(utc);
state ??= at(utc);
final gmst = greenwichSiderealTime(utc);
final localSidereal = gmst + longitude * degrees;

// The observer, in the same rotating-into-inertial sense.
// The observer, in the same rotating-into-inertial sense. One sine and
// one cosine of the latitude, reused below — they were each evaluated
// two or three times for the same angle.
final phi = latitude * degrees;
final sinPhi = math.sin(phi);
final cosPhi = math.cos(phi);
const flattening = 1 / 298.26; // WGS-72, to match SGP4's Earth.
final c =
1 /
math.sqrt(
1 + flattening * (flattening - 2) * math.pow(math.sin(phi), 2),
);
final observerX =
_earthRadiusKm * c * math.cos(phi) * math.cos(localSidereal);
final observerY =
_earthRadiusKm * c * math.cos(phi) * math.sin(localSidereal);
final observerZ =
_earthRadiusKm * c * math.pow(1 - flattening, 2) * math.sin(phi);
1 / math.sqrt(1 + flattening * (flattening - 2) * math.pow(sinPhi, 2));
final observerX = _earthRadiusKm * c * cosPhi * math.cos(localSidereal);
final observerY = _earthRadiusKm * c * cosPhi * math.sin(localSidereal);
final observerZ = _earthRadiusKm * c * math.pow(1 - flattening, 2) * sinPhi;

final rx = state.position.$1 - observerX;
final ry = state.position.$2 - observerY;
final rz = state.position.$3 - observerZ.toDouble();

// Rotate the range vector into the observer's south-east-zenith frame.
final sinPhi = math.sin(phi);
final cosPhi = math.cos(phi);
final sinTheta = math.sin(localSidereal);
final cosTheta = math.cos(localSidereal);
final south = sinPhi * cosTheta * rx + sinPhi * sinTheta * ry - cosPhi * rz;
Expand Down Expand Up @@ -586,20 +592,26 @@ abstract final class SatellitePasses {
}) {
final passes = <SatellitePass>[];
const step = Duration(seconds: 30);
final end = from.add(window);
// Immutable, so one for the whole search rather than one per step.
final observer = Observer(latitude: latitude, longitude: longitude);
DateTime? rose;
var best = -math.pi;
var bestAt = from;
var bestAzimuth = 0.0;

for (var at = from; at.isBefore(from.add(window)); at = at.add(step)) {
for (var at = from; at.isBefore(end); at = at.add(step)) {
// Propagated once per step and shared with the sunlit test below; the
// look and the shadow check are two views of this same state.
final state = satellite.at(at);
final look = satellite.lookFrom(
at,
latitude: latitude,
longitude: longitude,
state: state,
);
final visible =
look.altitude > 0 &&
(!sunlitOnly || _isSunlit(satellite, at, latitude, longitude));
look.altitude > 0 && (!sunlitOnly || _isSunlit(state, at, observer));
if (visible) {
rose ??= at;
if (look.altitude > best) {
Expand Down Expand Up @@ -628,22 +640,18 @@ abstract final class SatellitePasses {

/// Whether the satellite is in sunlight while the ground is dark — the
/// condition that makes a pass actually visible to the eye.
static bool _isSunlit(
Sgp4 satellite,
DateTime at,
double latitude,
double longitude,
) {
///
/// [state] is the satellite at [at], already propagated by the caller.
static bool _isSunlit(SatelliteState state, DateTime at, Observer observer) {
// The ground must be at least in civil twilight, or the sky outshines it.
final observer = Observer(latitude: latitude, longitude: longitude);
final sunAltitude = observer
.lookAt(SunEphemeris.at(at).equatorial, at)
.altitude;
if (sunAltitude > civilTwilight) return false;
final sun = _sunTeme(at);

// And the satellite must be outside the Earth's shadow cylinder.
final position = satellite.at(at).position;
final position = state.position;
final dot =
position.$1 * sun.$1 + position.$2 * sun.$2 + position.$3 * sun.$3;
if (dot > 0) return true;
Expand Down
26 changes: 16 additions & 10 deletions lib/core/logging/log_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ class LogStore {

final _pending = <StoredLog>[];
Timer? _timer;

/// The row ceiling, as one statement both [flush] and [prune] run.
///
/// "Everything but the newest N" is *every id below the N-th newest*, and
/// that one id is a single `OFFSET N-1` read down the primary-key index. It
/// used to be `id NOT IN (SELECT id … LIMIT N)`, which materialises all N
/// ids into a temporary table and probes it per row — on every flush,
/// i.e. every three seconds while anything logs. Same rows deleted: with
/// fewer than N rows the subquery is NULL, `id < NULL` matches nothing, and
/// with more, the rows below the N-th newest are exactly the ones outside
/// the old `IN` list.
static const String _capSql =
'DELETE FROM $logTable WHERE id < ('
'SELECT id FROM $logTable ORDER BY id DESC LIMIT 1 OFFSET ?)';
Future<void> _databaseTail = Future<void>.value();

/// Preserves the order in which persistence operations were requested.
Expand Down Expand Up @@ -149,11 +163,7 @@ class LogStore {
_now().toUtc().subtract(logRetention).millisecondsSinceEpoch,
]);
// See [logMaxRows]: the newest lines survive whatever the clock says.
await tx.execute(
'DELETE FROM $logTable WHERE id NOT IN ('
'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)',
[logMaxRows],
);
await tx.execute(_capSql, [logMaxRows - 1]);
});
} on Object {
// Reporting a logging failure through the logger is how a write loop
Expand Down Expand Up @@ -196,11 +206,7 @@ class LogStore {
// primary key and monotonic: a clock that steps backwards would
// otherwise make the newest rows look like the oldest and delete
// them.
await tx.execute(
'DELETE FROM $logTable WHERE id NOT IN ('
'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)',
[logMaxRows],
);
await tx.execute(_capSql, [logMaxRows - 1]);
});
} on Object {
// Deliberately silent: reporting a logging failure through the logger
Expand Down
7 changes: 6 additions & 1 deletion lib/core/network/endpoint_health.dart
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,15 @@ class EndpointHealth {
/// `TPE1`. Also covers the static hosts (`static.core-tnn1…`) and legacy
/// `api-1` (no region → the host's own last segment).
String get regionCode {
final core = RegExp(r'-(tpe1|khh1|tyo1|tnn1)\.').firstMatch(host);
final core = _regionInHost.firstMatch(host);
if (core != null) return core.group(1)!.toUpperCase();
return host.split('.').first.toUpperCase();
}

/// Compiled once: the status table reads this getter for every cell on
/// every rebuild, and a `RegExp(...)` literal inside it re-compiled the
/// pattern each time.
static final RegExp _regionInHost = RegExp(r'-(tpe1|khh1|tyo1|tnn1)\.');
}

/// Tracks per-service-host request outcomes so the UI can show which region is
Expand Down
Loading
Loading