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:
parent
a848b71bd4
commit
190c3872f4
42 changed files with 3726 additions and 97 deletions
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
958
apps/train/halkyon_train_flutter/test/fixtures/map/train_style_dark.json
vendored
Normal file
958
apps/train/halkyon_train_flutter/test/fixtures/map/train_style_dark.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
1000
apps/train/halkyon_train_flutter/test/fixtures/map/train_style_light_de.json
vendored
Normal file
1000
apps/train/halkyon_train_flutter/test/fixtures/map/train_style_light_de.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
Loading…
Reference in a new issue