-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmapStyle.ts
More file actions
194 lines (175 loc) · 7.36 KB
/
Copy pathmapStyle.ts
File metadata and controls
194 lines (175 loc) · 7.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
/**
* How the map looks.
*
* Its own module because the look is a decision, not a detail: the map is the backdrop for the
* photographs, and everything here serves that. Three things happen:
*
* 1. A colour flavour of our own, in the tones of the rest of the kiosk.
* 2. Layers a museum has no use for are left out.
* 3. Roads are drawn a little thinner than a navigation map would draw them.
*
* See docs/decisions.md, point 12.
*/
import { type Flavor, layers, namedFlavor } from "@protomaps/basemaps";
import type maplibregl from "maplibre-gl";
import type { Region } from "../region";
import { language, t } from "../text";
/**
* Fonts and icons live locally under /basemaps/.
*
* This is where an offline map otherwise breaks silently: tiles and style would come from the
* PMTiles file, but labels and icons would still be fetched from protomaps.github.io. Without a
* network what remains is a map without a single word on it.
*/
const GLYPHS = "/basemaps/fonts/{fontstack}/{range}.pbf";
// MapLibre demands an absolute sprite URL -- it rejects relative paths. The origin comes from the
// browser rather than from configuration, so the same build works on localhost, in the museum's
// wifi and behind the Pi's nginx.
//
// Read inside `buildStyle` and not at module level: a module that touches `window` while it is
// being imported cannot be imported anywhere else, and the language of the labels below is worth
// a test.
const sprite = () => `${window.location.origin}/basemaps/sprites/v4/light`;
/**
* "Papier" -- the map in the colours of the room around it.
*
* The ready-made flavours are built for navigation: turquoise water, saturated green, cool grey.
* Beside a panel in paper white (--paper) and sepia brown (--accent) they look like a different
* program. These tones come from the same family, so the scans sit on something that looks like
* the paper they were printed on.
*
* The rule while picking them: nothing on the map may be as saturated as a photograph. Whatever
* colour is on screen should be a photograph's.
*/
const PAPER: Partial<Flavor> = {
//: Warmer than --paper (#faf8f4), so the panel still reads as the lighter surface.
earth: "#f3ede2",
//: Green desaturated to sage. Real green would compete with the pictures.
park_a: "#e6e6d5",
park_b: "#cdd4bb",
wood_a: "#e6e6d5",
wood_b: "#cdd4bb",
scrub_a: "#e6e6d5",
scrub_b: "#cdd4bb",
//: Matt grey-blue instead of turquoise -- the Elbe marshes are not the Caribbean.
water: "#c6d2d4",
//: Areas that mean nothing to a visitor melt into the ground.
pedestrian: "#f3ede2",
hospital: "#f3ede2",
school: "#f3ede2",
industrial: "#f3ede2",
sand: "#f3ede2",
beach: "#f3ede2",
//: Buildings a touch darker than the ground, so a village core is readable as one.
buildings: "#e5dbc9",
other: "#f7f2e8",
minor_service: "#f7f2e8",
minor_a: "#f7f2e8",
minor_b: "#fffdf8",
link: "#fffdf8",
major: "#fffdf8",
highway: "#fffdf8",
bridges_other: "#f7f2e8",
bridges_minor: "#fffdf8",
bridges_link: "#fffdf8",
bridges_major: "#fffdf8",
bridges_highway: "#fffdf8",
//: Sand rather than grey, otherwise every road carries a cool outline.
minor_service_casing: "#e0d5c0",
minor_casing: "#e0d5c0",
link_casing: "#e0d5c0",
major_casing_early: "#e0d5c0",
major_casing_late: "#e0d5c0",
highway_casing_early: "#e0d5c0",
highway_casing_late: "#e0d5c0",
bridges_other_casing: "#e0d5c0",
bridges_minor_casing: "#e0d5c0",
bridges_link_casing: "#e0d5c0",
bridges_major_casing: "#e0d5c0",
bridges_highway_casing: "#e0d5c0",
railway: "#e0d5c0",
boundaries: "#e0d5c0",
//: --muted, the same colour the panel uses for secondary text.
roads_label_minor: "#6f6862",
roads_label_major: "#6f6862",
subplace_label: "#6f6862",
city_label: "#6f6862",
address_label: "#6f6862",
//: Halo in the ground colour, not white -- a white outline would glare on beige.
roads_label_minor_halo: "#f3ede2",
roads_label_major_halo: "#f3ede2",
subplace_label_halo: "#f3ede2",
city_label_halo: "#f3ede2",
address_label_halo: "#f3ede2",
};
/**
* Layers a museum kiosk has no use for.
*
* Deliberately short. Street names stay -- including the small ones: the panel says "tap the spot
* on the map, or search for the street name", and in a village most streets are minor ones. What
* goes is what nobody came here for: shops and their icons, house numbers printed on the map, and
* motorway shields.
*/
const OMITTED = new Set(["pois", "address_label", "roads_shields"]);
/** Roads are the backdrop here, not the subject. */
const ROAD_WIDTH = 0.8;
/**
* Take a line width down a notch.
*
* The obvious way does not work. Wrapping the whole thing as ``["*", width, 0.8]`` is rejected by
* MapLibre with *«"zoom" expression may only be used as input to a top-level "step" or
* "interpolate" expression»* -- the zoom interpolation has to stay the outermost thing there is.
*
* So the *stop values* are scaled and the shape of the curve is left alone. That also keeps this
* from becoming a hand-maintained copy of somebody else's cartography: when the flavour changes
* its widths, ours follow.
*/
function scaleWidth(width: unknown): unknown {
const thinner = (value: number) => Math.round(value * ROAD_WIDTH * 100) / 100;
if (typeof width === "number") return thinner(width);
if (!Array.isArray(width)) return width;
// ["interpolate", interpolation, input, stop, value, stop, value, …] -- values sit on the even
// positions from 4 on. ["step", input, value, stop, value, …] -- from 2 on.
const firstValue = width[0] === "interpolate" ? 4 : width[0] === "step" ? 2 : -1;
if (firstValue < 0) return width;
return width.map((part, index) =>
index >= firstValue && index % 2 === 0 && typeof part === "number" ? thinner(part) : part,
);
}
function calmRoads(layer: maplibregl.LayerSpecification): maplibregl.LayerSpecification {
if (layer.type !== "line" || !layer.id.startsWith("roads_")) return layer;
const width = layer.paint?.["line-width"];
if (width === undefined) return layer;
return {
...layer,
paint: { ...layer.paint, "line-width": scaleWidth(width) },
} as maplibregl.LayerSpecification;
}
export function buildStyle(region: Region): maplibregl.StyleSpecification {
const flavor = { ...namedFlavor("light"), ...PAPER } as Flavor;
return {
version: 8,
glyphs: GLYPHS,
sprite: sprite(),
sources: {
protomaps: {
type: "vector",
url: "pmtiles:///tiles/map.pmtiles",
// The licence belongs beside the name, not only the name: vector tiles are a derivative
// database in the sense of the ODbL, which asks that they be recognisable as one.
// See docs/licensing.md.
attribution: t.map.attribution,
},
},
// What the ground is called belongs to the place, so it comes from region.json; what the
// device says to its visitors is KIEKMAP_LANGUAGE. The two agree in Holm and are not the same
// question -- see docs/developer/decisions.md, point 79.
//
// Never left out: without `lang` the library returns 57 layers instead of 71, and not one of
// them carries a text field. A map without a single word on it, and nothing says why.
layers: layers("protomaps", flavor, { lang: region.labelLanguage ?? language })
.filter((layer) => !OMITTED.has(layer.id))
.map(calmRoads),
...{ maxzoom: region.maxZoom },
} as maplibregl.StyleSpecification;
}