import 'dart:math' as math; /// A point on the globe in degrees. class GeoPoint { const GeoPoint(this.longitude, this.latitude); final double longitude; final double latitude; @override String toString() => 'GeoPoint($longitude, $latitude)'; @override bool operator ==(Object other) => other is GeoPoint && other.longitude == longitude && other.latitude == latitude; @override int get hashCode => Object.hash(longitude, latitude); } /// An axis-aligned geographic bounding box in degrees. /// /// Serialised as `[west, south, east, north]`, the ordering GeoJSON and GDAL /// both use, so a bbox can be copied between the region config, the worker and /// the published manifest without being reordered on the way. class GeoBounds { const GeoBounds({ required this.west, required this.south, required this.east, required this.north, }); /// Parses the `[west, south, east, north]` form. /// /// Throws [FormatException] if the list is the wrong length, holds /// non-numbers, or describes an empty or out-of-range box. An inverted box is /// rejected rather than silently normalised: the likely cause is a swapped /// latitude and longitude, and normalising would hide it. factory GeoBounds.fromJson(Object? json) { if (json is! List || json.length != 4) { throw const FormatException( 'bbox must be a list of four numbers [west, south, east, north]', ); } final values = []; for (final entry in json) { if (entry is! num) { throw FormatException('bbox contains a non-numeric value: $entry'); } values.add(entry.toDouble()); } final bounds = GeoBounds( west: values[0], south: values[1], east: values[2], north: values[3], ); bounds._validate(); return bounds; } final double west; final double south; final double east; final double north; double get widthDegrees => east - west; double get heightDegrees => north - south; GeoPoint get center => GeoPoint(west + widthDegrees / 2, south + heightDegrees / 2); bool contains(GeoPoint point) => point.longitude >= west && point.longitude <= east && point.latitude >= south && point.latitude <= north; void _validate() { if (west < -180 || east > 180) { throw FormatException('bbox longitude out of range: $west..$east'); } if (south < -90 || north > 90) { throw FormatException('bbox latitude out of range: $south..$north'); } if (east <= west) { throw FormatException( 'bbox east ($east) must be greater than west ($west)', ); } if (north <= south) { throw FormatException( 'bbox north ($north) must be greater than south ($south)', ); } } List toJson() => [west, south, east, north]; @override String toString() => 'GeoBounds($west, $south, $east, $north)'; @override bool operator ==(Object other) => other is GeoBounds && other.west == west && other.south == south && other.east == east && other.north == north; @override int get hashCode => Object.hash(west, south, east, north); } /// Web Mercator helpers. /// /// The worker publishes frames already reprojected to EPSG:3857, so the app /// only needs the forward transform to reason about frame geometry — never the /// warp itself. class WebMercator { const WebMercator._(); /// Latitude beyond which Web Mercator is undefined in practice. static const double maxLatitude = 85.05112878; static double xFromLongitude(double longitude) => longitude / 360 + 0.5; static double yFromLatitude(double latitude) { final clamped = latitude.clamp(-maxLatitude, maxLatitude); final sin = math.sin(clamped * math.pi / 180); return 0.5 - math.log((1 + sin) / (1 - sin)) / (4 * math.pi); } }