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
8 changed files with 3726 additions and 97 deletions

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();

View file

@ -12,32 +12,38 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// 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 _is.SerializableModel, _is.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.
@_is.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

@ -0,0 +1,130 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/map/map_key.dart';
import 'package:halkyon_train_server/src/map/map_source_config.dart';
import 'package:halkyon_train_server/src/map/train_style.dart';
import 'package:serverpod/serverpod.dart';
/// The instance's map (ADR 0030, D11, D22, D50): its source as the operator
/// sets it and, where the tiles are its own, Train's style for them and the
/// keys their tiles are asked with.
class InstanceMap {
InstanceMap(this.config, {this.keys});
/// The map of [pod], made the first time it is asked for, from the
/// operator's environment and `mapKeySecret`. Tests set their own.
factory InstanceMap.of(Serverpod pod) => _ofPod[pod] ??= InstanceMap(
mapConfig(),
keys: switch (pod.getPassword('mapKeySecret')) {
final secret? when secret.length >= mapKeySecretMinLength => MapKeys(
secret,
),
_ => null,
},
);
static final _ofPod = Expando<InstanceMap>('InstanceMap');
/// Makes [map] the one of [pod].
static void use(Serverpod pod, InstanceMap map) => _ofPod[pod] = map;
final MapConfig config;
final MapKeys? keys;
/// The source as `map.source` hands it, with a key for the instance's
/// own tiles.
MapSource source() => switch ((config.tiles, keys)) {
(_?, final keys?) => config.source.copyWith(tileKey: keys.issue()),
_ => config.source,
};
/// Train's style [name] (`light` or `dark`) in [language], as JSON; null
/// for another name or where the tiles aren't the instance's own.
String? style(String name, {String? language}) {
final tiles = config.tiles;
if (tiles == null || (name != 'light' && name != 'dark')) {
return null;
}
return jsonEncode(
trainStyle(tiles: tiles, dark: name == 'dark', language: language),
);
}
/// Whether a tile asked for with [authorization] (its `Authorization`
/// header's values) may be had: never where the tiles aren't the
/// instance's own.
bool allows(Iterable<String>? authorization) => switch ((
config.tiles,
keys,
MapKeys.bearerOf(authorization),
)) {
(_?, final keys?, final key?) => keys.valid(key),
_ => false,
};
}
/// Throws a [StateError] where [pod]'s operator serves the instance's own
/// tiles (`MAP_TILES_URL`) without a `mapKeySecret` of at least
/// [mapKeySecretMinLength] characters: no tile could be had.
void checkMapKeySecret(Serverpod pod) {
final map = InstanceMap.of(pod);
if (map.config.tiles != null && map.keys == null) {
throw StateError(
'The instance serves its own tiles (MAP_TILES_URL) but has no '
'mapKeySecret of $mapKeySecretMinLength characters or more '
'(SERVERPOD_PASSWORD_mapKeySecret). Generate one: openssl rand -hex 32',
);
}
}
/// Train's style at `/map/style/<name>.json` and the tiles' check at
/// `/map/tile-key`, on [pod]'s web server, which Caddy reaches for them
/// (`sidecars/train-maps.caddy`).
void addMapRoutes(Serverpod pod) {
if (pod.config.webServer == null) {
return;
}
pod.webServer
..addRoute(_StyleRoute(), '/map/style/*')
..addRoute(_TileKeyRoute(), '/map/tile-key');
}
/// Train's style: public, as nothing in it is secret; kept an hour.
class _StyleRoute() extends Route {
this : super(methods: {Method.get, Method.head});
@override
Future<Result> handleCall(Session session, Request request) async {
final file = request.url.pathSegments.last;
final style = file.endsWith('.json')
? InstanceMap.of(session.serverpod).style(
file.substring(0, file.length - '.json'.length),
language: request.url.queryParameters['lang'],
)
: null;
if (style == null) {
return Response.notFound();
}
return Response.ok(
body: Body.fromString(style, mimeType: MimeType.json),
headers: Headers.fromMap({
HttpHeaders.cacheControlHeader: ['public, max-age=3600'],
}),
);
}
}
/// Caddy's question before each tile (`forward_auth`): 204 for a valid key,
/// else 401. Nothing logged: a tile's address shows the area viewed.
class _TileKeyRoute() extends Route {
this : super(methods: {Method.get, Method.head});
@override
Future<Result> handleCall(Session session, Request request) async =>
InstanceMap.of(
session.serverpod,
).allows(request.headers[HttpHeaders.authorizationHeader])
? Response(204)
: Response.unauthorized();
}

View file

@ -1,13 +1,16 @@
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/map/map_source_config.dart';
import 'package:halkyon_train_server/src/map/instance_map.dart';
import 'package:serverpod/serverpod.dart';
/// 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.
class MapEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// The instance's map source, as its operator sets it.
Future<MapSource> source(Session session) async => mapSource();
/// The instance's map source, as its operator sets it, with a key for its
/// own tiles.
Future<MapSource> source(Session session) async =>
InstanceMap.of(session.serverpod).source();
}

View file

@ -0,0 +1,69 @@
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:crypto/crypto.dart';
/// The fewest characters of `mapKeySecret`: whoever has it makes keys for
/// the instance's tiles.
const mapKeySecretMinLength = 32;
/// Keys for the instance's own tiles (ADR 0030, D50): the last day a key is
/// valid, counted from 1970, and an HMAC-SHA256 of it under the instance's
/// secret, `m1.<day>.<mac>`. A key names no one, so that the tiles asked
/// with it point at no person; every key issued on a day is the same.
class MapKeys(final String _secret) {
/// How long a key is valid.
static const lifetime = Duration(days: 30);
/// A key valid for [lifetime] from now.
String issue() {
final day = _today() + lifetime.inDays;
return 'm1.$day.${_mac(day)}';
}
/// Whether [key] is one of [issue]'s still valid.
bool valid(String key) {
final parts = key.split('.');
if (parts.length != 3 || parts[0] != 'm1') {
return false;
}
final day = int.tryParse(parts[1]);
if (day == null || day < _today() || day > _today() + lifetime.inDays) {
return false;
}
final expected = utf8.encode(_mac(day));
final given = utf8.encode(parts[2]);
if (expected.length != given.length) {
return false;
}
// In constant time, so that no answer's time gives a byte away.
var difference = 0;
for (var i = 0; i < expected.length; i++) {
difference |= expected[i] ^ given[i];
}
return difference == 0;
}
/// The key in an `Authorization` header's [values], if it is a bearer's.
static String? bearerOf(Iterable<String>? values) {
for (final value in values ?? const <String>[]) {
final match = RegExp(r'^Bearer\s+(\S+)$').firstMatch(value.trim());
if (match != null) {
return match[1];
}
}
return null;
}
String _mac(int day) => base64Url
.encode(
Hmac(
sha256,
utf8.encode(_secret),
).convert(utf8.encode('m1.$day')).bytes,
)
.replaceAll('=', '');
static int _today() =>
clock.now().toUtc().millisecondsSinceEpoch ~/ Duration.millisecondsPerDay;
}

View file

@ -1,7 +1,7 @@
### 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.
class: MapSource
fields:
### What the app names as drawing the map, as `OpenFreeMap`.
@ -9,8 +9,15 @@ fields:
### The MapLibre (Mapbox GL) style the vector tiles are drawn by; a
### provider's key may be in it, so only people signed in get it.
styleUrl: String
### The style for the app's dark theme; null where the source has one
### alone (D50).
darkStyleUrl: String?
### The credit the map shows, as the source's terms ask.
attribution: String
### Where the source says what it keeps of those viewing its maps; null
### where the operator names nothing.
aboutUrl: String?
### 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.
tileKey: String?

View file

@ -11,27 +11,58 @@ final openFreeMap = MapSource(
aboutUrl: 'https://openfreemap.org',
);
/// The instance's map source, as its operator's environment sets it.
MapSource mapSource() => mapSourceFrom(Platform.environment);
/// The instance's map, as its operator's environment sets it: its source
/// and, where the tiles are the instance's own, their tiles template.
typedef MapConfig = ({MapSource source, String? tiles});
/// The map source [environment] sets: `MAP_STYLE_URL`, a MapLibre style over
/// HTTPS, with `MAP_SOURCE_NAME` (else the style's host), `MAP_ATTRIBUTION`
/// (else OpenStreetMap's credit) and `MAP_ABOUT_URL` (HTTPS, else none);
/// [openFreeMap] without a style, or with one that isn't an HTTPS address.
MapSource mapSourceFrom(Map<String, String> environment) {
final style = _https(environment['MAP_STYLE_URL']);
if (style == null) {
return openFreeMap;
}
/// The instance's map, from its environment.
MapConfig mapConfig() => mapConfigFrom(Platform.environment);
/// The map [environment] sets. `MAP_TILES_URL` (D50), the instance's own
/// Protomaps tiles as an HTTPS `{z}/{x}/{y}` template, drawn by Train's
/// style, served beside them at `/map/style/light.json` and `dark.json`;
/// else `MAP_STYLE_URL`, a provider's MapLibre style over HTTPS; else
/// [openFreeMap]. Both with `MAP_SOURCE_NAME` (else the host),
/// `MAP_ATTRIBUTION` (else OpenStreetMap's credit, and Protomaps' for the
/// instance's tiles) and `MAP_ABOUT_URL` (HTTPS, else none).
MapConfig mapConfigFrom(Map<String, String> environment) {
String? set(String name) => switch (environment[name]?.trim()) {
final value? when value.isNotEmpty => value,
_ => null,
};
return MapSource(
name: set('MAP_SOURCE_NAME') ?? style.host,
styleUrl: style.toString(),
attribution: set('MAP_ATTRIBUTION') ?? '© OpenStreetMap contributors',
aboutUrl: _https(environment['MAP_ABOUT_URL'])?.toString(),
final about = _https(environment['MAP_ABOUT_URL'])?.toString();
if (_template(environment['MAP_TILES_URL']) case (final tiles, final at)) {
String style(String name) => Uri(
scheme: 'https',
host: at.host,
port: at.port,
path: '/map/style/$name.json',
).toString();
return (
source: MapSource(
name: set('MAP_SOURCE_NAME') ?? at.host,
styleUrl: style('light'),
darkStyleUrl: style('dark'),
attribution:
set('MAP_ATTRIBUTION') ??
'© OpenStreetMap contributors · Protomaps',
aboutUrl: about,
),
tiles: tiles,
);
}
final style = _https(environment['MAP_STYLE_URL']);
if (style == null) {
return (source: openFreeMap, tiles: null);
}
return (
source: MapSource(
name: set('MAP_SOURCE_NAME') ?? style.host,
styleUrl: style.toString(),
attribution: set('MAP_ATTRIBUTION') ?? '© OpenStreetMap contributors',
aboutUrl: about,
),
tiles: null,
);
}
@ -42,3 +73,18 @@ Uri? _https(String? value) {
? uri
: null;
}
/// [value] as a tiles template, with the address before its first `{`;
/// null unless it is HTTPS and has `{z}`, `{x}` and `{y}`.
(String, Uri)? _template(String? value) {
final template = value?.trim() ?? '';
if (!['{z}', '{x}', '{y}'].every(template.contains)) {
return null;
}
final at = _https(template.substring(0, template.indexOf('{')));
return at == null ? null : (template, at);
}
/// The source [environment] sets ([mapConfigFrom]'s).
MapSource mapSourceFrom(Map<String, String> environment) =>
mapConfigFrom(environment).source;

View file

@ -0,0 +1,522 @@
/// The languages the style names places in where the tiles have them; any
/// other leaves the tiles' own names (each place's local one).
const styleLanguages = {'en', 'de'};
/// Train's map style (ADR 0030, D50): a MapLibre style for Protomaps'
/// basemap (schema v4) at [tiles], an `{z}/{x}/{y}` template, light or
/// [dark], for running and riding: land, woods, parks, water, roads by class
/// with paths, tracks and cycleways set apart, rail, buildings from zoom 14,
/// boundaries, and the names of places, roads and water in [language] where
/// the tiles have it ([styleLanguages]). No icons, no glyphs: the app draws
/// text with its own fonts. Only expressions vector_tile_renderer reads.
Map<String, Object?> trainStyle({
required String tiles,
required bool dark,
String? language,
}) {
final colours = dark ? _dark : _light;
final name = styleLanguages.contains(language)
? [
'coalesce',
['get', 'name:$language'],
['get', 'name'],
]
: ['get', 'name'];
Map<String, Object?> layer(
String id,
String type,
String sourceLayer, {
Object? filter,
int? minzoom,
Map<String, Object?>? layout,
required Map<String, Object?> paint,
}) => {
'id': id,
'type': type,
'source': 'protomaps',
'source-layer': sourceLayer,
'filter': ?filter,
'minzoom': ?minzoom,
'layout': ?layout,
'paint': paint,
};
Object kindIn(List<String> kinds) => [
'match',
['get', 'kind'],
kinds,
true,
false,
];
Object detailIn(List<String> details) => [
'match',
['get', 'kind_detail'],
details,
true,
false,
];
Object width(num at12, num at18) => [
'interpolate',
['exponential', 1.6],
['zoom'],
12,
at12,
18,
at18,
];
Map<String, Object?> label(String colour) => {
'text-color': colour,
'text-halo-color': colours.halo,
'text-halo-width': 1.5,
};
final roadLayout = {'line-cap': 'round', 'line-join': 'round'};
const majorRoads = ['highway', 'major_road'];
const paths = ['footway', 'path', 'pedestrian', 'steps', 'bridleway'];
return {
'version': 8,
'name': dark ? 'Halkyon Train, dark' : 'Halkyon Train',
'sources': {
'protomaps': {
'type': 'vector',
'tiles': [tiles],
'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': colours.water},
},
layer('earth', 'fill', 'earth', paint: {'fill-color': colours.land}),
layer(
'landcover',
'fill',
'landcover',
paint: {
'fill-color': [
'match',
['get', 'kind'],
['forest', 'scrub'],
colours.wood,
['grassland', 'farmland'],
colours.grass,
'glacier',
colours.ice,
'barren',
colours.sand,
colours.urban,
],
},
),
layer(
'landuse-wood',
'fill',
'landuse',
filter: kindIn(['forest', 'wood', 'orchard', 'scrub']),
paint: {'fill-color': colours.wood},
),
layer(
'landuse-park',
'fill',
'landuse',
filter: kindIn([
'park',
'garden',
'grass',
'meadow',
'recreation_ground',
'playground',
'dog_park',
'golf_course',
'nature_reserve',
'national_park',
'protected_area',
'cemetery',
'camp_site',
]),
paint: {'fill-color': colours.park},
),
layer(
'landuse-pitch',
'fill',
'landuse',
filter: kindIn(['pitch', 'stadium']),
paint: {'fill-color': colours.pitch},
),
layer(
'landuse-sand',
'fill',
'landuse',
filter: kindIn(['beach', 'sand', 'bare_rock']),
paint: {'fill-color': colours.sand},
),
layer(
'landuse-wetland',
'fill',
'landuse',
filter: kindIn(['wetland']),
paint: {'fill-color': colours.wetland},
),
layer(
'landuse-built',
'fill',
'landuse',
filter: kindIn([
'industrial',
'commercial',
'railway',
'military',
'aerodrome',
'hospital',
'school',
'college',
'university',
'kindergarten',
]),
paint: {'fill-color': colours.built},
),
layer(
'landuse-runway',
'fill',
'landuse',
filter: kindIn(['runway', 'taxiway']),
paint: {'fill-color': colours.runway},
),
layer(
'water',
'fill',
'water',
filter: [
'==',
['geometry-type'],
'Polygon',
],
paint: {'fill-color': colours.water},
),
layer(
'water-lines',
'line',
'water',
filter: [
'==',
['geometry-type'],
'LineString',
],
minzoom: 9,
layout: roadLayout,
paint: {
'line-color': colours.water,
'line-width': [
'match',
['get', 'kind_detail'],
['river', 'canal'],
width(1.5, 12),
width(0.5, 3),
],
},
),
layer(
'buildings',
'fill',
'buildings',
filter: kindIn(['building', 'building_part']),
minzoom: 14,
paint: {'fill-color': colours.building},
),
layer(
'boundaries',
'line',
'boundaries',
filter: kindIn(['country', 'region']),
paint: {
'line-color': colours.boundary,
'line-width': [
'match',
['get', 'kind'],
'country',
1.5,
0.8,
],
'line-dasharray': [3, 2],
},
),
layer(
'rail',
'line',
'roads',
filter: kindIn(['rail']),
minzoom: 10,
paint: {'line-color': colours.rail, 'line-width': width(0.8, 3)},
),
layer(
'roads-major-casing',
'line',
'roads',
filter: kindIn(majorRoads),
minzoom: 7,
layout: roadLayout,
paint: {'line-color': colours.casing, 'line-width': width(3, 22)},
),
layer(
'roads-minor-casing',
'line',
'roads',
filter: kindIn(['minor_road']),
minzoom: 13,
layout: roadLayout,
paint: {'line-color': colours.casing, 'line-width': width(1.5, 14)},
),
layer(
'roads-minor',
'line',
'roads',
filter: kindIn(['minor_road']),
minzoom: 11,
layout: roadLayout,
paint: {'line-color': colours.road, 'line-width': width(0.8, 11)},
),
layer(
'roads-major',
'line',
'roads',
filter: kindIn(majorRoads),
minzoom: 5,
layout: roadLayout,
paint: {
'line-color': [
'match',
['get', 'kind'],
'highway',
colours.highway,
colours.major,
],
'line-width': width(2, 18),
},
),
// The ways Train is for, each set apart: tracks brown, footpaths
// dotted, cycleways in a colour of their own.
layer(
'tracks',
'line',
'roads',
filter: [
'all',
kindIn(['path', 'minor_road']),
detailIn(['track']),
],
minzoom: 12,
layout: roadLayout,
paint: {
'line-color': colours.track,
'line-width': width(1, 4),
'line-dasharray': [4, 1.5],
},
),
layer(
'footpaths',
'line',
'roads',
filter: [
'all',
kindIn(['path']),
detailIn(paths),
],
minzoom: 13,
paint: {
'line-color': colours.path,
'line-width': width(1, 3.5),
'line-dasharray': [1.5, 1.5],
},
),
layer(
'cycleways',
'line',
'roads',
filter: [
'all',
kindIn(['path']),
detailIn(['cycleway']),
],
minzoom: 12,
layout: roadLayout,
paint: {'line-color': colours.cycleway, 'line-width': width(1, 4)},
),
layer(
'water-names',
'symbol',
'water',
filter: ['has', 'name'],
minzoom: 12,
layout: {
'symbol-placement': 'line',
'text-field': name,
'text-size': 12,
},
paint: label(colours.waterText),
),
layer(
'road-names',
'symbol',
'roads',
filter: [
'all',
['has', 'name'],
kindIn(['highway', 'major_road', 'minor_road', 'path']),
],
minzoom: 14,
layout: {
'symbol-placement': 'line',
'text-field': name,
'text-size': 12,
},
paint: label(colours.text),
),
layer(
'places-local',
'symbol',
'places',
filter: kindIn(['neighbourhood', 'macrohood']),
minzoom: 13,
layout: {
'text-field': name,
'text-size': 12,
'text-transform': 'uppercase',
'text-letter-spacing': 0.08,
},
paint: label(colours.minorText),
),
layer(
'places-locality',
'symbol',
'places',
filter: kindIn(['locality']),
layout: {
'text-field': name,
'text-size': [
'interpolate',
['linear'],
['get', 'population_rank'],
1,
11,
15,
18,
],
},
paint: label(colours.text),
),
layer(
'places-region',
'symbol',
'places',
filter: kindIn(['region']),
layout: {
'text-field': name,
'text-size': 12,
'text-transform': 'uppercase',
},
paint: label(colours.minorText),
),
layer(
'places-country',
'symbol',
'places',
filter: kindIn(['country']),
layout: {'text-field': name, 'text-size': 15},
paint: label(colours.text),
),
],
};
}
/// A theme's colours: quiet, so that a track in cobalt stands out.
final class const _Colours({
required final String land,
required final String water,
required final String waterText,
required final String wood,
required final String grass,
required final String park,
required final String pitch,
required final String sand,
required final String wetland,
required final String ice,
required final String urban,
required final String built,
required final String runway,
required final String building,
required final String boundary,
required final String rail,
required final String casing,
required final String road,
required final String major,
required final String highway,
required final String track,
required final String path,
required final String cycleway,
required final String text,
required final String minorText,
required final String halo,
});
const _light = _Colours(
land: '#f4f1ea',
water: '#b9d3e3',
waterText: '#3f6a86',
wood: '#cfe0c3',
grass: '#e6ecd6',
park: '#d8e8cc',
pitch: '#cde3c0',
sand: '#eee4c8',
wetland: '#d6e4dc',
ice: '#fbfcfd',
urban: '#ece8e0',
built: '#e7e2dd',
runway: '#dcd8d3',
building: '#dfd9d1',
boundary: '#9b8fa8',
rail: '#a7a4a0',
casing: '#d2ccc2',
road: '#ffffff',
major: '#fdf3dc',
highway: '#f6dcb1',
track: '#9a6b3c',
path: '#b0563a',
cycleway: '#2f7f7a',
text: '#3a3631',
minorText: '#6e675e',
halo: '#f9f7f2',
);
const _dark = _Colours(
land: '#1d2024',
water: '#1b3042',
waterText: '#8fb2cb',
wood: '#22302a',
grass: '#222a24',
park: '#233127',
pitch: '#253427',
sand: '#2e2c25',
wetland: '#20302d',
ice: '#2b3036',
urban: '#212428',
built: '#25272b',
runway: '#2c2e32',
building: '#2a2c30',
boundary: '#7d7290',
rail: '#55575b',
casing: '#15171a',
road: '#3a3d42',
major: '#4b4639',
highway: '#6a573a',
track: '#b88a5a',
path: '#d07a5c',
cycleway: '#5fb5ae',
text: '#e2ded8',
minorText: '#a29b92',
halo: '#1d2024',
);