feat(train): the instance's own map tiles, drawn by Train's style (ADR 0030, D50)

Protomaps' basemap cut for Europe and served by pmtiles beside Train's server
(compose.maps.yaml), drawn by Train's own style, light and dark, which the
server serves. Only people signed in get tiles: map.source hands a key that
Caddy has the server check before each tile; nothing logs a tile.

Also fixes .dockerignore, which left apps/train/ out of the build context, so
Train's server's and BRouter's images couldn't have been built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 16:04:05 +01:00
commit 190c3872f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
42 changed files with 3726 additions and 97 deletions

View file

@ -8,6 +8,9 @@
# Dictionary files: tens of megabytes, mounted on an instance, never built in
apps/learn/halkyon_learn_server/dictionaries/
deploy/dictionaries/
# BRouter's segments and the map's tiles: gigabytes, mounted on an instance, never built in
deploy/brouter/segments/
deploy/maps/tiles/
# Published studysets' repositories of a development server
apps/learn/halkyon_learn_server/repositories/
apps/learn/halkyon_learn_server/.serverpod/
@ -26,7 +29,9 @@ apps/learn/halkyon_learn_flutter/
apps/learn/halkyon_learn_client/
apps/learn/tool/
apps/todo/
apps/train/
apps/train/halkyon_train_flutter/
apps/train/halkyon_train_client/
apps/train/halkyon_train_server/.serverpod/
packages/halkyon_app/
packages/halkyon_interop/
packages/halkyon_keys/

4
.gitignore vendored
View file

@ -29,6 +29,10 @@ deploy/assistants.env
# A sidecar's files on the server, such as Jellyfin's configuration and media
deploy/jellyfin/
deploy/languagetool/
# BRouter's segments and the map's tiles (Train's ADR 0030, D44, D50), downloaded on
# an instance
deploy/brouter/segments/
deploy/maps/tiles/
# Dictionary files an instance loads: made by halkyon_learn_curator, not kept in git.
deploy/dictionaries/*.jsonl
deploy/dictionaries/*.jsonl.gz

File diff suppressed because one or more lines are too long

View file

@ -84,8 +84,9 @@ class EndpointFirmware extends _isc.EndpointRef {
);
}
/// The map workouts are drawn on (ADR 0030, D11, D22), for the people signed
/// in: an operator's provider may have its key in the style's address.
/// The map workouts are drawn on (ADR 0030, D11, D22, D50), for the people
/// signed in: an operator's provider may have its key in the style's
/// address, and the instance's own tiles are theirs alone.
/// {@category Endpoint}
class EndpointMap extends _isc.EndpointRef {
EndpointMap(_isc.EndpointCaller caller) : super(caller);
@ -93,7 +94,8 @@ class EndpointMap extends _isc.EndpointRef {
@override
String get name => 'map';
/// The instance's map source, as its operator sets it.
/// The instance's map source, as its operator sets it, with a key for its
/// own tiles.
_ida.Future<_iebj1x2g.MapSource> source() =>
caller.callServerEndpoint<_iebj1x2g.MapSource>(
'map',

View file

@ -12,32 +12,38 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// The map this instance's workouts are drawn on (ADR 0030, D11, D22), as
/// its operator sets it: a MapLibre style, its source's name and the credit
/// it asks for, and where the source says what it sees. OpenFreeMap where
/// nothing is set.
/// The map this instance's workouts are drawn on (ADR 0030, D11, D22, D50),
/// as its operator sets it: a MapLibre style, its source's name and the
/// credit it asks for, and where the source says what it sees. OpenFreeMap
/// where nothing is set; the instance's own tiles where its maps sidecar runs.
abstract class MapSource
implements _isc.SerializableModel, _isc.ProtocolSerialization {
MapSource._({
required this.name,
required this.styleUrl,
this.darkStyleUrl,
required this.attribution,
this.aboutUrl,
this.tileKey,
});
factory MapSource({
required String name,
required String styleUrl,
String? darkStyleUrl,
required String attribution,
String? aboutUrl,
String? tileKey,
}) = _MapSourceImpl;
factory MapSource.fromJson(Map<String, dynamic> jsonSerialization) {
return MapSource(
name: jsonSerialization['name'] as String,
styleUrl: jsonSerialization['styleUrl'] as String,
darkStyleUrl: jsonSerialization['darkStyleUrl'] as String?,
attribution: jsonSerialization['attribution'] as String,
aboutUrl: jsonSerialization['aboutUrl'] as String?,
tileKey: jsonSerialization['tileKey'] as String?,
);
}
@ -48,6 +54,10 @@ abstract class MapSource
/// provider's key may be in it, so only people signed in get it.
String styleUrl;
/// The style for the app's dark theme; null where the source has one
/// alone (D50).
String? darkStyleUrl;
/// The credit the map shows, as the source's terms ask.
String attribution;
@ -55,14 +65,21 @@ abstract class MapSource
/// where the operator names nothing.
String? aboutUrl;
/// What each tile asks with, as `Authorization: Bearer`, where the tiles
/// are the instance's own (D50): valid some 30 days, naming no one; null
/// for every other source.
String? tileKey;
/// Returns a shallow copy of this [MapSource]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
MapSource copyWith({
String? name,
String? styleUrl,
String? darkStyleUrl,
String? attribution,
String? aboutUrl,
String? tileKey,
});
@override
Map<String, dynamic> toJson() {
@ -70,8 +87,10 @@ abstract class MapSource
'__className__': 'MapSource',
'name': name,
'styleUrl': styleUrl,
if (darkStyleUrl != null) 'darkStyleUrl': darkStyleUrl,
'attribution': attribution,
if (aboutUrl != null) 'aboutUrl': aboutUrl,
if (tileKey != null) 'tileKey': tileKey,
};
}
@ -81,8 +100,10 @@ abstract class MapSource
'__className__': 'MapSource',
'name': name,
'styleUrl': styleUrl,
if (darkStyleUrl != null) 'darkStyleUrl': darkStyleUrl,
'attribution': attribution,
if (aboutUrl != null) 'aboutUrl': aboutUrl,
if (tileKey != null) 'tileKey': tileKey,
};
}
@ -98,13 +119,17 @@ class _MapSourceImpl extends MapSource {
_MapSourceImpl({
required String name,
required String styleUrl,
String? darkStyleUrl,
required String attribution,
String? aboutUrl,
String? tileKey,
}) : super._(
name: name,
styleUrl: styleUrl,
darkStyleUrl: darkStyleUrl,
attribution: attribution,
aboutUrl: aboutUrl,
tileKey: tileKey,
);
/// Returns a shallow copy of this [MapSource]
@ -114,14 +139,18 @@ class _MapSourceImpl extends MapSource {
MapSource copyWith({
String? name,
String? styleUrl,
Object? darkStyleUrl = _Undefined,
String? attribution,
Object? aboutUrl = _Undefined,
Object? tileKey = _Undefined,
}) {
return MapSource(
name: name ?? this.name,
styleUrl: styleUrl ?? this.styleUrl,
darkStyleUrl: darkStyleUrl is String? ? darkStyleUrl : this.darkStyleUrl,
attribution: attribution ?? this.attribution,
aboutUrl: aboutUrl is String? ? aboutUrl : this.aboutUrl,
tileKey: tileKey is String? ? tileKey : this.tileKey,
);
}
}

View file

@ -92,6 +92,7 @@
"workoutSpeedChart": "Geschwindigkeit",
"workoutElevationChart": "Höhe",
"workoutMapSource": "Karte von {name}: sieht die Adresse deines Geräts und den gezeigten Ausschnitt, nicht deine Strecke.",
"workoutMapOwn": "Karte von deiner Instanz, {name}, aus OpenStreetMap: sie speichert nichts vom gezeigten Ausschnitt.",
"workoutMapSourceAbout": "Was {name} speichert",
"workoutMapUnavailable": "Die Karte kann gerade nicht geladen werden.",
"workoutNotFound": "Dieses Training gibt es nicht mehr.",

View file

@ -116,6 +116,8 @@
"workoutElevationChart": "Elevation",
"workoutMapSource": "Map by {name}: it sees your device's address and the area shown, not your track.",
"@workoutMapSource": {"description": "Under a workout's map: what the map's source sees.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}},
"workoutMapOwn": "Map by your instance, {name}, from OpenStreetMap: it keeps nothing of the area shown.",
"@workoutMapOwn": {"description": "Under a workout's map drawn from the instance's own tiles (D50): what it sees.", "placeholders": {"name": {"type": "String", "example": "train.halkyon.eu"}}},
"workoutMapSourceAbout": "What {name} keeps",
"@workoutMapSourceAbout": {"description": "A link to the map source's page about what it keeps.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}},
"workoutMapUnavailable": "The map can't be loaded now.",

View file

@ -610,6 +610,12 @@ abstract class AppLocalizations {
/// **'Map by {name}: it sees your device\'s address and the area shown, not your track.'**
String workoutMapSource(String name);
/// Under a workout's map drawn from the instance's own tiles (D50): what it sees.
///
/// In en, this message translates to:
/// **'Map by your instance, {name}, from OpenStreetMap: it keeps nothing of the area shown.'**
String workoutMapOwn(String name);
/// A link to the map source's page about what it keeps.
///
/// In en, this message translates to:

View file

@ -329,6 +329,11 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Karte von $name: sieht die Adresse deines Geräts und den gezeigten Ausschnitt, nicht deine Strecke.';
}
@override
String workoutMapOwn(String name) {
return 'Karte von deiner Instanz, $name, aus OpenStreetMap: sie speichert nichts vom gezeigten Ausschnitt.';
}
@override
String workoutMapSourceAbout(String name) {
return 'Was $name speichert';

View file

@ -327,6 +327,11 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Map by $name: it sees your device\'s address and the area shown, not your track.';
}
@override
String workoutMapOwn(String name) {
return 'Map by your instance, $name, from OpenStreetMap: it keeps nothing of the area shown.';
}
@override
String workoutMapSourceAbout(String name) {
return 'What $name keeps';

View file

@ -0,0 +1,23 @@
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
/// The style [source] draws by (ADR 0030, D50): its dark one for a [dark]
/// theme where it has one; the instance's own (those handed with a key)
/// naming places in [language].
String styleUrlOf(MapSource source, {bool dark = false, String? language}) {
final url = dark ? source.darkStyleUrl ?? source.styleUrl : source.styleUrl;
if (source.tileKey == null || language == null) {
return url;
}
final uri = Uri.parse(url);
return uri
.replace(queryParameters: {...uri.queryParameters, 'lang': language})
.toString();
}
/// What [source]'s requests carry: the instance's key for its own tiles
/// (D50), as a bearer's; nothing for any other source.
Map<String, String>? tileHeadersOf(MapSource source) =>
switch (source.tileKey) {
final key? => {'authorization': 'Bearer $key'},
null => null,
};

View file

@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/feature/map/data/corridor.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_style.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:http/http.dart' as http;
@ -49,7 +50,9 @@ final class const OfflineStyle({
/// routes; the instance's style kept as it was last read, so that the map
/// draws without a network. In the person's own folder, never synced.
///
/// The folder holds `style/` (the style, its sources, its sprites),
/// The folder holds `style/<hash>/` (each style read, light or dark, in a
/// language: its JSON, its sources, its sprites), the instance's own tiles
/// asked for with its key (D50),
/// `tiles/<source>/<z>/<x>/<y>` and `routes/<id>.json`, each kept route's
/// tiles and what they were made for.
class OfflineMaps(
@ -191,7 +194,10 @@ class OfflineMaps(
.replaceAll('{x}', x)
.replaceAll('{y}', y);
try {
final response = await _http.get(Uri.parse(url));
final response = await _http.get(
Uri.parse(url),
headers: tileHeadersOf(source),
);
if (response.statusCode == 200) {
await _write(file, response.bodyBytes);
size = response.bodyBytes.length;
@ -289,16 +295,22 @@ class OfflineMaps(
}
}
/// [source]'s style read from it and kept, or as it was last kept where
/// it can't be read now; null where neither can be had.
Future<OfflineStyle?> style(MapSource source) async {
/// [source]'s style ([styleUrlOf] for [dark] and [language]) read from
/// it and kept, or as it was last kept where it can't be read now; null
/// where neither can be had.
Future<OfflineStyle?> style(
MapSource source, {
bool dark = false,
String? language,
}) async {
final url = styleUrlOf(source, dark: dark, language: language);
try {
final style = await _read(source.styleUrl);
await _keepStyle(source.styleUrl, style);
final style = await _read(url, headers: tileHeadersOf(source));
await _keepStyle(url, style);
return style;
} on Object catch (error) {
log('The style not read: ${error.runtimeType}', name: 'Map');
return await _keptStyle(source.styleUrl);
return await _keptStyle(url);
}
}
@ -322,8 +334,8 @@ class OfflineMaps(
: {...kept.value, id: map};
}
Future<OfflineStyle> _read(String url) async {
final json = _object(await _get(url));
Future<OfflineStyle> _read(String url, {Map<String, String>? headers}) async {
final json = _object(await _get(url, headers: headers));
final sources = <String, StyleSource>{};
for (final MapEntry(:key, :value) in _object(json['sources']).entries) {
// A source's name becomes a folder of its tiles.
@ -336,7 +348,9 @@ class OfflineMaps(
continue;
}
final tileJson = switch (source['url']) {
final String at => _object(await _get(_resolve(at, url: url))),
final String at => _object(
await _get(_resolve(at, url: url), headers: headers),
),
_ => source,
};
if (tileJson['tiles'] case [final String tiles, ...]) {
@ -376,9 +390,18 @@ class OfflineMaps(
);
}
/// Where the style read from [url] is kept.
Directory _styleOf(String url) => Directory(
p.join(
_style.path,
sha256.convert(utf8.encode(url)).toString().substring(0, 16),
),
);
Future<void> _keepStyle(String url, OfflineStyle style) async {
final folder = _styleOf(url);
await _write(
File(p.join(_style.path, 'style.json')),
File(p.join(folder.path, 'style.json')),
utf8.encode(
jsonEncode({
'url': url,
@ -396,7 +419,7 @@ class OfflineMaps(
}),
),
);
final image = File(p.join(_style.path, 'sprite.png'));
final image = File(p.join(folder.path, 'sprite.png'));
if (style.spriteImage case final bytes?) {
await _write(image, bytes);
} else if (image.existsSync()) {
@ -406,15 +429,16 @@ class OfflineMaps(
Future<OfflineStyle?> _keptStyle(String url) async {
try {
final folder = _styleOf(url);
final kept = _object(
jsonDecode(
await File(p.join(_style.path, 'style.json')).readAsString(),
await File(p.join(folder.path, 'style.json')).readAsString(),
),
);
if (kept['url'] != url) {
return null;
}
final image = File(p.join(_style.path, 'sprite.png'));
final image = File(p.join(folder.path, 'sprite.png'));
return OfflineStyle(
json: _object(kept['style']),
sources: {
@ -442,8 +466,8 @@ class OfflineMaps(
}
}
Future<Object?> _get(String url) async {
final response = await _http.get(Uri.parse(url));
Future<Object?> _get(String url, {Map<String, String>? headers}) async {
final response = await _http.get(Uri.parse(url), headers: headers);
if (response.statusCode != 200) {
throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url));
}

View file

@ -1,7 +1,8 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/feature/map/data/map_style.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:vector_map_tiles/vector_map_tiles.dart';
import 'package:vector_tile_renderer/vector_tile_renderer.dart'
@ -11,19 +12,36 @@ import 'package:vector_tile_renderer/vector_tile_renderer.dart'
/// in widget tests, which reach no network.
typedef MapTiles = Widget Function(MapSource source);
/// The style for the app's theme and language (D50): dark or not, and the
/// language's code.
typedef _StyleFor = Future<Style> Function(bool dark, String language);
/// The source's vector tiles, drawn by its MapLibre style (ADR 0030, D11,
/// D23); nothing while the style loads, nor where it can't be read.
Widget vectorTiles(MapSource source) =>
_VectorTiles(style: () => StyleReader(uri: source.styleUrl).read());
/// D23), the dark one in a dark theme (D50), the instance's own tiles asked
/// for with its key; nothing while the style loads, nor where it can't be
/// read.
Widget vectorTiles(MapSource source) => _VectorTiles(
style: (dark, language) => StyleReader(
uri: styleUrlOf(source, dark: dark, language: language),
httpHeaders: tileHeadersOf(source),
).read(),
);
/// [vectorTiles] with what [maps] keeps offline (D45): the style as it was
/// last read where it can't be read now, and a kept route's tiles from the
/// device before the network.
MapTiles keptVectorTiles(OfflineMaps maps) =>
(source) => _VectorTiles(style: () => _keptStyle(maps, source));
(source) => _VectorTiles(
style: (dark, language) => _keptStyle(maps, source, dark, language),
);
Future<Style> _keptStyle(OfflineMaps maps, MapSource source) async {
final style = await maps.style(source);
Future<Style> _keptStyle(
OfflineMaps maps,
MapSource source,
bool dark,
String language,
) async {
final style = await maps.style(source, dark: dark, language: language);
if (style == null) {
throw StateError('No style for ${source.name}');
}
@ -33,7 +51,7 @@ Future<Style> _keptStyle(OfflineMaps maps, MapSource source) async {
theme: ThemeReader().read(style.json),
providers: TileProviders({
for (final MapEntry(:key, :value) in style.sources.entries)
key: _KeptFirst(maps, key, value),
key: _KeptFirst(maps, key, value, tileHeadersOf(source)),
}),
sprites: sprite != null && image != null
? SpriteStyle(
@ -49,9 +67,11 @@ class _KeptFirst(
final OfflineMaps _maps,
final String _name,
final StyleSource _source,
final Map<String, String>? _headers,
) extends VectorTileProvider {
late final _network = NetworkVectorTileProvider(
urlTemplate: _source.tiles,
httpHeaders: _headers,
type: type,
maximumZoom: maximumZoom,
minimumZoom: minimumZoom,
@ -77,14 +97,28 @@ class _KeptFirst(
: TileProviderType.vector;
}
class const _VectorTiles({required final Future<Style> Function() style})
class const _VectorTiles({required final _StyleFor style})
extends StatefulWidget {
@override
State<_VectorTiles> createState() => _VectorTilesState();
}
class _VectorTilesState() extends State<_VectorTiles> {
late final Future<Style> _style = widget.style();
Future<Style>? _style;
(bool, String)? _for;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final wanted = (
Theme.of(context).brightness == Brightness.dark,
Localizations.localeOf(context).languageCode,
);
if (wanted != _for) {
_for = wanted;
_style = widget.style(wanted.$1, wanted.$2);
}
}
@override
Widget build(BuildContext context) => FutureBuilder(

View file

@ -458,7 +458,12 @@ class const _MapNote({required final WorkoutMap? map}) extends StatelessWidget {
MapFrom(:final source) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.workoutMapSource(source.name), style: style),
Text(
source.tileKey == null
? l10n.workoutMapSource(source.name)
: l10n.workoutMapOwn(source.name),
style: style,
),
if (source.aboutUrl case final url?)
TextButton.icon(
style: TextButton.styleFrom(padding: EdgeInsets.zero),

View file

@ -84,6 +84,7 @@ void main() {
RecordingDevice? recording,
WorkoutRepository? workouts,
bool mapReachable = true,
MapSource? mapSource,
HealthConnect? health,
ProfileRepository? profile,
DayRepository? days,
@ -208,7 +209,9 @@ void main() {
health: health,
loop: loop,
maps: MapSourceRepository(
_FakeMapSourceGateway(mapReachable ? openFreeMap : null),
_FakeMapSourceGateway(
mapReachable ? mapSource ?? openFreeMap : null,
),
),
// No tiles: widget tests reach no network.
mapTiles: (source) => const SizedBox.shrink(),
@ -2604,6 +2607,35 @@ void main() {
expect(find.text('Pace'), findsOneWidget);
});
testWidgets("drawn from the instance's own tiles, the page says the "
'instance keeps nothing of the area shown (D50)', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await start(
tester,
usedBefore: true,
workouts: workouts,
mapSource: MapSource(
name: 'train.example.org',
styleUrl: 'https://train.example.org/map/style/light.json',
darkStyleUrl: 'https://train.example.org/map/style/dark.json',
attribution: '© OpenStreetMap contributors · Protomaps',
tileKey: 'm1.20000.mac',
),
);
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
expect(
find.text(
'Map by your instance, train.example.org, from OpenStreetMap: it '
'keeps nothing of the area shown.',
),
findsOneWidget,
);
expect(find.textContaining('sees your device'), findsNothing);
});
testWidgets("a workout with a strap shows the heart's average and highest "
'rate, by split too, and its chart', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30), strap: true);

View file

@ -0,0 +1,70 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/feature/map/data/map_style.dart';
import 'package:vector_tile_renderer/vector_tile_renderer.dart';
/// Train's style for the instance's own tiles (ADR 0030, D50), as its
/// server makes it (the fixtures its tests keep), read by the renderer
/// without a warning; and which style and key a source's requests take.
void main() {
for (final name in ['train_style_light_de.json', 'train_style_dark.json']) {
test(
'$name: vector_tile_renderer reads each layer, warning of nothing',
() {
final style = jsonDecode(
File('test/fixtures/map/$name').readAsStringSync(),
) as Map<String, dynamic>;
final warnings = <String>[];
final theme = ThemeReader(logger: _Warnings(warnings)).read(style);
expect(warnings, isEmpty);
expect(theme.layers, hasLength((style['layers'] as List).length));
},
);
}
final own = MapSource(
name: 'train.example.org',
styleUrl: 'https://train.example.org/map/style/light.json',
darkStyleUrl: 'https://train.example.org/map/style/dark.json',
attribution: '© OpenStreetMap contributors · Protomaps',
tileKey: 'm1.20000.mac',
);
final provider = MapSource(
name: 'MapTiler',
styleUrl: 'https://api.maptiler.com/maps/outdoor-v2/style.json?key=k',
attribution: '© MapTiler',
);
test("the instance's own: dark in a dark theme, in the app's language, "
'its tiles asked for with its key', () {
expect(
styleUrlOf(own, dark: true, language: 'de'),
'https://train.example.org/map/style/dark.json?lang=de',
);
expect(styleUrlOf(own), own.styleUrl);
expect(tileHeadersOf(own), {'authorization': 'Bearer m1.20000.mac'});
});
test(
"a provider's: its one style as given, and no key of the instance's",
() {
expect(
styleUrlOf(provider, dark: true, language: 'de'),
provider.styleUrl,
);
expect(tileHeadersOf(provider), isNull);
},
);
}
class _Warnings(final List<String> _warnings) implements Logger {
@override
void log(MessageFunction message) {}
@override
void warn(MessageFunction message) => _warnings.add(message());
}

View file

@ -217,6 +217,49 @@ void main() {
},
);
test("the instance's own tiles are kept with its key; another source's "
'with none (D50)', () async {
final own = MapSource(
name: 'tiles.example',
styleUrl: 'https://tiles.example/map/style/light.json',
darkStyleUrl: 'https://tiles.example/map/style/dark.json',
attribution: '© OpenStreetMap',
tileKey: 'm1.20000.mac',
);
await maps().keep('route-1', north, own);
expect(server.keys, isNotEmpty);
expect(server.keys.toSet(), {'Bearer m1.20000.mac'});
server.keys.clear();
await maps().keep('route-2', east, source);
expect(server.keys.toSet(), {null});
});
test("each style is kept apart: light and dark, in the app's language "
"for the instance's own (D50)", () async {
final own = MapSource(
name: 'tiles.example',
styleUrl: 'https://tiles.example/map/style/light.json',
darkStyleUrl: 'https://tiles.example/map/style/dark.json',
attribution: '© OpenStreetMap',
tileKey: 'm1.20000.mac',
);
await maps().style(own, language: 'de');
await maps().style(own, dark: true, language: 'de');
expect(server.styles, [
'https://tiles.example/map/style/light.json?lang=de',
'https://tiles.example/map/style/dark.json?lang=de',
]);
server.offline = true;
expect((await maps().style(own, language: 'de'))!.json['name'], 'Liberty');
expect(
(await maps().style(own, dark: true, language: 'de'))!.json['name'],
'Dark',
);
expect(await maps().style(own, language: 'en'), isNull);
});
test('the style read once draws offline, with its sprites; another '
"source's does not", () async {
final online = await maps().style(source);
@ -249,6 +292,12 @@ void main() {
/// tiles asked for listed.
class _Tiles() {
final tiles = <String>[];
/// The `Authorization` header each tile came with, null for none.
final keys = <String?>[];
/// The styles asked for, with their query.
final styles = <String>[];
bool failing = false;
bool empty = false;
bool offline = false;
@ -259,19 +308,24 @@ class _Tiles() {
}
final path = request.url.path;
return switch (path) {
'/styles/liberty' => http.Response(
jsonEncode({
'version': 8,
'name': 'Liberty',
'sources': {
'planet': {'type': 'vector', 'url': '/planet'},
'shade': {'type': 'raster-dem', 'url': '/dem'},
},
'sprite': 'https://tiles.example/sprites/liberty',
'layers': <Object?>[],
}),
200,
),
'/styles/liberty' ||
'/map/style/light.json' ||
'/map/style/dark.json' => () {
styles.add(request.url.toString());
return http.Response(
jsonEncode({
'version': 8,
'name': path.contains('dark') ? 'Dark' : 'Liberty',
'sources': {
'planet': {'type': 'vector', 'url': '/planet'},
'shade': {'type': 'raster-dem', 'url': '/dem'},
},
'sprite': 'https://tiles.example/sprites/liberty',
'layers': <Object?>[],
}),
200,
);
}(),
'/planet' => http.Response(
jsonEncode({
'tiles': ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
@ -292,6 +346,7 @@ class _Tiles() {
),
_ when path.startsWith('/planet/') => () {
tiles.add(path.substring(1).replaceAll('.pbf', ''));
keys.add(request.headers['authorization']);
if (failing) {
return http.Response('', 503);
}

View file

@ -0,0 +1,958 @@
{
"version": 8,
"name": "Halkyon Train, dark",
"sources": {
"protomaps": {
"type": "vector",
"tiles": [
"https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt"
],
"minzoom": 0,
"maxzoom": 15,
"attribution": "<a href=\"https://www.openstreetmap.org/copyright\">© OpenStreetMap contributors</a> · <a href=\"https://protomaps.com\">Protomaps</a>"
}
},
"layers": [
{
"id": "background",
"type": "background",
"paint": {
"background-color": "#1b3042"
}
},
{
"id": "earth",
"type": "fill",
"source": "protomaps",
"source-layer": "earth",
"paint": {
"fill-color": "#1d2024"
}
},
{
"id": "landcover",
"type": "fill",
"source": "protomaps",
"source-layer": "landcover",
"paint": {
"fill-color": [
"match",
[
"get",
"kind"
],
[
"forest",
"scrub"
],
"#22302a",
[
"grassland",
"farmland"
],
"#222a24",
"glacier",
"#2b3036",
"barren",
"#2e2c25",
"#212428"
]
}
},
{
"id": "landuse-wood",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"forest",
"wood",
"orchard",
"scrub"
],
true,
false
],
"paint": {
"fill-color": "#22302a"
}
},
{
"id": "landuse-park",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"park",
"garden",
"grass",
"meadow",
"recreation_ground",
"playground",
"dog_park",
"golf_course",
"nature_reserve",
"national_park",
"protected_area",
"cemetery",
"camp_site"
],
true,
false
],
"paint": {
"fill-color": "#233127"
}
},
{
"id": "landuse-pitch",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"pitch",
"stadium"
],
true,
false
],
"paint": {
"fill-color": "#253427"
}
},
{
"id": "landuse-sand",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"beach",
"sand",
"bare_rock"
],
true,
false
],
"paint": {
"fill-color": "#2e2c25"
}
},
{
"id": "landuse-wetland",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"wetland"
],
true,
false
],
"paint": {
"fill-color": "#20302d"
}
},
{
"id": "landuse-built",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"industrial",
"commercial",
"railway",
"military",
"aerodrome",
"hospital",
"school",
"college",
"university",
"kindergarten"
],
true,
false
],
"paint": {
"fill-color": "#25272b"
}
},
{
"id": "landuse-runway",
"type": "fill",
"source": "protomaps",
"source-layer": "landuse",
"filter": [
"match",
[
"get",
"kind"
],
[
"runway",
"taxiway"
],
true,
false
],
"paint": {
"fill-color": "#2c2e32"
}
},
{
"id": "water",
"type": "fill",
"source": "protomaps",
"source-layer": "water",
"filter": [
"==",
[
"geometry-type"
],
"Polygon"
],
"paint": {
"fill-color": "#1b3042"
}
},
{
"id": "water-lines",
"type": "line",
"source": "protomaps",
"source-layer": "water",
"filter": [
"==",
[
"geometry-type"
],
"LineString"
],
"minzoom": 9,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#1b3042",
"line-width": [
"match",
[
"get",
"kind_detail"
],
[
"river",
"canal"
],
[
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1.5,
18,
12
],
[
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
0.5,
18,
3
]
]
}
},
{
"id": "buildings",
"type": "fill",
"source": "protomaps",
"source-layer": "buildings",
"filter": [
"match",
[
"get",
"kind"
],
[
"building",
"building_part"
],
true,
false
],
"minzoom": 14,
"paint": {
"fill-color": "#2a2c30"
}
},
{
"id": "boundaries",
"type": "line",
"source": "protomaps",
"source-layer": "boundaries",
"filter": [
"match",
[
"get",
"kind"
],
[
"country",
"region"
],
true,
false
],
"paint": {
"line-color": "#7d7290",
"line-width": [
"match",
[
"get",
"kind"
],
"country",
1.5,
0.8
],
"line-dasharray": [
3,
2
]
}
},
{
"id": "rail",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"rail"
],
true,
false
],
"minzoom": 10,
"paint": {
"line-color": "#55575b",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
0.8,
18,
3
]
}
},
{
"id": "roads-major-casing",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"highway",
"major_road"
],
true,
false
],
"minzoom": 7,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#15171a",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
3,
18,
22
]
}
},
{
"id": "roads-minor-casing",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"minor_road"
],
true,
false
],
"minzoom": 13,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#15171a",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1.5,
18,
14
]
}
},
{
"id": "roads-minor",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"minor_road"
],
true,
false
],
"minzoom": 11,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#3a3d42",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
0.8,
18,
11
]
}
},
{
"id": "roads-major",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"match",
[
"get",
"kind"
],
[
"highway",
"major_road"
],
true,
false
],
"minzoom": 5,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": [
"match",
[
"get",
"kind"
],
"highway",
"#6a573a",
"#4b4639"
],
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
2,
18,
18
]
}
},
{
"id": "tracks",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"all",
[
"match",
[
"get",
"kind"
],
[
"path",
"minor_road"
],
true,
false
],
[
"match",
[
"get",
"kind_detail"
],
[
"track"
],
true,
false
]
],
"minzoom": 12,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#b88a5a",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1,
18,
4
],
"line-dasharray": [
4,
1.5
]
}
},
{
"id": "footpaths",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"all",
[
"match",
[
"get",
"kind"
],
[
"path"
],
true,
false
],
[
"match",
[
"get",
"kind_detail"
],
[
"footway",
"path",
"pedestrian",
"steps",
"bridleway"
],
true,
false
]
],
"minzoom": 13,
"paint": {
"line-color": "#d07a5c",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1,
18,
3.5
],
"line-dasharray": [
1.5,
1.5
]
}
},
{
"id": "cycleways",
"type": "line",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"all",
[
"match",
[
"get",
"kind"
],
[
"path"
],
true,
false
],
[
"match",
[
"get",
"kind_detail"
],
[
"cycleway"
],
true,
false
]
],
"minzoom": 12,
"layout": {
"line-cap": "round",
"line-join": "round"
},
"paint": {
"line-color": "#5fb5ae",
"line-width": [
"interpolate",
[
"exponential",
1.6
],
[
"zoom"
],
12,
1,
18,
4
]
}
},
{
"id": "water-names",
"type": "symbol",
"source": "protomaps",
"source-layer": "water",
"filter": [
"has",
"name"
],
"minzoom": 12,
"layout": {
"symbol-placement": "line",
"text-field": [
"get",
"name"
],
"text-size": 12
},
"paint": {
"text-color": "#8fb2cb",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "road-names",
"type": "symbol",
"source": "protomaps",
"source-layer": "roads",
"filter": [
"all",
[
"has",
"name"
],
[
"match",
[
"get",
"kind"
],
[
"highway",
"major_road",
"minor_road",
"path"
],
true,
false
]
],
"minzoom": 14,
"layout": {
"symbol-placement": "line",
"text-field": [
"get",
"name"
],
"text-size": 12
},
"paint": {
"text-color": "#e2ded8",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "places-local",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"filter": [
"match",
[
"get",
"kind"
],
[
"neighbourhood",
"macrohood"
],
true,
false
],
"minzoom": 13,
"layout": {
"text-field": [
"get",
"name"
],
"text-size": 12,
"text-transform": "uppercase",
"text-letter-spacing": 0.08
},
"paint": {
"text-color": "#a29b92",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "places-locality",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"filter": [
"match",
[
"get",
"kind"
],
[
"locality"
],
true,
false
],
"layout": {
"text-field": [
"get",
"name"
],
"text-size": [
"interpolate",
[
"linear"
],
[
"get",
"population_rank"
],
1,
11,
15,
18
]
},
"paint": {
"text-color": "#e2ded8",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "places-region",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"filter": [
"match",
[
"get",
"kind"
],
[
"region"
],
true,
false
],
"layout": {
"text-field": [
"get",
"name"
],
"text-size": 12,
"text-transform": "uppercase"
},
"paint": {
"text-color": "#a29b92",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
},
{
"id": "places-country",
"type": "symbol",
"source": "protomaps",
"source-layer": "places",
"filter": [
"match",
[
"get",
"kind"
],
[
"country"
],
true,
false
],
"layout": {
"text-field": [
"get",
"name"
],
"text-size": 15
},
"paint": {
"text-color": "#e2ded8",
"text-halo-color": "#1d2024",
"text-halo-width": 1.5
}
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_train_server/src/generated/serverpod.dart';
import 'package:halkyon_train_server/src/map/instance_map.dart';
import 'package:halkyon_train_server/src/pruning.dart';
import 'package:halkyon_train_server/src/train_base.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -26,6 +27,10 @@ Future<void> run(List<String> args) async {
// The calls between the instance's services, on the web server
// (ADR 0016); Caddy never forwards them.
addInstanceRoutes(pod);
// The instance's own tiles' style and their check (ADR 0030, D50), which
// Caddy asks before each tile; no start where they couldn't be had.
checkMapKeySecret(pod);
addMapRoutes(pod);
await pod.start();

Some files were not shown because too many files have changed in this diff Show more