add google_fonts dependency, implement feature inspection on map, and enhance tile cache management

This commit is contained in:
ImBenji 2026-03-30 23:52:07 +01:00
parent dc22ce2f46
commit 31a91a054a
7 changed files with 1536 additions and 431 deletions

View file

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
final Color kMapBackgroundColor = Color(0xFFA7BFDB);
final Color kMapLanduseColor = Color(0xFFE500FE);
final Color kMapGrassColor = Color(0xFFA2CE83);
final Color kMapWaterColor = Color(0xFF74B8ED);
final Color kMapBuildingFillColor = Color(0xFFFED000);
final Color kMapBuildingOutlineColor = Color(0xFFE7B600);
final Color kMapRoadFillColor = Color(0xFFFEFEFF);
final Color kMapPathFillColour = Color(0xFFD9E0F0);
final Color kMapDebugTileBoundaryColor = Color(0xCCFF2D55);
final Color kMapPlaceLabelColor = Color(0xFF1F2636);
final Color kMapPlaceLabelHaloColor = Color(0xF7FFFFFF);
final Color kMapRoadLabelColor = Color(0xFF56657A);
final Color kMapRoadLabelHaloColor = Color(0xF2FFFFFF);
final Color kMapRailFillColor = Color(0xFF032D51);
final Color kMapRailUnderlayColor = Color(0xFF8A97A8);
final Color kMapPoiLabelColor = Color(0xFF032D51);
final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF);

View file

@ -14,6 +14,7 @@ import 'package:flutter/scheduler.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:latlong2/latlong.dart' show LatLng; import 'package:latlong2/latlong.dart' show LatLng;
import 'package:rra_app/pages/map/routing/osrm_routing_service.dart'; import 'package:rra_app/pages/map/routing/osrm_routing_service.dart';
import 'package:rra_app/pages/map/vector/mapbox_vector_tile_cache.dart';
import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart'; import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart';
import 'package:rra_app/pages/map/vector/tile_pyramid_painter.dart'; import 'package:rra_app/pages/map/vector/tile_pyramid_painter.dart';
import 'package:rra_app/pages/map/widgets/toolbar.dart'; import 'package:rra_app/pages/map/widgets/toolbar.dart';
@ -72,10 +73,12 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
bool _isAddPointArmed = false; bool _isAddPointArmed = false;
bool _isResolvingRoute = false; bool _isResolvingRoute = false;
bool _isInspectingFeature = false;
String? _routeError; String? _routeError;
int _routeRequestId = 0; int _routeRequestId = 0;
int? _draggingInsertPointIndex; int? _draggingInsertPointIndex;
Timer? _interactionIdleTimer; Timer? _interactionIdleTimer;
MapDebugHit? _debugFeatureHit;
late final TilePyramidPainter _pyramidPainter; late final TilePyramidPainter _pyramidPainter;
@ -103,12 +106,25 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
void dispose() { void dispose() {
_zoomTicker.dispose(); _zoomTicker.dispose();
_pyramidPainter.dispose(); _pyramidPainter.dispose();
MapboxVectorTileCache.clear();
TilePyramidCache.clear(); TilePyramidCache.clear();
SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings); SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings);
_interactionIdleTimer?.cancel(); _interactionIdleTimer?.cancel();
super.dispose(); super.dispose();
} }
@override
void reassemble() {
super.reassemble();
MapboxVectorTileCache.clear();
TilePyramidCache.clear();
_pyramidPainter.markDirty();
if (!mounted) return;
setState(() {
_fallbackTileZoom = null;
});
}
void _onFrameTimings(List<FrameTiming> timings) { void _onFrameTimings(List<FrameTiming> timings) {
for (final timing in timings) { for (final timing in timings) {
_frameMsWindow.add(timing.totalSpan.inMicroseconds / 1000.0); _frameMsWindow.add(timing.totalSpan.inMicroseconds / 1000.0);
@ -256,8 +272,10 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
int _idealTileZoom(double zoomValue, {required double devicePixelRatio}) { int _idealTileZoom(double zoomValue, {required double devicePixelRatio}) {
final ratio = devicePixelRatio.clamp(1.0, 4.0).toDouble(); final ratio = devicePixelRatio.clamp(1.0, 4.0).toDouble();
final adjusted = zoomValue + (math.log(ratio / 2.0) / math.ln2); // Bias toward higher-detail tiles on high-DPI screens so the visible
return adjusted.floor().clamp(_minTileZoom, _maxTileZoom); // raster layer matches the viewport density more closely.
final adjusted = zoomValue + (math.log(ratio) / math.ln2);
return adjusted.round().clamp(_minTileZoom, _maxTileZoom);
} }
void _setZoomAroundFocal(double requestedZoom, Offset focal) { void _setZoomAroundFocal(double requestedZoom, Offset focal) {
@ -417,6 +435,20 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
_resolveRoute(); _resolveRoute();
} }
Future<void> _inspectFeatureAt(Offset localPosition) async {
setState(() {
_isInspectingFeature = true;
});
final hit = await _pyramidPainter.inspectFeatureAt(localPosition);
if (!mounted) return;
setState(() {
_debugFeatureHit = hit;
_isInspectingFeature = false;
});
}
List<Widget> _buildPointWidgets() { List<Widget> _buildPointWidgets() {
final widgets = <Widget>[]; final widgets = <Widget>[];
for (var i = 0; i < _points.length; i++) { for (var i = 0; i < _points.length; i++) {
@ -572,11 +604,12 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
_queuePan(details.delta); _queuePan(details.delta);
}, },
onTapUp: (details) { onTapUp: (details) {
if (!_isAddPointArmed || if (_draggingInsertPointIndex != null) return;
_draggingInsertPointIndex != null) { if (_isAddPointArmed) {
_addPoint(_screenToLatLng(details.localPosition));
return; return;
} }
_addPoint(_screenToLatLng(details.localPosition)); _inspectFeatureAt(details.localPosition);
}, },
child: ClipRect( child: ClipRect(
child: Stack( child: Stack(
@ -658,11 +691,22 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
alignment: Alignment.topRight, alignment: Alignment.topRight,
child: Padding( child: Padding(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: IgnorePointer( child: Column(
child: _PerformanceHud( mainAxisSize: MainAxisSize.min,
fps: _displayFps, crossAxisAlignment: CrossAxisAlignment.end,
frameMs: _displayFrameMs, children: [
), IgnorePointer(
child: _PerformanceHud(
fps: _displayFps,
frameMs: _displayFrameMs,
),
),
const SizedBox(height: 8),
_DebugInspectPanel(
hit: _debugFeatureHit,
isInspecting: _isInspectingFeature,
),
],
), ),
), ),
), ),
@ -750,6 +794,83 @@ class _HudCornerNotice extends StatelessWidget {
} }
} }
class _DebugInspectPanel extends StatelessWidget {
const _DebugInspectPanel({required this.hit, required this.isInspecting});
final MapDebugHit? hit;
final bool isInspecting;
@override
Widget build(BuildContext context) {
if (!isInspecting && hit == null) return const SizedBox.shrink();
final rows = <String>[
if (hit != null) 'layer: ${hit!.layerName}',
if (hit != null) 'type: ${hit!.geometryType.name}',
if (hit != null) 'tile: z${hit!.tileZ}/${hit!.tileX}/${hit!.tileY}',
if (hit != null) 'distance: ${hit!.distance.toStringAsFixed(1)}',
if (hit != null)
...hit!.properties.entries.map(
(entry) => '${entry.key}: ${entry.value}',
),
];
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.94),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x22000000)),
boxShadow: const [
BoxShadow(
color: Color(0x22000000),
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: Padding(
padding: const EdgeInsets.all(12),
child: DefaultTextStyle(
style: const TextStyle(
fontSize: 12,
height: 1.35,
color: Color(0xFF1F2937),
fontWeight: FontWeight.w600,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isInspecting ? 'Inspecting map...' : 'Map Inspect',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: Color(0xFF111827),
),
),
if (!isInspecting && hit == null)
const Padding(
padding: EdgeInsets.only(top: 6),
child: Text('Click the map to inspect a feature.'),
),
if (rows.isNotEmpty) const SizedBox(height: 6),
for (final row in rows)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(row),
),
],
),
),
),
),
);
}
}
class _PerformanceHud extends StatelessWidget { class _PerformanceHud extends StatelessWidget {
const _PerformanceHud({required this.fps, required this.frameMs}); const _PerformanceHud({required this.fps, required this.frameMs});

View file

@ -68,4 +68,9 @@ class MapboxVectorTileCache {
_cache.remove(_cache.keys.first); _cache.remove(_cache.keys.first);
} }
} }
static void clear() {
_cache.clear();
_inFlight.clear();
}
} }

View file

@ -17,18 +17,13 @@ class _TileCoord {
} }
class CachedTile { class CachedTile {
CachedTile(this.image, this.zoomBucket); CachedTile(this.image);
final ui.Image image; final ui.Image image;
final int zoomBucket;
} }
// Static tile pyramid image cache. Stores rasterised ui.Image per (z, x, y). // Static tile pyramid image cache. Stores one rasterised ui.Image per
// Each entry also records the zoom bucket it was rendered at callers can // deterministic pyramid tile coordinate (z, x, y).
// check whether a re-render is warranted, but the old image stays as a
// fallback until the new one arrives.
//
// Ancestor lookup (for fallback blitting) just calls peek() at a coarser z.
class TilePyramidCache { class TilePyramidCache {
TilePyramidCache._(); TilePyramidCache._();
@ -67,7 +62,6 @@ class TilePyramidCache {
int z, int z,
int x, int x,
int y, int y,
int zoomBucket,
Future<ui.Image?> Function() render, Future<ui.Image?> Function() render,
void Function() onReady, void Function() onReady,
) { ) {
@ -77,14 +71,7 @@ class TilePyramidCache {
if (filter != null && !filter(z, x, y)) return; if (filter != null && !filter(z, x, y)) return;
_inFlight.add(coord); _inFlight.add(coord);
_queue.add( _queue.add(_Queued(coord: coord, render: render, onReady: onReady));
_Queued(
coord: coord,
zoomBucket: zoomBucket,
render: render,
onReady: onReady,
),
);
_drain(); _drain();
} }
@ -118,7 +105,7 @@ class TilePyramidCache {
existing.image.dispose(); existing.image.dispose();
} }
_cache[item.coord] = CachedTile(image, item.zoomBucket); _cache[item.coord] = CachedTile(image);
_approxBytes += _estimateBytes(image); _approxBytes += _estimateBytes(image);
_trim(); _trim();
@ -176,13 +163,11 @@ class TilePyramidCache {
class _Queued { class _Queued {
const _Queued({ const _Queued({
required this.coord, required this.coord,
required this.zoomBucket,
required this.render, required this.render,
required this.onReady, required this.onReady,
}); });
final _TileCoord coord; final _TileCoord coord;
final int zoomBucket;
final Future<ui.Image?> Function() render; final Future<ui.Image?> Function() render;
final void Function() onReady; final void Function() onReady;
} }

File diff suppressed because it is too large Load diff

View file

@ -197,6 +197,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.8.1" version: "14.8.1"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
hive: hive:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -41,6 +41,7 @@ dependencies:
go_router: ^14.8.1 go_router: ^14.8.1
http: ^1.6.0 http: ^1.6.0
shadcn_flutter: ^0.0.51 shadcn_flutter: ^0.0.51
google_fonts: ^6.3.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: