UI-09: switch to CARTO dark map tiles with attribution and cache versioning
Replaces OSM's tan/cream default raster style with CARTO's "Dark Matter" basemap (both RideMap and the route planner's independent TileLayer), so the map itself looks like the dark mockups rather than a light basemap under a dark overlay. Shares one urlTemplate/subdomains/maxNativeZoom across both call sites instead of two independently-drifting copies. Re-points the V3-11 tile cache at a versioned directory (tiles/carto_dark_v1) since TileKey(z, x, y) carries no provider identity and would otherwise silently serve stale tan tiles cached under the old scheme. Added a test proving cache isolation holds across provider directories. Adds a shared TileAttribution widget crediting both OpenStreetMap (CARTO's style is still built from OSM data) and CARTO, composed into every map rather than duplicated per screen -- OSM-only credit stopped being sufficient once a second tile host entered the mix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
/// Ported from `com.rippr.ui.components.RideMap` (osmdroid → flutter_map).
|
||||
///
|
||||
/// Same tile source, same reasoning that chose osmdroid: OpenStreetMap raster tiles, no
|
||||
/// API key, no billing.
|
||||
/// UI-09: tiles come from CARTO's "Dark Matter" basemap, not OSM's own tan/cream
|
||||
/// default -- a purpose-built dark, desaturated style that actually matches the HUD
|
||||
/// sitting on top of it, rather than a light basemap punched through a dark overlay.
|
||||
/// Still no API key, no billing; still a third-party tile host to respect the same way
|
||||
/// OSM's own usage policy was respected before (real user agent, no bulk prefetch,
|
||||
/// rate-limit-conscious), just a different one, with its own attribution obligation
|
||||
/// alongside OSM's (CARTO's stylesheet is still built from OSM's underlying data).
|
||||
///
|
||||
/// ## Four things here are load-bearing
|
||||
///
|
||||
/// 1. **One polyline per segment**, so a pause leaves a visible gap instead of a
|
||||
/// straight line drawn across town.
|
||||
/// 2. **Decimation is render-only.** `simplify` never touches storage or export.
|
||||
/// 3. **The zoom clamp.** Fitting a 50 m ride zooms past OpenStreetMap's maximum tile
|
||||
/// zoom of 19 and renders an empty grid — that shipped in v2.0 and is invisible to
|
||||
/// any fixture of the wrong size.
|
||||
/// 4. **A real user agent**, or OSM's tile servers return 403.
|
||||
/// 3. **The zoom clamp.** Fitting a 50 m ride zooms past this app's zoom ceiling and
|
||||
/// renders an empty grid — that shipped in v2.0 (against OSM's own tiles, capped at
|
||||
/// the same 19) and is invisible to any fixture of the wrong size.
|
||||
/// 4. **A real user agent**, or a tile host's servers return 403.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -29,13 +34,25 @@ const double simplifyEpsilonM = 5.0;
|
||||
/// fiddly, and flutter_map has no equivalent either. Buckets also read better at a glance.
|
||||
const int speedBucketKmh = 10;
|
||||
|
||||
/// OpenStreetMap serves no raster tiles above this. Exceeding it renders a blank grid.
|
||||
/// This app's own zoom ceiling -- not raised to CARTO's native 20 (see
|
||||
/// [tileMaxNativeZoom]) without deliberately re-verifying the fit/follow-zoom logic
|
||||
/// tuned against 19 by earlier tickets. Exceeding it renders an empty grid.
|
||||
const double maxTileZoom = 19.0;
|
||||
|
||||
/// CARTO's dark tiles are natively rendered up to this zoom (one past [maxTileZoom]) --
|
||||
/// passed to `TileLayer.maxNativeZoom` so a future bump to [maxTileZoom] doesn't also
|
||||
/// require re-deriving this number.
|
||||
const int tileMaxNativeZoom = 20;
|
||||
|
||||
/// CARTO's dark basemap. `{s}` is one of [tileSubdomains]; `{r}` is resolved by
|
||||
/// `TileLayer`'s own `retinaMode` to `@2x` (or empty) based on device pixel ratio.
|
||||
const String tileUrlTemplate = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
const List<String> tileSubdomains = ['a', 'b', 'c', 'd'];
|
||||
|
||||
/// What a very short ride falls back to, so streets stay visible.
|
||||
const double shortRideZoom = 17.0;
|
||||
|
||||
/// Identifies the app to OSM's servers. Anonymous bulk requests get 403.
|
||||
/// Identifies the app to the tile host's servers. Anonymous bulk requests get 403.
|
||||
const String tileUserAgent = 'com.rippr.port';
|
||||
|
||||
class RideMap extends StatefulWidget {
|
||||
@@ -225,14 +242,18 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||
// tile request can fire off-screen. See the lifecycle observer above.
|
||||
if (!_backgrounded)
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
urlTemplate: tileUrlTemplate,
|
||||
subdomains: tileSubdomains,
|
||||
retinaMode: true,
|
||||
userAgentPackageName: tileUserAgent,
|
||||
maxNativeZoom: maxTileZoom.toInt(),
|
||||
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
|
||||
maxNativeZoom: tileMaxNativeZoom,
|
||||
// Respect the tile host's usage policy: render what is looked at, never
|
||||
// bulk prefetch.
|
||||
panBuffer: 0,
|
||||
tileProvider: widget.tileProvider,
|
||||
),
|
||||
PolylineLayer(polylines: polylines),
|
||||
const TileAttribution(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -320,3 +341,24 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||
return Color.lerp(colors.secondary, colors.primary, t)!;
|
||||
}
|
||||
}
|
||||
|
||||
/// UI-09: CARTO's dark tiles are built from OpenStreetMap's underlying data, so both
|
||||
/// need crediting, not just CARTO's own styling on top -- OSM-only attribution (what
|
||||
/// this app carried while it used OSM's raw tiles directly) stopped being sufficient the
|
||||
/// moment CARTO entered the mix. Shared by every `FlutterMap` in the app rather than
|
||||
/// duplicated per screen, so there's exactly one place this obligation is discharged.
|
||||
class TileAttribution extends StatelessWidget {
|
||||
const TileAttribution({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const RichAttributionWidget(
|
||||
alignment: AttributionAlignment.bottomLeft,
|
||||
// No `onTap` -- opening the license pages would need `url_launcher`, a new
|
||||
// dependency this ticket has no other reason to add. The obligation is to credit
|
||||
// both sources visibly, not to make the credit tappable.
|
||||
attributions: [
|
||||
TextSourceAttribution('OpenStreetMap contributors'),
|
||||
TextSourceAttribution('CARTO'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,14 @@ import '../../geo/geo.dart' as geo;
|
||||
import '../../tiles/tile_cache.dart';
|
||||
import '../../tiles/tile_downloader.dart';
|
||||
import '../../tiles/tile_math.dart';
|
||||
import '../components/ride_map.dart' show maxTileZoom, tileUserAgent;
|
||||
import '../components/ride_map.dart'
|
||||
show
|
||||
TileAttribution,
|
||||
maxTileZoom,
|
||||
tileMaxNativeZoom,
|
||||
tileSubdomains,
|
||||
tileUrlTemplate,
|
||||
tileUserAgent;
|
||||
import '../components/stats.dart' show confirmDialog;
|
||||
import '../format.dart';
|
||||
|
||||
@@ -143,9 +150,11 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
urlTemplate: tileUrlTemplate,
|
||||
subdomains: tileSubdomains,
|
||||
retinaMode: true,
|
||||
userAgentPackageName: tileUserAgent,
|
||||
maxNativeZoom: maxTileZoom.toInt(),
|
||||
maxNativeZoom: tileMaxNativeZoom,
|
||||
panBuffer: 0,
|
||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||
),
|
||||
@@ -197,6 +206,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const TileAttribution(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user