Skip to main content

osmic_style/
style.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{json, Value};
3
4/// MapLibre-compatible style definition.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct Style {
7    pub version: u8,
8    pub name: String,
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub glyphs: Option<String>,
11    pub sources: Value,
12    pub layers: Vec<Value>,
13}
14
15impl Style {
16    pub fn to_json(&self) -> String {
17        serde_json::to_string_pretty(self).unwrap_or_default()
18    }
19}
20
21/// Generate a default MapLibre GL JS style for osmic tiles.
22///
23/// `source_url` should be the path/URL to the PMTiles archive,
24/// e.g., "pmtiles://tiles.pmtiles" or "http://localhost:3000/{z}/{x}/{y}.mvt"
25pub fn default_style_json(source_url: &str) -> Style {
26    let is_pmtiles = source_url.starts_with("pmtiles://") || source_url.ends_with(".pmtiles");
27
28    let source = if is_pmtiles {
29        json!({
30            "osmic": {
31                "type": "vector",
32                "url": source_url
33            }
34        })
35    } else {
36        json!({
37            "osmic": {
38                "type": "vector",
39                "tiles": [source_url]
40            }
41        })
42    };
43
44    let layers = vec![
45        // Background
46        json!({
47            "id": "background",
48            "type": "background",
49            "paint": { "background-color": "#f8f4f0" }
50        }),
51        // Landuse
52        json!({
53            "id": "landuse-fill",
54            "type": "fill",
55            "source": "osmic",
56            "source-layer": "landuse",
57            "minzoom": 7,
58            "paint": {
59                "fill-color": [
60                    "match", ["get", "class"],
61                    "forest", "#add19e",
62                    "grass", "#cdebb0",
63                    "meadow", "#cdebb0",
64                    "farmland", "#d5e29e",
65                    "residential", "#e0d6d0",
66                    "commercial", "#f2dad9",
67                    "industrial", "#ebdbe8",
68                    "cemetery", "#aacbaf",
69                    "#d5cfc8"
70                ],
71                "fill-opacity": 0.8
72            }
73        }),
74        // Natural areas
75        json!({
76            "id": "natural-fill",
77            "type": "fill",
78            "source": "osmic",
79            "source-layer": "natural",
80            "minzoom": 6,
81            "paint": {
82                "fill-color": [
83                    "match", ["get", "class"],
84                    "wood", "#add19e",
85                    "scrub", "#c8d7ab",
86                    "grassland", "#cdebb0",
87                    "sand", "#f5e9c6",
88                    "beach", "#fff1ba",
89                    "glacier", "#ddecec",
90                    "#e8e0d8"
91                ],
92                "fill-opacity": 0.6
93            }
94        }),
95        // Leisure areas
96        json!({
97            "id": "leisure-fill",
98            "type": "fill",
99            "source": "osmic",
100            "source-layer": "leisure",
101            "minzoom": 8,
102            "paint": {
103                "fill-color": [
104                    "match", ["get", "class"],
105                    "park", "#c8facc",
106                    "garden", "#cdebb0",
107                    "golf_course", "#b5e3b5",
108                    "nature_reserve", "#cdebb0",
109                    "#c8facc"
110                ],
111                "fill-opacity": 0.6
112            }
113        }),
114        // Water fill
115        json!({
116            "id": "water-fill",
117            "type": "fill",
118            "source": "osmic",
119            "source-layer": "water",
120            "filter": ["in", "class", "lake", "pond", "reservoir", "basin"],
121            "paint": {
122                "fill-color": "#aad3df",
123                "fill-opacity": 0.8
124            }
125        }),
126        // Water lines
127        json!({
128            "id": "water-line",
129            "type": "line",
130            "source": "osmic",
131            "source-layer": "water",
132            "filter": ["in", "class", "river", "stream", "canal", "drain", "ditch"],
133            "paint": {
134                "line-color": "#aad3df",
135                "line-width": [
136                    "match", ["get", "class"],
137                    "river", 3,
138                    "canal", 2,
139                    1
140                ]
141            }
142        }),
143        // Building fill
144        json!({
145            "id": "building-fill",
146            "type": "fill",
147            "source": "osmic",
148            "source-layer": "building",
149            "minzoom": 13,
150            "paint": {
151                "fill-color": "#dfdbd7",
152                "fill-opacity": 0.8
153            }
154        }),
155        // Building outline
156        json!({
157            "id": "building-outline",
158            "type": "line",
159            "source": "osmic",
160            "source-layer": "building",
161            "minzoom": 14,
162            "paint": {
163                "line-color": "#c9c0b8",
164                "line-width": 0.5
165            }
166        }),
167        // Boundary
168        json!({
169            "id": "boundary",
170            "type": "line",
171            "source": "osmic",
172            "source-layer": "boundary",
173            "minzoom": 2,
174            "paint": {
175                "line-color": "#9e9cab",
176                "line-width": 1.5,
177                "line-dasharray": [4, 2]
178            }
179        }),
180        // Railway
181        json!({
182            "id": "railway",
183            "type": "line",
184            "source": "osmic",
185            "source-layer": "railway",
186            "minzoom": 8,
187            "paint": {
188                "line-color": "#bfbfbf",
189                "line-width": 1.0
190            }
191        }),
192        // Highway casing (wider, darker line underneath)
193        json!({
194            "id": "highway-casing",
195            "type": "line",
196            "source": "osmic",
197            "source-layer": "highway",
198            "minzoom": 7,
199            "layout": { "line-cap": "round", "line-join": "round" },
200            "paint": {
201                "line-color": "#c0b8b0",
202                "line-width": [
203                    "match", ["get", "class"],
204                    "motorway", 8,
205                    "trunk", 7,
206                    "primary", 6,
207                    "secondary", 5,
208                    "tertiary", 4,
209                    "residential", 3,
210                    "service", 2,
211                    1.5
212                ]
213            }
214        }),
215        // Highway fill
216        json!({
217            "id": "highway-fill",
218            "type": "line",
219            "source": "osmic",
220            "source-layer": "highway",
221            "minzoom": 4,
222            "layout": { "line-cap": "round", "line-join": "round" },
223            "paint": {
224                "line-color": [
225                    "match", ["get", "class"],
226                    "motorway", "#e892a2",
227                    "motorway_link", "#e892a2",
228                    "trunk", "#f9b29c",
229                    "trunk_link", "#f9b29c",
230                    "primary", "#fcd6a4",
231                    "primary_link", "#fcd6a4",
232                    "secondary", "#f7fabf",
233                    "secondary_link", "#f7fabf",
234                    "tertiary", "#ffffff",
235                    "tertiary_link", "#ffffff",
236                    "#ffffff"
237                ],
238                "line-width": [
239                    "match", ["get", "class"],
240                    "motorway", 6,
241                    "trunk", 5,
242                    "primary", 4,
243                    "secondary", 3,
244                    "tertiary", 2.5,
245                    "residential", 1.5,
246                    "service", 1,
247                    0.75
248                ]
249            }
250        }),
251        // Road labels (along the line)
252        json!({
253            "id": "highway-label-major",
254            "type": "symbol",
255            "source": "osmic",
256            "source-layer": "highway",
257            "minzoom": 10,
258            "filter": ["in", "class", "motorway", "trunk", "primary", "secondary"],
259            "layout": {
260                "text-field": ["get", "name"],
261                "text-font": ["Open Sans Regular"],
262                "text-size": [
263                    "match", ["get", "class"],
264                    "motorway", 13,
265                    "trunk", 12,
266                    "primary", 11,
267                    10
268                ],
269                "symbol-placement": "line",
270                "text-rotation-alignment": "map",
271                "text-max-angle": 30,
272                "text-padding": 20
273            },
274            "paint": {
275                "text-color": "#555",
276                "text-halo-color": "#fff",
277                "text-halo-width": 1.5
278            }
279        }),
280        json!({
281            "id": "highway-label-minor",
282            "type": "symbol",
283            "source": "osmic",
284            "source-layer": "highway",
285            "minzoom": 14,
286            "filter": ["in", "class", "tertiary", "residential", "unclassified", "service", "living_street"],
287            "layout": {
288                "text-field": ["get", "name"],
289                "text-font": ["Open Sans Regular"],
290                "text-size": 10,
291                "symbol-placement": "line",
292                "text-rotation-alignment": "map",
293                "text-max-angle": 30,
294                "text-padding": 10
295            },
296            "paint": {
297                "text-color": "#666",
298                "text-halo-color": "#fff",
299                "text-halo-width": 1.0
300            }
301        }),
302        // Water labels
303        json!({
304            "id": "water-label",
305            "type": "symbol",
306            "source": "osmic",
307            "source-layer": "water",
308            "minzoom": 10,
309            "filter": ["has", "name"],
310            "layout": {
311                "text-field": ["get", "name"],
312                "text-font": ["Open Sans Regular"],
313                "text-size": 12,
314                "symbol-placement": "line",
315                "text-rotation-alignment": "map",
316                "text-max-angle": 30,
317                "text-padding": 30
318            },
319            "paint": {
320                "text-color": "#6b9daf",
321                "text-halo-color": "#fff",
322                "text-halo-width": 1.0
323            }
324        }),
325        // Natural/leisure area labels
326        json!({
327            "id": "area-label",
328            "type": "symbol",
329            "source": "osmic",
330            "source-layer": "leisure",
331            "minzoom": 12,
332            "filter": ["has", "name"],
333            "layout": {
334                "text-field": ["get", "name"],
335                "text-font": ["Open Sans Regular"],
336                "text-size": 11,
337                "text-padding": 10
338            },
339            "paint": {
340                "text-color": "#3a7a3a",
341                "text-halo-color": "#fff",
342                "text-halo-width": 1.0
343            }
344        }),
345        // Amenity/POI labels
346        json!({
347            "id": "amenity-label",
348            "type": "symbol",
349            "source": "osmic",
350            "source-layer": "amenity",
351            "minzoom": 15,
352            "filter": ["has", "name"],
353            "layout": {
354                "text-field": ["get", "name"],
355                "text-font": ["Open Sans Regular"],
356                "text-size": 10,
357                "text-padding": 5,
358                "icon-allow-overlap": false,
359                "text-allow-overlap": false
360            },
361            "paint": {
362                "text-color": "#734a08",
363                "text-halo-color": "#fff",
364                "text-halo-width": 1.0
365            }
366        }),
367        // Shop labels
368        json!({
369            "id": "shop-label",
370            "type": "symbol",
371            "source": "osmic",
372            "source-layer": "shop",
373            "minzoom": 15,
374            "filter": ["has", "name"],
375            "layout": {
376                "text-field": ["get", "name"],
377                "text-font": ["Open Sans Regular"],
378                "text-size": 10,
379                "text-padding": 5,
380                "text-allow-overlap": false
381            },
382            "paint": {
383                "text-color": "#5b3a0a",
384                "text-halo-color": "#fff",
385                "text-halo-width": 1.0
386            }
387        }),
388        // Tourism labels
389        json!({
390            "id": "tourism-label",
391            "type": "symbol",
392            "source": "osmic",
393            "source-layer": "tourism",
394            "minzoom": 14,
395            "filter": ["has", "name"],
396            "layout": {
397                "text-field": ["get", "name"],
398                "text-font": ["Open Sans Regular"],
399                "text-size": 10,
400                "text-padding": 5,
401                "text-allow-overlap": false
402            },
403            "paint": {
404                "text-color": "#0d7377",
405                "text-halo-color": "#fff",
406                "text-halo-width": 1.0
407            }
408        }),
409        // Healthcare labels
410        json!({
411            "id": "healthcare-label",
412            "type": "symbol",
413            "source": "osmic",
414            "source-layer": "healthcare",
415            "minzoom": 15,
416            "filter": ["has", "name"],
417            "layout": {
418                "text-field": ["get", "name"],
419                "text-font": ["Open Sans Regular"],
420                "text-size": 10,
421                "text-padding": 5,
422                "text-allow-overlap": false
423            },
424            "paint": {
425                "text-color": "#c4281c",
426                "text-halo-color": "#fff",
427                "text-halo-width": 1.0
428            }
429        }),
430        // Office labels
431        json!({
432            "id": "office-label",
433            "type": "symbol",
434            "source": "osmic",
435            "source-layer": "office",
436            "minzoom": 15,
437            "filter": ["has", "name"],
438            "layout": {
439                "text-field": ["get", "name"],
440                "text-font": ["Open Sans Regular"],
441                "text-size": 10,
442                "text-padding": 5,
443                "text-allow-overlap": false
444            },
445            "paint": {
446                "text-color": "#555",
447                "text-halo-color": "#fff",
448                "text-halo-width": 1.0
449            }
450        }),
451        // Craft labels
452        json!({
453            "id": "craft-label",
454            "type": "symbol",
455            "source": "osmic",
456            "source-layer": "craft",
457            "minzoom": 15,
458            "filter": ["has", "name"],
459            "layout": {
460                "text-field": ["get", "name"],
461                "text-font": ["Open Sans Regular"],
462                "text-size": 10,
463                "text-padding": 5,
464                "text-allow-overlap": false
465            },
466            "paint": {
467                "text-color": "#b5651d",
468                "text-halo-color": "#fff",
469                "text-halo-width": 1.0
470            }
471        }),
472        // Historic labels
473        json!({
474            "id": "historic-label",
475            "type": "symbol",
476            "source": "osmic",
477            "source-layer": "historic",
478            "minzoom": 14,
479            "filter": ["has", "name"],
480            "layout": {
481                "text-field": ["get", "name"],
482                "text-font": ["Open Sans Regular"],
483                "text-size": 10,
484                "text-padding": 5,
485                "text-allow-overlap": false
486            },
487            "paint": {
488                "text-color": "#7b2d8b",
489                "text-halo-color": "#fff",
490                "text-halo-width": 1.0
491            }
492        }),
493        // Place labels
494        json!({
495            "id": "place-label",
496            "type": "symbol",
497            "source": "osmic",
498            "source-layer": "place",
499            "minzoom": 4,
500            "layout": {
501                "text-field": ["get", "name"],
502                "text-size": [
503                    "match", ["get", "class"],
504                    "city", 20,
505                    "town", 15,
506                    "village", 12,
507                    10
508                ],
509                "text-font": ["Open Sans Regular"],
510                "text-padding": 5
511            },
512            "paint": {
513                "text-color": "#333",
514                "text-halo-color": "#fff",
515                "text-halo-width": 2
516            }
517        }),
518    ];
519
520    Style {
521        version: 8,
522        name: "Osmic Default".into(),
523        glyphs: Some("https://fonts.openmaptiles.org/{fontstack}/{range}.pbf".into()),
524        sources: source,
525        layers,
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn default_style_pmtiles_source_uses_url_key() {
535        let style = default_style_json("pmtiles://tiles.pmtiles");
536        assert_eq!(style.version, 8);
537        assert_eq!(style.name, "Osmic Default");
538        let osmic_source = &style.sources["osmic"];
539        assert_eq!(osmic_source["type"], "vector");
540        assert_eq!(osmic_source["url"], "pmtiles://tiles.pmtiles");
541        assert!(osmic_source.get("tiles").is_none());
542    }
543
544    #[test]
545    fn default_style_http_source_uses_tiles_array() {
546        let url = "http://localhost:3000/{z}/{x}/{y}.mvt";
547        let style = default_style_json(url);
548        let osmic_source = &style.sources["osmic"];
549        assert_eq!(osmic_source["type"], "vector");
550        assert_eq!(osmic_source["tiles"][0], url);
551        assert!(osmic_source.get("url").is_none());
552    }
553
554    #[test]
555    fn default_style_has_background_and_nonempty_layers() {
556        let style = default_style_json("pmtiles://x.pmtiles");
557        assert!(!style.layers.is_empty());
558        assert_eq!(style.layers[0]["id"], "background");
559        assert_eq!(style.layers[0]["type"], "background");
560    }
561
562    #[test]
563    fn style_roundtrips_through_json() {
564        let style = default_style_json("pmtiles://x.pmtiles");
565        let json = style.to_json();
566        assert!(!json.is_empty());
567        let parsed: Style = serde_json::from_str(&json).expect("roundtrip");
568        assert_eq!(parsed.version, style.version);
569        assert_eq!(parsed.name, style.name);
570        assert_eq!(parsed.layers.len(), style.layers.len());
571    }
572}