forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathliveTopo.ts
More file actions
1659 lines (1596 loc) · 61.9 KB
/
Copy pathliveTopo.ts
File metadata and controls
1659 lines (1596 loc) · 61.9 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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// The live topographic background: OpenStreetMap vector tiles, drawn as a
// hiking sheet rather than a road map.
//
// WHY VECTOR, WHEN THE DOWNLOADED BACKGROUND IS RASTER
//
// The corridor archive is a picture of a map. US Topo quads are pre-rendered
// at 1:24,000, in per-quad UTM zones, with their labels baked into the pixels -
// so mosaicking them means reprojecting and resampling ink that was drawn for
// one scale, and reading them at any other zoom means looking at that ink
// stretched or crushed. Seams between quads of different vintages, type that
// cannot reflow, contours that cannot be recoloured, and nothing at all
// outside the 30-mile strip are not bugs in that pipeline; they are what a
// raster mosaic IS. features/MAP_OPTIONS.md's own framing - a live background
// is additive, most useful where there is signal - still holds, which is why
// this stacks over the archive rather than deleting it.
//
// Vector tiles carry the features instead of a picture of them, so the same
// bytes render sharp at every zoom, and every colour, weight and threshold
// below is ours to set. That is what makes "stylized for hiking" a real thing
// rather than a filter: what a hiker needs foregrounded (water, woodland,
// terrain shape, tracks and paths, named summits) is foregrounded, and what a
// road map would foreground is turned down or left out.
//
// THE SOURCE
//
// Local bytes first, the network where they do not reach (#189). The sheet's
// tiles come through the `basemap://` scheme below: map/basemap.ts answers
// each request from the downloaded basemap package when the phone holds one,
// and falls through - per tile, not per session - to OpenFreeMap's public
// instance where the package does not answer. Both serve the same unmodified
// OpenMapTiles schema (the package is built by our own Planetiler job,
// pipeline/BASEMAP.md), which is what lets every layer below stay one
// definition with no offline variant.
//
// OpenFreeMap is the network half for the same reasons it was the whole
// source: OpenStreetMap data, no API key, no registration, no request cap,
// explicitly free for commercial use with attribution. That last part is why
// it is here and raw tile.openstreetmap.org is not - the OSMF tile policy
// warns that access to that server may be withdrawn from exactly this kind
// of app, and MAP_OPTIONS.md already ruled it out on those grounds.
//
// Attribution is a licence condition on both counts - ODbL for the data,
// OpenFreeMap's own terms for the hosting - so LIVE_TOPO_ATTRIBUTION is
// carried into the style and is not behind a prop.
//
// If the public instance ever becomes a problem (it is donation-funded and
// carries no SLA), OPENFREEMAP_TILEJSON is the single line to repoint: the
// schema is standard OpenMapTiles, so a self-hosted extract on the R2 bucket
// the pipeline already publishes to serves the same layer names, and nothing
// below this constant changes.
import type {
LayerSpecification,
SourceSpecification,
} from '@maplibre/maplibre-gl-style-spec'
import type { Map as MapLibreMap } from 'maplibre-gl'
import { whenStyleReady } from './styleReady'
import { OPENFREEMAP_CREDIT, OSM_CREDIT } from './credits'
import type { ResolvedTheme } from '../lib/theme'
import type { MapStyle, Theme } from '../lib/userPreferences'
import {
CONTOUR_ELEVATION_KEY,
CONTOUR_LAYER,
CONTOUR_LEVEL_KEY,
CONTOUR_MAX_ZOOM,
CONTOUR_SOURCE_ID,
DEM_MAX_ZOOM,
DEM_SOURCE_ID,
ELEVATION_ATTRIBUTION,
type ContourUnits,
type TerrainUrls,
} from './terrain'
export const OPENFREEMAP_TILEJSON = 'https://tiles.openfreemap.org/planet'
/**
* The scheme the sheet's tile requests go through, and the template the osm
* source declares. Config only - the handler that answers it lives in
* basemap.ts (local package first, network fallthrough), the same
* config/runtime split terrain.ts and contours.ts draw, and for the same
* reason: building a style must cost arithmetic, not a protocol registration.
*/
export const BASEMAP_SCHEME = 'basemap'
export const BASEMAP_TILES_URL = `${BASEMAP_SCHEME}://{z}/{x}/{y}`
/**
* Declared on the source because a `tiles:` template carries no TileJSON to
* say it. 14 is the OpenMapTiles standard ceiling - true of OpenFreeMap's
* planet and of our own Planetiler build alike (pipeline/BASEMAP.md), so the
* local and network halves of the fallthrough agree on where overzooming
* starts and one constant serves both.
*/
export const BASEMAP_MAX_ZOOM = 14
/**
* Glyphs ship with the app, not from a font host (#188).
*
* Symbol layers fetch glyph PBFs per 256-codepoint range, and no offline
* plumbing intercepts those requests - the pmtiles protocol only handles tile
* sources. Served from a host, every label on the sheet - place names, peak
* elevations, contour labels - silently rendered nothing without signal. So
* the one fontstack the style uses is bundled under public/glyphs/ (6.24 MB,
* all 256 ranges of Noto Sans Regular, OFL-licensed - provenance and licence
* note sit next to the files) and the style points at its own origin. Being
* real build assets is also what gets the ranges into the service worker's
* precache, so a fresh install labels its map in airplane mode - the same
* reasoning mapWorker.ts documents for the emitted worker asset.
*
* BASE_URL rather than a bare `/`: GitHub Pages serves the app under
* /OurHike/app/, and a root-anchored path would resolve to the project site
* instead - see vite.config.ts's note on BASE.
*/
export const BUNDLED_GLYPHS = `${import.meta.env.BASE_URL}glyphs/{fontstack}/{range}.pbf`
export const OSM_SOURCE_ID = 'osm'
/**
* What the vector source declares: OpenFreeMap's terms for the hosting and
* ODbL for the data underneath it, both of which this one source brings.
*
* Composed from credits.ts's atoms rather than spelled out, because the corner
* shows those atoms one per line and a second spelling of either would be a
* credit the deduping could not see - which is how "© OpenStreetMap
* contributors" came to be printed twice in the first place.
*/
export const LIVE_TOPO_ATTRIBUTION = `${OPENFREEMAP_CREDIT} · ${OSM_CREDIT}`
/**
* One font, varied by size, colour and halo rather than by weight.
*
* A fontstack the glyph endpoint does not have is a label layer that silently
* renders nothing, and "Noto Sans Regular" is the one stack every OpenMapTiles
* glyph source ships - including the bundled one, which is why BUNDLED_GLYPHS
* carries exactly this stack and no other. Reaching for a second weight would
* double the app's 6.24 MB of glyph assets to buy a distinction that halo and
* size already make.
*/
const FONT = ['Noto Sans Regular']
/**
* The `field` day sheet - MAP_STYLE_SPEC.md's reviewed favorite (card 1b in
* the spec's mockups), and the palette every hiker on the defaults sees.
*
* A topographic sheet's palette, not a screen palette: white paper, ink that
* is brown rather than black, woodland as a flat overprint rather than a
* photograph of trees, and nothing competing with the blaze colours the trail
* lines are drawn in. That last constraint is the real one, and it got
* stricter in review: roads and tracks are NEUTRAL GRAY on purpose, because
* nothing on the ground may share a hue with a blaze colour (review finding,
* 2026-08-06) - the old sheet's tan roads sat too close to the Yellow and
* Orange blazes for a glance to separate.
*/
export const TOPO_PALETTE = {
/** Woodland overprint, the same green family as the app's sage tokens. */
wood: '#dcebd2',
scrub: '#e8f0dd',
wetland: '#cfe3d8',
rock: '#eae6da',
/** Protected land. The wash is the WHOLE treatment - no outline (#347) -
* so it is mixed green enough to still read over woodland, which is what
* most protected land along the corridor is. */
park: '#c2ddb1',
water: '#8fc0dc',
waterEdge: '#2e79a6',
waterway: '#2e79a6',
/** Contours in USGS brown. Index lines are the same hue, darker and wider. */
contour: '#8a6c42',
contourIndex: '#5f4527',
contourLabel: '#4a3620',
/** Roads and tracks: present, quiet, and hue-free - see above. */
roadMajor: '#dad6ca',
roadMajorEdge: '#8e897a',
roadMinor: '#ddd9cd',
track: '#7b776b',
path: '#55503f',
boundary: '#6f6753',
label: '#14130f',
labelHalo: '#ffffff',
waterLabel: '#1c5c86',
/** Relief shading. Here rather than inline at the layer for the same reason
* as everything else in this object: it is a colour, so it is a colour the
* appearance can change. */
hillshadeShadow: '#4a4234',
hillshadeHighlight: '#ffffff',
hillshadeAccent: '#6f6753',
} as const
/** The shape both palettes share, so a key added to one has to be added to the
* other rather than silently keeping the light value under the dark theme. */
export type TopoPalette = Record<keyof typeof TOPO_PALETTE, string>
/**
* The `night_hike` sheet - the dark style in its own right, and what `field`
* turns into when the theme resolves dark (MAP_STYLE_SPEC.md: "night_hike is
* the auto-dark for field").
*
* Not the light palette inverted. An inverted topo sheet puts white contours
* and pale roads over dark ground, which is the wrong way round twice over:
* contours and roads are the quiet layers here (see above - everything is
* chosen to sit BEHIND the trail), and inversion makes them the loudest thing
* on the screen while turning the blaze colours, which are not inverted
* because they mean something, into the quietest.
*
* So it is re-drawn to the same brief instead. Ground goes to ink; woodland
* stays a slightly-greener overprint of it, a few percent lighter rather than
* a dark green block; contours keep their USGS brown at a lightness that reads
* on ink without shouting; and the only things allowed to be genuinely bright
* are the labels, because a place name you cannot read is a place name that is
* not there.
*
* Kept dark on purpose, and darker than a desktop dark theme would be. The
* reason this is in MVP at all is a phone out on a trail after sunset
* (features/UX_CUSTOMIZATION.md), where the screen is the brightest object for
* a mile and a "dark" map that settles at mid-grey still costs the night
* vision it was meant to protect.
*/
export const TOPO_PALETTE_DARK: TopoPalette = {
wood: '#101b14',
scrub: '#0f1913',
wetland: '#0e1c19',
rock: '#141a13',
/* Lifted off the card's own #122016 to clear the over-woodland margin the
outline's removal made load-bearing (#347): the card drew this wash with
a dashed edge to lean on, and with the edge gone the tint has to carry
protected land by itself. Same hue, same night-vision brief - only far
enough from `wood` for the fact to survive on a phone panel. */
park: '#163218',
water: '#0e2430',
waterEdge: '#1f4456',
waterway: '#2c5a72',
contour: '#2c3a2e',
contourIndex: '#465844',
contourLabel: '#6b8465',
roadMajor: '#2a2f22',
roadMajorEdge: '#3a4030',
roadMinor: '#232819',
track: '#4a4f3a',
path: '#565b46',
boundary: '#444a3a',
/* Moss, not white - card 1c's whole trick: the brightest ink on this sheet
is the White blaze itself, which is the point of it. */
label: '#96b98c',
labelHalo: '#0c1410',
waterLabel: '#5f8ea6',
/* Relief inverts more honestly than ink does: a shadow on dark ground is
near-black, and a lit slope is a dim green-grey rather than paper. */
hillshadeShadow: '#040705',
hillshadeHighlight: '#1d2a1e',
hillshadeAccent: '#0f1a12',
}
/**
* night_hike's red-light sub-mode: the dark sheet re-inked in one hue.
*
* Rod cells are nearly blind to deep red, which is why headlamps carry a red
* mode - a red screen can be READ without spending the half hour of dark
* adaptation a white one costs. So this is TOPO_PALETTE_DARK's lightness
* ladder with every hue pulled to the same red-amber family: ground fills
* stay near-black, lines sit in dim rust, and only the labels are allowed
* brightness, exactly as on the dark sheet. Blue is the first casualty on
* purpose - water keeps its lightness step and loses its hue, because a blue
* that reads as blue is a wavelength the eye pays for.
*
* The reviewed values from the mockups' card 1c (Red light), which are dimmer
* throughout than a first derivation of them was - "astronomy-grade darkness"
* is the card's own bar, and every step of brightness spent here is spent
* against it.
*/
export const TOPO_PALETTE_RED: TopoPalette = {
wood: '#1c0906',
scrub: '#190805',
wetland: '#1a0a07',
rock: '#1e0b07',
/* The wash stays in the red family - a green would be a wavelength the eye
pays for - and clears the same over-woodland margin the other sheets
hold, because protected land is still information at night. Lifted off
the card's #200c08 for the reason the dark sheet's was: the outline it
was drawn beside is gone (#347), so the tint carries the fact alone. */
park: '#3a1409',
water: '#260e08',
waterEdge: '#45180d',
waterway: '#571f10',
contour: '#481a0e',
contourIndex: '#6b2a16',
contourLabel: '#963f20',
roadMajor: '#341107',
roadMajorEdge: '#45180c',
roadMinor: '#2a0e06',
track: '#5f2412',
path: '#6b2a15',
boundary: '#521f10',
label: '#c1611a',
labelHalo: '#140503',
waterLabel: '#a34c1c',
hillshadeShadow: '#000000',
hillshadeHighlight: '#2b0f07',
hillshadeAccent: '#1c0906',
}
/**
* `field` after an EXPLICIT dark - card 1b's Night: maximum-contrast dark,
* white type on near-black, water still saturated. Deliberately distinct from
* night_hike, which is dim on purpose: this one is for a bright screen in the
* dark - driving to a trailhead - not for eyes that are adapting. Which is
* why it is only reachable by CHOOSING dark (see sheetVariant): a phone that
* flips itself dark at sunset on the trail gets night_hike instead.
*/
export const TOPO_PALETTE_FIELD_NIGHT: TopoPalette = {
wood: '#17231a',
scrub: '#141d15',
wetland: '#12211d',
rock: '#1c1e17',
/* Every sheet below carries its park wash lifted off its card value, for
the reason the two above do: the cards drew this tint with a dashed
outline beside it, #347 removed the outline as a false trail line, and
the wash now has to carry protected land alone - far enough from the
sheet's own `wood` for the fact to survive over the woodland most
protected land here is. The test file pins that margin per palette. */
park: '#1b3d1c',
water: '#123349',
waterEdge: '#3d84ad',
waterway: '#3d84ad',
contour: '#5f5236',
contourIndex: '#8a7649',
contourLabel: '#b39b60',
roadMajor: '#383830',
roadMajorEdge: '#55534a',
roadMinor: '#2a2a24',
track: '#6f6d62',
path: '#7a745c',
boundary: '#5f5c4a',
label: '#ffffff',
labelHalo: '#0d0e0b',
waterLabel: '#7cbade',
hillshadeShadow: '#000000',
hillshadeHighlight: '#2e3128',
hillshadeAccent: '#141610',
}
/**
* `quiet_pine` - card 1a: modern muted outdoor. The brown ink cools to
* gray-green so the sheet reads as one calm surface and the blaze colours
* become the loudest thing on it. The closest sheet to the app chrome's own
* palette, and the mockups' own suggested default before review settled on
* field.
*/
export const TOPO_PALETTE_QUIET_PINE: TopoPalette = {
wood: '#dfe8d6',
scrub: '#e7ecdc',
wetland: '#d6e2da',
rock: '#e9e7dd',
park: '#cbe0be',
water: '#b7d4de',
waterEdge: '#84aec2',
waterway: '#6f9fb8',
contour: '#a3a08c',
contourIndex: '#7c7a64',
contourLabel: '#6d6b55',
roadMajor: '#dcd4bd',
roadMajorEdge: '#bcb193',
roadMinor: '#e3ddc9',
track: '#a99f83',
path: '#97907a',
boundary: '#9a9483',
label: '#3f4237',
labelHalo: '#f4f4ec',
waterLabel: '#41708a',
hillshadeShadow: '#5f6350',
hillshadeHighlight: '#ffffff',
hillshadeAccent: '#8b8f7c',
}
/** quiet_pine's dark companion - evening use at normal screen brightness;
* blazes keep their day hexes. For headlamp hours, night_hike goes further. */
export const TOPO_PALETTE_QUIET_PINE_NIGHT: TopoPalette = {
wood: '#1c2b21',
scrub: '#192720',
wetland: '#182a26',
rock: '#20261f',
park: '#1e3e22',
water: '#14303c',
waterEdge: '#2b5468',
waterway: '#3a6b84',
contour: '#46503f',
contourIndex: '#66705a',
contourLabel: '#8b9678',
roadMajor: '#38402f',
roadMajorEdge: '#4c5540',
roadMinor: '#2c3326',
track: '#5a5f48',
path: '#6a6f58',
boundary: '#55584a',
label: '#cfd8c2',
labelHalo: '#121a14',
waterLabel: '#7fb3cc',
hillshadeShadow: '#060b08',
hillshadeHighlight: '#2c3a2e',
hillshadeAccent: '#1a231c',
}
/**
* `parchment` - card 1d: the current sheet leaned harder into the USGS quad
* it quotes. Warmer paper, contours saturated toward true USGS brown, the
* classic green woodland overprint, water edges at engraving weight. The
* style for anything a hiker prints or reads like a document.
*/
export const TOPO_PALETTE_PARCHMENT: TopoPalette = {
wood: '#d9e4c0',
scrub: '#e5ebcf',
wetland: '#cfdfc9',
rock: '#e9e2cd',
park: '#c4dea0',
water: '#aed3e4',
waterEdge: '#5b9cbd',
waterway: '#4f92b4',
contour: '#b06e35',
contourIndex: '#7e4a1e',
contourLabel: '#6b3d16',
roadMajor: '#e2c99b',
roadMajorEdge: '#a67c48',
roadMinor: '#e6d8b4',
track: '#8f6f3f',
path: '#7d6a45',
boundary: '#8a6f4a',
label: '#3d3222',
labelHalo: '#f6efdd',
waterLabel: '#2f6b8a',
hillshadeShadow: '#6b5535',
hillshadeHighlight: '#fff8e6',
hillshadeAccent: '#93825f',
}
/** parchment's Lantern mode - the same engraved linework on umber, warm
* candle-dark. For reading in the tent, not navigating on the move;
* night_hike owns that job. */
export const TOPO_PALETTE_LANTERN: TopoPalette = {
wood: '#1f2010',
scrub: '#1c1d0e',
wetland: '#1a2013',
rock: '#241c0e',
park: '#2c3a14',
water: '#142834',
waterEdge: '#2b4c5e',
waterway: '#396379',
contour: '#6b4a24',
contourIndex: '#966c38',
contourLabel: '#c19453',
roadMajor: '#3d2f18',
roadMajorEdge: '#55432a',
roadMinor: '#2c2312',
track: '#6b5738',
path: '#77644a',
boundary: '#5f4f36',
label: '#e0d0a6',
labelHalo: '#191108',
waterLabel: '#7aa8bf',
hillshadeShadow: '#000000',
hillshadeHighlight: '#2e2410',
hillshadeAccent: '#4a3d24',
}
/**
* `ridgeline` - card 1e: terrain does the talking. Hillshade carried at 0.55
* through hiking zooms, contours promoted to gray ink, landcover flattened
* near-monochrome; colour is reserved for water, park edges and the blazes.
* For judging a climb before committing to it.
*/
export const TOPO_PALETTE_RIDGELINE: TopoPalette = {
wood: '#e2e4d8',
scrub: '#e9eadf',
wetland: '#dde4dd',
rock: '#e7e5da',
park: '#cee0ba',
water: '#a5c8d6',
waterEdge: '#6ba2bb',
waterway: '#5e97b2',
contour: '#97948a',
contourIndex: '#6e6b60',
contourLabel: '#5c594e',
roadMajor: '#dcd6c4',
roadMajorEdge: '#b5ac94',
roadMinor: '#e2dccb',
track: '#9d9480',
path: '#8c8574',
boundary: '#969082',
label: '#3a382f',
labelHalo: '#efeee8',
waterLabel: '#40708a',
hillshadeShadow: '#3f3d33',
hillshadeHighlight: '#ffffff',
hillshadeAccent: '#6e6b60',
}
/** ridgeline's Moonlit relief - the highlight lifts instead of the shadow
* deepening, so ridges glow and valleys sink; contours one step brighter
* than quiet_pine's night so terrain still reads. */
export const TOPO_PALETTE_RIDGELINE_NIGHT: TopoPalette = {
wood: '#1b201a',
scrub: '#191d17',
wetland: '#182019',
rock: '#1f211c',
park: '#1e381c',
water: '#122833',
waterEdge: '#295062',
waterway: '#356882',
contour: '#4c4e45',
contourIndex: '#6e7165',
contourLabel: '#909485',
roadMajor: '#30322a',
roadMajorEdge: '#43463a',
roadMinor: '#26281f',
track: '#565949',
path: '#626555',
boundary: '#4e5145',
label: '#d5d8c9',
labelHalo: '#141613',
waterLabel: '#79aac2',
hillshadeShadow: '#000000',
hillshadeHighlight: '#34372e',
hillshadeAccent: '#101210',
}
/**
* One sheet as drawn: its palette plus the handful of values that vary with
* it but do not live in the palette's 24 colour keys. Exactly what one mockup
* card mode carries, and the cards are the source of every row below.
*
* - `backdrop`/`casing` are style.ts's layers (the paper under everything,
* the hairline under every blaze) - carried here because each sheet inks
* them itself, and read through style.ts's mapBackdrop/trailCasingColor.
* - `hillshadeBase` is the relief weight at hiking zooms - ridgeline's whole
* idea is carrying it at 0.55 where the others sit at 0.30-0.35.
* - `contoursEarly` moves the contour fade-ins one zoom earlier (ridgeline:
* terrain first means terrain sooner).
* - `boldType` is field's sunlight brief: labels one size up, halos 1.8 -
* per the card, not sheet-wide.
* - `dark` is what the archive dimming and the chrome-facing predicates key
* off; `redLight` marks the one variant that overrides the blazes.
*/
export interface SheetVariant {
palette: TopoPalette
backdrop: string
casing: string
hillshadeBase: number
contoursEarly: boolean
boldType: boolean
dark: boolean
redLight: boolean
}
const QUIET_PINE_DAY: SheetVariant = {
palette: TOPO_PALETTE_QUIET_PINE,
backdrop: '#f4f4ec',
casing: '#2b2f26',
hillshadeBase: 0.35,
contoursEarly: false,
boldType: false,
dark: false,
redLight: false,
}
const QUIET_PINE_NIGHT: SheetVariant = {
palette: TOPO_PALETTE_QUIET_PINE_NIGHT,
backdrop: '#16201a',
casing: '#0a0f0b',
hillshadeBase: 0.35,
contoursEarly: false,
boldType: false,
dark: true,
redLight: false,
}
const FIELD_DAY: SheetVariant = {
palette: TOPO_PALETTE,
backdrop: '#ffffff',
casing: '#14130f',
hillshadeBase: 0.3,
contoursEarly: false,
boldType: true,
dark: false,
redLight: false,
}
const FIELD_NIGHT: SheetVariant = {
palette: TOPO_PALETTE_FIELD_NIGHT,
backdrop: '#0d0e0b',
casing: '#000000',
hillshadeBase: 0.3,
contoursEarly: false,
boldType: true,
dark: true,
redLight: false,
}
/** One variant for both of night_hike's slots: it has no day form - a
* night-vision sheet chosen in daylight is still the night-vision sheet. */
const NIGHT_HIKE: SheetVariant = {
palette: TOPO_PALETTE_DARK,
backdrop: '#0c1410',
casing: '#060907',
hillshadeBase: 0.3,
contoursEarly: false,
boldType: false,
dark: true,
redLight: false,
}
const NIGHT_HIKE_RED: SheetVariant = {
palette: TOPO_PALETTE_RED,
backdrop: '#140503',
casing: '#0a0301',
hillshadeBase: 0.3,
contoursEarly: false,
boldType: false,
dark: true,
redLight: true,
}
const PARCHMENT_DAY: SheetVariant = {
palette: TOPO_PALETTE_PARCHMENT,
backdrop: '#f6efdd',
casing: '#241d12',
hillshadeBase: 0.35,
contoursEarly: false,
boldType: false,
dark: false,
redLight: false,
}
const PARCHMENT_LANTERN: SheetVariant = {
palette: TOPO_PALETTE_LANTERN,
backdrop: '#191108',
casing: '#0e0a05',
hillshadeBase: 0.35,
contoursEarly: false,
boldType: false,
dark: true,
redLight: false,
}
const RIDGELINE_DAY: SheetVariant = {
palette: TOPO_PALETTE_RIDGELINE,
backdrop: '#efeee8',
casing: '#26251e',
hillshadeBase: 0.55,
contoursEarly: true,
boldType: false,
dark: false,
redLight: false,
}
const RIDGELINE_NIGHT: SheetVariant = {
palette: TOPO_PALETTE_RIDGELINE_NIGHT,
backdrop: '#171916',
casing: '#0b0d0a',
hillshadeBase: 0.55,
contoursEarly: true,
boldType: false,
dark: true,
redLight: false,
}
/** Every style's day and night sheet, exactly as the mockup cards spec them.
* Exported for the tests that sweep all of them; resolution goes through
* sheetVariant below, never through this table directly. */
export const SHEET_VARIANTS: Record<
MapStyle,
{ day: SheetVariant; night: SheetVariant }
> = {
quiet_pine: { day: QUIET_PINE_DAY, night: QUIET_PINE_NIGHT },
field: { day: FIELD_DAY, night: FIELD_NIGHT },
night_hike: { day: NIGHT_HIKE, night: NIGHT_HIKE },
parchment: { day: PARCHMENT_DAY, night: PARCHMENT_LANTERN },
ridgeline: { day: RIDGELINE_DAY, night: RIDGELINE_NIGHT },
}
/** The red variant, reachable only through night_hike + the toggle - see
* sheetVariant. Exported for the same test sweep as SHEET_VARIANTS. */
export const SHEET_VARIANT_RED: SheetVariant = NIGHT_HIKE_RED
/**
* Which sheet the map draws, per MAP_STYLE_SPEC.md's preferences. All
* optional, defaulting to the sheet a caller with no opinion has always been
* handed - the field day sheet.
*
* `theme` is the spec's `mapMode` under the name this codebase already had
* for it: day = light, night = dark, and auto resolves through
* lib/useTheme.ts before it gets here, exactly as it does for the chrome.
* `themeChoice` is the preference BEFORE that resolution, and it exists for
* exactly one distinction - see sheetVariant on field's two darks.
*/
export interface SheetAppearance {
theme?: ResolvedTheme
/** The stored preference ('light' | 'dark' | 'auto'), so night can tell
* "chosen" from "arrived with sunset". Defaults to 'auto', which keeps
* every caller that does not pass it on the spec's auto behaviour. */
themeChoice?: Theme
mapStyle?: MapStyle
/** Only meaningful with night_hike - see TOPO_PALETTE_RED. */
redLight?: boolean
}
/**
* The sheet's variant for an appearance. One function so nothing else has to
* know how the preferences compose:
*
* - night_hike is dark under either theme (a hiker readying night vision
* before dusk should not have to flip the whole app), and red light
* refines it only - never any day sheet.
* - Every other style follows the resolved theme to its own night form -
* with one deliberate exception. Field's AUTO-dark is night_hike (the
* spec's own line): a phone that flips itself dark at sunset is a phone on
* a trail at dusk, and handing it field's maximum-contrast white-on-black
* night sheet would light the woods up. Field/night is reachable by
* CHOOSING the dark theme, which is the "bright screen in the dark" case
* it was drawn for.
*/
export function sheetVariant({
theme = 'light',
themeChoice = 'auto',
mapStyle = 'field',
redLight = false,
}: SheetAppearance): SheetVariant {
if (mapStyle === 'night_hike' && redLight) return NIGHT_HIKE_RED
if (theme !== 'dark') return SHEET_VARIANTS[mapStyle].day
if (mapStyle === 'field' && themeChoice !== 'dark') return NIGHT_HIKE
return SHEET_VARIANTS[mapStyle].night
}
/** The palette alone, for the callers that only paint colours. */
export function sheetPalette(appearance: SheetAppearance): TopoPalette {
return sheetVariant(appearance).palette
}
export const LIVE_TOPO_LAYER_IDS = {
wood: 'topo-wood',
scrub: 'topo-scrub',
wetland: 'topo-wetland',
rock: 'topo-rock',
parkFill: 'topo-park-fill',
hillshade: 'topo-hillshade',
water: 'topo-water',
waterway: 'topo-waterway',
contour: 'topo-contour',
contourIndex: 'topo-contour-index',
contourLabel: 'topo-contour-label',
roadMinor: 'topo-road-minor',
roadMajorCasing: 'topo-road-major-casing',
roadMajor: 'topo-road-major',
track: 'topo-track',
path: 'topo-path',
boundary: 'topo-boundary',
peak: 'topo-peak',
waterLabel: 'topo-water-label',
place: 'topo-place',
} as const
/**
* Every paint property on the sheet whose value is a colour, and which colour
* it is - in one table.
*
* It is read twice, which is the whole reason it is a table rather than
* literals at each layer. liveTopoLayers() builds the style out of it, and
* attachSheetAppearance() replays it onto a LIVE map when the appearance changes.
* Written out in both places instead, the two would drift, and what drift
* looks like here is one layer that did not follow the theme - a road still
* drawn in paper-brown over an ink sheet, which reads as a rendering bug
* rather than as a missing line in a list.
*
* Colours only. Widths, dash patterns and opacities stay at their layers,
* because they do not change with the theme and hoisting them here would put
* half of each layer's paint in a different part of the file for no gain.
*/
export const SHEET_COLOURS: ReadonlyArray<
readonly [layer: string, property: string, colour: keyof TopoPalette]
> = [
[LIVE_TOPO_LAYER_IDS.wood, 'fill-color', 'wood'],
[LIVE_TOPO_LAYER_IDS.scrub, 'fill-color', 'scrub'],
[LIVE_TOPO_LAYER_IDS.wetland, 'fill-color', 'wetland'],
[LIVE_TOPO_LAYER_IDS.rock, 'fill-color', 'rock'],
[LIVE_TOPO_LAYER_IDS.parkFill, 'fill-color', 'park'],
[LIVE_TOPO_LAYER_IDS.hillshade, 'hillshade-shadow-color', 'hillshadeShadow'],
[LIVE_TOPO_LAYER_IDS.hillshade, 'hillshade-highlight-color', 'hillshadeHighlight'],
[LIVE_TOPO_LAYER_IDS.hillshade, 'hillshade-accent-color', 'hillshadeAccent'],
[LIVE_TOPO_LAYER_IDS.water, 'fill-color', 'water'],
[LIVE_TOPO_LAYER_IDS.water, 'fill-outline-color', 'waterEdge'],
[LIVE_TOPO_LAYER_IDS.waterway, 'line-color', 'waterway'],
[LIVE_TOPO_LAYER_IDS.contour, 'line-color', 'contour'],
[LIVE_TOPO_LAYER_IDS.contourIndex, 'line-color', 'contourIndex'],
[LIVE_TOPO_LAYER_IDS.contourLabel, 'text-color', 'contourLabel'],
[LIVE_TOPO_LAYER_IDS.contourLabel, 'text-halo-color', 'labelHalo'],
[LIVE_TOPO_LAYER_IDS.roadMinor, 'line-color', 'roadMinor'],
[LIVE_TOPO_LAYER_IDS.roadMajorCasing, 'line-color', 'roadMajorEdge'],
[LIVE_TOPO_LAYER_IDS.roadMajor, 'line-color', 'roadMajor'],
[LIVE_TOPO_LAYER_IDS.track, 'line-color', 'track'],
[LIVE_TOPO_LAYER_IDS.path, 'line-color', 'path'],
[LIVE_TOPO_LAYER_IDS.boundary, 'line-color', 'boundary'],
[LIVE_TOPO_LAYER_IDS.peak, 'text-color', 'label'],
[LIVE_TOPO_LAYER_IDS.peak, 'text-halo-color', 'labelHalo'],
[LIVE_TOPO_LAYER_IDS.waterLabel, 'text-color', 'waterLabel'],
[LIVE_TOPO_LAYER_IDS.waterLabel, 'text-halo-color', 'labelHalo'],
[LIVE_TOPO_LAYER_IDS.place, 'text-color', 'label'],
[LIVE_TOPO_LAYER_IDS.place, 'text-halo-color', 'labelHalo'],
]
/** One layer's colour paint properties, resolved against a palette. Spread
* into the layer's own `paint` alongside whatever is not a colour. */
function sheetColours(layer: string, palette: TopoPalette): Record<string, string> {
return Object.fromEntries(
SHEET_COLOURS.filter(([id]) => id === layer).map(([, property, colour]) => [
property,
palette[colour],
]),
)
}
/** Shorthand for the OpenMapTiles `class` attribute test, used a dozen times. */
function isClass(...values: string[]): unknown[] {
return values.length === 1
? ['==', ['get', 'class'], values[0]]
: ['in', ['get', 'class'], ['literal', values]]
}
/**
* The zoom each place class starts labelling at (#159).
*
* Distance decides what deserves ink, the way it does on a paper sheet. The
* corridor-wide view crosses the whole Boston-Washington seaboard, and with
* every class labelling at every zoom, that view was a wall of type the
* centerline had to be picked out from under. So each class waits for the
* zoom where its name starts meaning something to a hiker: a city anchors
* the map from any distance, a town matters once a section is being planned,
* a village once it is about to be walked past.
*
* Cities carry no threshold - orientation is their whole job, and the
* corridor view without them is a line through unnamed country.
*/
export const PLACE_TOWN_MIN_ZOOM = 8
export const PLACE_VILLAGE_MIN_ZOOM = 11
export const PLACE_FILTER = [
'step',
['zoom'],
isClass('city'),
PLACE_TOWN_MIN_ZOOM,
isClass('city', 'town'),
PLACE_VILLAGE_MIN_ZOOM,
isClass('city', 'town', 'village'),
]
/**
* Which label survives when two places collide: the bigger one.
*
* Lower sorts place first, and earlier placement wins the space - so without
* this, whether Boston or a suburb's town label survives their collision is
* decided by feature order inside the tile, which is nobody's decision. Same
* reasoning as poiLayers.ts's POI_PRIORITY, one layer over.
*/
export const PLACE_SORT_KEY_EXPRESSION = [
'match',
['get', 'class'],
'city',
0,
'town',
1,
2,
]
/**
* How hard the relief is shaded - a zoom ramp rather than one number, and the
* reason is what the rest of this sheet does NOT draw.
*
* The hillshade is the only layer here with something to say at every zoom.
* Everything else that describes terrain is keyed to hiking zooms: the
* contours fade in over 9-12 and are at flat zero below that, their labels
* start at 12, the peaks at 10, and OpenMapTiles carries no woodland to fill
* below roughly z7. The opening view is the whole trail - App.tsx frames
* CORRIDOR_BOUNDS, which on a phone lands near z4 - so on that view relief is
* the only thing between the hiker and blank paper.
*
* At 0.35, stretched across a thousand kilometres of DEM, it was not enough to
* be one: the first thing anyone saw on opening the app was an empty sheet
* with a scale bar on it. So the shading is carried at full strength exactly
* where it works alone, and hands back to its old weight as the contours
* arrive - over the same 9-to-12 window they fade in across, read off the same
* numbers rather than a second set that could drift from them.
*
* Past the handover nothing changes: at hiking zooms this is one flat weight,
* which is what keeps the shading from competing with the contours for the
* same job and from making the trail line harder to follow across a slope.
* `interpolate` holds its end values outside the stops, so both ends are flat
* rather than extrapolating into a black hillside.
*
* The hiking-zoom weight is the variant's own (SheetVariant.hillshadeBase):
* 0.30 on field and night_hike, whose darker contour ink does some of the
* work the shading was doing; 0.35 on quiet_pine and parchment, the weight
* the sheet launched at; 0.55 on ridgeline, whose whole idea is relief
* carried strong. The constant below is the default sheet's value, kept for
* the callers and tests that reason about the ramp without a variant in
* hand.
*
* It also costs nothing. The DEM tiles behind this layer are fetched at every
* zoom already; the ramp only decides how much of what they contain reaches
* the screen.
*/
export const HILLSHADE_EXAGGERATION = 0.3
export const HILLSHADE_RELIEF_ONLY_EXAGGERATION = 1
/** The first zoom at which any contour ink is drawn, and the zoom by which
* both contour layers are at full strength - see contourFadeZooms below,
* which these have to keep agreeing with. */
export const HILLSHADE_HANDOVER_START_ZOOM = 9
export const HILLSHADE_HANDOVER_END_ZOOM = 12
/** The relief ramp for one variant's hiking-zoom weight. One builder, used
* by the style build and the live repaint alike, so the two cannot drift. */
export function hillshadeExaggerationExpression(base: number): unknown[] {
return [
'interpolate',
['linear'],
['zoom'],
HILLSHADE_HANDOVER_START_ZOOM,
HILLSHADE_RELIEF_ONLY_EXAGGERATION,
HILLSHADE_HANDOVER_END_ZOOM,
base,
]
}
export const HILLSHADE_EXAGGERATION_EXPRESSION =
hillshadeExaggerationExpression(HILLSHADE_EXAGGERATION)
/**
* Where each contour layer fades in, per variant.
*
* The default window is the handover the hillshade comment above describes:
* index lines over 9-11, minor lines over 10-12. `contoursEarly` (ridgeline)
* moves both one zoom earlier - the card's "contour opacity ramps arrive one
* zoom earlier" - because a terrain-first sheet wants the land's shape before
* a general sheet needs it.
*/
export function contourFadeZooms(variant: SheetVariant): {
minor: [start: number, full: number]
index: [start: number, full: number]
} {
return variant.contoursEarly
? { minor: [9, 11], index: [8, 10] }
: { minor: [10, 12], index: [9, 11] }
}
/**
* The type treatment one variant carries - field's sunlight brief against the
* baseline everything else uses (MAP_STYLE_SPEC.md's "field extras": labels
* one size up, halos 1.8). One builder for the style build and the live
* repaint, like the palette table and for the same reason.
*/
export function sheetTypeSizes(variant: SheetVariant): {
contourLabelSize: number
peakSizeExpression: unknown[]
contourLabelHalo: number
peakHalo: number
waterLabelHalo: number
placeHalo: number
} {
return variant.boldType
? {
contourLabelSize: 11,
peakSizeExpression: ['interpolate', ['linear'], ['zoom'], 10, 12, 14, 14],
contourLabelHalo: 1.8,
peakHalo: 1.8,
waterLabelHalo: 1.8,
placeHalo: 1.8,
}
: {
contourLabelSize: 10,
peakSizeExpression: ['interpolate', ['linear'], ['zoom'], 10, 10, 14, 13],
contourLabelHalo: 1.4,
peakHalo: 1.6,
waterLabelHalo: 1.4,
placeHalo: 1.6,
}
}
export interface LiveTopoOptions {
/**
* DEM and contour tile URLs, or `undefined` when they could not be built.
*
* Optional because elevation is one INPUT to this sheet rather than the
* sheet itself: of the layers below, exactly one reads the DEM (the
* hillshade) and three read the contour tiles. The other sixteen are OSM
* vector - landcover, parks, water, the path and road network, summits and
* place names - and none of them needs an elevation model to draw.
*
* So a DEM that will not build costs relief and contour lines, and leaves
* the rest of the sheet alone. That is what terrain.ts promises ("every
* failure path here is a missing layer, never a broken map") and until this
* was optional the promise was not kept: style.ts folded "asked for the live
* sheet" and "got terrain URLs" into one boolean, so a missing DEM dropped
* every layer of the sheet and left bare paper.
*/
terrain?: TerrainUrls
units: ContourUnits