forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpoiIcons.ts
More file actions
799 lines (739 loc) · 31.3 KB
/
Copy pathpoiIcons.ts
File metadata and controls
799 lines (739 loc) · 31.3 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
// The pins themselves: one generated image per POI category, per confidence.
//
// Three decisions here are load-bearing rather than cosmetic.
//
// 1. SHAPE is the primary channel, colour is the second one. This is the same
// rule the blaze line widths follow (style.ts), and for the same reason:
// these six accent colours sit between 1.06:1 and 2.19:1 of each other, so
// in the greyscale pass (WIREFRAMES.md `9d`) or in direct sun they are one
// colour. A droplet is still a droplet. Every category therefore gets a
// silhouette that survives being reduced to a black shape, and no category
// is distinguished from another by hue alone.
//
// 2. The images are COMPUTED, not shipped as assets. An offline-first app
// should not spend a network round trip or a build step on a handful of
// 30px badges, and a pure function is testable in jsdom - which can
// neither rasterise an SVG nor run a canvas. So the glyphs are polygons
// and this module contains a small scanline rasteriser, which is the
// price of both properties.
//
// 3. RED IS NOT AVAILABLE to a POI. Red is spoken for by closures
// (lib/closureStyle.ts) and by the serious-warning pin, and a spring that
// reads at a glance as "do not walk down there" is a worse failure than an
// ugly palette. The test suite holds this.
//
// Confidence rides on the rim, not on the colour or the glyph: a solid rim is
// a POI somebody has verified exists, a broken one is a POI nobody has
// (WIREFRAMES.md §11 - "a dashed pin means never verified to exist"). That is
// deliberately a different channel from staleness, which is about when a human
// last looked at a POI that is known to be real.
import { POI_TYPES, type PoiType } from '../lib/config'
import { SITE_ANCHOR_TYPES, SITE_MEMBER_TYPES } from './poiSites'
/**
* Rendered size in CSS pixels.
*
* `--space-9` / the header-button size, which is a token this design system
* already has rather than a number invented for the map. Comfortably inside
* WIREFRAMES.md's serious-warning pin, which should stay the biggest thing on
* the map, and which moved up to one full touch target (44px) when this did -
* a warning pin that a water pin has caught up with has stopped outranking
* anything.
*
* The cost of drawing pins bigger is that fewer of them survive
* `icon-allow-overlap: false` at a given zoom. That is a trade the collision
* ordering was built to absorb: POI_PRIORITY in poiLayers.ts decides who
* survives, and water is first in it.
*/
export const POI_PIN_SIZE = 38
/** Drawn at 2x so the pins stay crisp on a phone. */
export const POI_PIN_PIXEL_RATIO = 2
/**
* One accent per category, each at least 4.5:1 against the glyph on top of it
* (FEATURES.md's waypoint icon spec asks for WCAG AA, and poiIcons.test.ts
* computes the ratios rather than trusting this comment).
*
* Values are the design system's own tokens: blaze-blue, pine-700, forest-500,
* blaze-orange-dark and the Purple blaze. Nothing here is a fresh hex invented
* for the map.
*/
export const POI_COLORS: Record<PoiType, string> = {
water: '#1c6ea4',
shelter: '#284029',
campsite: '#47784b',
resupply: '#994e15',
crossing: '#6a4a8f',
// The three added with ATC's vista/parking/privy layers, and the first
// accents here that are not lifted verbatim from tokens/colors.css. Not for
// want of looking: a pin's disc has to clear 4.5:1 against the near-white
// halo drawn on it, which rules out every remaining light token
// (blaze-yellow is 2.5:1, moss-400 3.5:1), and the dark ones that do clear
// it are either already spoken for (pine-700 is shelter, stone-700 is the
// fallback pin) or a second orange within a degree of resupply's hue -
// which is exactly the "one colour in glare" failure this palette is built
// to avoid.
//
// So these fill the three gaps left on the wheel - teal, indigo, plum -
// each measured against the same bars the tokens were: AA on the halo, a
// hue of its own, and well clear of the closure red. poiIcons.test.ts
// computes all three rather than taking this comment's word for it.
viewpoint: '#12615c',
parking: '#3f4d8a',
privy: '#7a2f66',
}
/**
* For a POI type this build has never heard of.
*
* A later import adding a category should put a neutral pin on the map rather
* than nothing at all - the same call lib/waypointLanes.ts makes when it drops
* an unrecognised type into the ELSE lane. Silently not drawing it would hide
* real data behind a client release.
*/
export const POI_FALLBACK_COLOR = '#5a5346'
/** `--paper-0`. The halo and the glyph. It sat lighter than the map's old
* cream paper; on the field sheet's white (`MAP_BACKGROUND_COLOR`) the edge
* hairline below is what keeps a pin reading as sitting on top. */
export const PIN_HALO_COLOR = '#fffdf7'
/** `--stone-900`, a hairline outside the halo. Without it a pale halo on pale
* paper has no edge at all where the topo happens to be blank. */
export const PIN_EDGE_COLOR = '#2b2620'
/** The name the fallback pin is registered under. Not a `PoiType` - that is
* the point of it. */
export const UNKNOWN_POI_TYPE = 'unknown'
export type PoiConfidence = 'high' | 'low'
/** Stable image id, and the string the style's `match` expression resolves to. */
export function poiIconId(
type: string,
confidence: PoiConfidence,
members: readonly string[] = [],
): string {
const base = `poi-${type}-${confidence === 'high' ? 'verified' : 'unverified'}`
// A site pin's id carries what it is carrying, so the style resolves straight
// from the feature's `site_members` property to an image without a lookup
// table in between (#524). Empty members give exactly the old id, so every
// plain pin keeps the name it already had - nothing re-registers.
return members.length === 0 ? base : `${base}-${members.join('+')}`
}
/**
* Geometry, in image pixels from the centre outwards.
*
* Every one of these is a FRACTION of the pin rather than a fixed pixel count,
* which is what makes the size a single knob. Written as constants they held
* their look at exactly one size: drawn bigger, the rim thinned out and the
* glyph shrank inside a disc that grew around it, so a pin asked to be larger
* came back not just larger but differently proportioned.
*
* A function of the size rather than a set of module constants,
* because there is now a second pin at a second size - the serious-warning pin
* at 44px (map/warningPin.ts). Sharing this is the whole reason that pin is a
* variant of the waypoint spec rather than a visual language of its own.
*
* Exported for the third caller, which wants no pixels at all: map/MapIcon.tsx
* draws these same pins as SVG for the legend, and asks for `pinGeometry(1)` to
* get every proportion as a fraction of a unit viewBox. That is the whole
* reason the numbers below are ratios - a legend pin whose rim thickness was
* typed out a second time in a stylesheet would drift from the map's the first
* time either moved.
*/
export function pinGeometry(pixels: number) {
const center = pixels / 2
const rOuter = center
const edgeWidth = rOuter / 15
const haloWidth = rOuter / 6
const rDisc = rOuter - edgeWidth - haloWidth
// A MEMBER BADGE (#524, and #611 which moved it here from a footer band).
//
// 21 CSS px at the standard pin, which puts its glyph at 10.7 CSS px - half
// again the 14 px this shipped as, and above even the 9.6 px the old footer
// strip managed at its most generous. The first size was picked to read as
// "extra, not equal"; on a real screen it read as too quiet, and a member a
// hiker has to squint at is the failure this whole feature exists to fix.
const badgeRadius = rOuter * 0.555
// DERIVED, not chosen, and that is the point: a badge sits exactly far enough
// out to clear the disc, whatever size it is. Crossing the disc would put a
// badge on top of the anchor's own silhouette - the thing moving off the band
// was for - so the rule holds the invariant rather than a constant that would
// have to be re-tuned by hand every time the badge changed. The daylight is a
// third of the pin's own hairline: enough that the two never merge, little
// enough that a badge still reads as attached rather than floating.
const badgeRing = rDisc + badgeRadius + edgeWidth / 3
// Thinner rings than the pin's own 1/6 and 1/15. At this size the pin's
// proportions would spend a fifth of the badge on a halo whose only job here
// is separating it from the disc beneath it - the dark hairline outside is
// what does that work, and the extra room goes to the glyph.
const badgeDisc = badgeRadius * 0.8
return {
// No `pixels` here any more, and its absence is the point: a site pin's
// IMAGE is bigger than its pin, so a field on this object named for the size
// that was passed in would be read as the image's the first time somebody
// needed one. {@link sitePinPadding} is where the difference lives.
center,
rOuter,
edgeWidth,
rDisc,
/**
* Side of the centred box the glyph is drawn in.
*
* Its half-diagonal must stay inside `rDisc` or the corners of a glyph
* would spill onto the halo - so it is derived from that bound rather than
* checked against it. The largest box that fits has side `rDisc * √2`; 86%
* of it leaves the corners some air.
*/
glyphBox: rDisc * Math.SQRT2 * 0.86,
/**
* A member badge, for a SITE pin (#524, #611).
*
* The same pin at badge scale rather than a second visual language: an
* accent disc, the category's own silhouette on it in halo white, a white
* ring and the dark hairline outside that. The ring is what keeps a badge
* legible where it crosses the parent's halo.
*
* NOT SIZED FOR THE CURRENT DISTRIBUTION, and that is deliberate - a badge
* is the same size whatever a pin carries, so the case worth holding room
* for is three. Of the 295 sites the 2026-08-13 publish produced, 169 carry
* one member category (57%), 123 carry two (42%) and three carry three (1%),
* and that 1% is measuring a DATA GAP rather than the trail: 153 of those
* sites are privy-only and only 11 water POIs are members of anything, while
* #529 measured that 97% of shelters have no mapped water source within
* 250 m and observed that nearly every A.T. shelter has water in reality.
* Close that gap and privy+water becomes ordinary and privy+campsite+water
* common.
*
* WHERE THIS RUNS OUT. Three badges already fan from twelve o'clock to
* three. A fourth member category would either tighten the pitch below what
* the badges can take or wrap past three o'clock into the lower right, which
* is a different look and wants the same real screen this one got rather
* than a fraction adjusted in advance.
*/
badge: {
radius: badgeRadius,
/** How far a badge's centre sits from the pin's own. */
ring: badgeRing,
/** The dark hairline, and the disc inside the white ring inside it. */
edgeWidth: badgeRadius * 0.06,
rDisc: badgeDisc,
/** Slightly fuller than the pin's 0.86, which it can afford: these are
* single silhouettes on a small disc, and the corners still clear it. */
glyphBox: badgeDisc * Math.SQRT2 * 0.9,
/**
* Angle between two badges, so neighbours clear each other by a
* fourteenth of a badge rather than merely touching.
*
* Derived rather than typed out, because it is a consequence of the two
* sizes above: change either and the fan re-spaces itself instead of
* quietly overlapping.
*/
pitch: 2 * Math.asin((badgeRadius * 1.07) / badgeRing),
},
}
}
export type PinGeometry = ReturnType<typeof pinGeometry>
/**
* Where each member badge sits, as an offset from the pin's centre.
*
* Centred on the 45-degree axis with the first member at the top running
* clockwise, so a pin carrying one member has it square in the corner and a pin
* carrying three fans them from twelve o'clock to three. The order is
* SITE_MEMBER_TYPES', which is fixed - so a hiker who learns where the privy
* badge sits on one pin finds it in the same place on the next.
*/
export function badgeCenters(
count: number,
badge: PinGeometry['badge'],
): readonly { x: number; y: number }[] {
const start = -Math.PI / 4 - (badge.pitch * (count - 1)) / 2
return Array.from({ length: count }, (_, index) => {
const angle = start + badge.pitch * index
return { x: Math.cos(angle) * badge.ring, y: Math.sin(angle) * badge.ring }
})
}
/**
* How far past its own edge a site pin has to be padded, in CSS pixels.
*
* Badges hang outside the rim, so the image has to grow to hold them - and it
* grows SYMMETRICALLY, which is the whole reason map/poiLayers.ts needs no
* `icon-offset`: the disc stays at the centre of the image, so it stays on the
* hiker's coordinate at every zoom.
*
* Per member count rather than one padding for every site pin, because the
* padding is what MapLibre's collision box is made of. A pin carrying one member
* needs 4 px and a pin carrying three needs 10; giving the first the second's
* box would evict neighbours for room it is not using, on 57% of sites.
*
* Whole pixels, so the image is an integer number of pixels wide at any integer
* pixel ratio.
*/
export function sitePinPadding(memberCount: number, sizePx = POI_PIN_SIZE): number {
if (memberCount <= 0) return 0
const { rOuter, badge } = pinGeometry(sizePx)
let reach = 0
for (const { x, y } of badgeCenters(memberCount, badge)) {
reach = Math.max(reach, Math.abs(x) + badge.radius, Math.abs(y) + badge.radius)
}
return Math.max(0, Math.ceil(reach - rOuter))
}
/** Dash count around the rim of an unverified pin. Even, so the pattern closes
* cleanly where the last gap meets the first dash.
*
* Exported for the same reason {@link pinGeometry} is: map/MapIcon.tsx spends
* it on an SVG `stroke-dasharray`, and a legend pin dashed to a different
* rhythm from the map's would be teaching the wrong rhythm. */
export const RIM_DASHES = 8
/** Sub-samples per axis. 3x3 is enough to take the stair-stepping off a 60px
* circle without making icon generation something to think about. */
const SUPERSAMPLE = 3
export type Point = readonly [number, number]
/** Rings in a normalised glyph box, filled even-odd so a ring inside another
* ring - the tent's doorway - cuts a hole instead of filling it. */
export type Glyph = readonly (readonly Point[])[]
function arc(
cx: number,
cy: number,
r: number,
fromDeg: number,
toDeg: number,
steps = 14,
): Point[] {
const points: Point[] = []
for (let i = 0; i <= steps; i += 1) {
const rad = ((fromDeg + ((toDeg - fromDeg) * i) / steps) * Math.PI) / 180
points.push([cx + r * Math.cos(rad), cy + r * Math.sin(rad)])
}
return points
}
/** A zigzag band: a wave that still reads as a wave in silhouette. */
function chevron(top: number, amplitude: number, thickness: number): Point[] {
const xs = [0.04, 0.27, 0.5, 0.73, 0.96]
const ys = xs.map((_, i) => top + (i % 2 === 0 ? amplitude : 0))
return [
...xs.map((x, i): Point => [x, ys[i]]),
...xs.map((x, i): Point => [x, ys[i] + thickness]).reverse(),
]
}
/**
* The silhouettes, in a 0-1 box with y running down the screen.
*
* Bold and geometric on purpose. At 30px in sunlight a faithful line-art icon
* is a smudge, so each of these is a filled shape that survives being small,
* being desaturated, and being looked at for a quarter of a second.
*/
const GLYPHS: Record<string, Glyph> = {
// Droplet: apex over a round bowl.
water: [[[0.5, 0.02], ...arc(0.5, 0.63, 0.33, -50, 230)]],
// House: gable roof over a square body, with a doorway cut out of it.
//
// The doorway is not decoration. Without it the house is a solid blob that
// completely CONTAINS the droplet - every pixel of water's glyph sits inside
// shelter's - and a shape that is a strict subset of another can read as a
// half-rendered version of it. The cutout breaks the containment, and is why
// poiIcons.test.ts asserts against subsets rather than only against overlap.
shelter: [
[
[0.5, 0.04],
[0.97, 0.45],
[0.84, 0.45],
[0.84, 0.96],
[0.16, 0.96],
[0.16, 0.45],
[0.03, 0.45],
],
[
[0.39, 0.62],
[0.61, 0.62],
[0.61, 0.96],
[0.39, 0.96],
],
],
// Tent: a triangle with a doorway cut out, so it is never just a triangle.
// The doorway is small on purpose - drawn any bigger it eats the walls and
// what is left reads as a bare chevron rather than a tent.
campsite: [
[
[0.5, 0.05],
[0.95, 0.93],
[0.05, 0.93],
],
[
[0.5, 0.62],
[0.59, 0.93],
[0.41, 0.93],
],
],
// Carried bag: body plus a handle standing clear above it.
resupply: [
[
[0.14, 0.38],
[0.86, 0.38],
[0.93, 0.97],
[0.07, 0.97],
],
[...arc(0.5, 0.38, 0.23, 180, 360), ...arc(0.5, 0.38, 0.15, 360, 180)],
],
// Running water, as two bands - a stream to be crossed, not a stream to drink
// from, which is what the droplet says.
crossing: [chevron(0.1, 0.14, 0.15), chevron(0.52, 0.14, 0.15)],
// Two peaks with a valley between them, and a sun clear of the left one.
//
// The peaks alone are the obvious drawing and were not enough: a solid
// range sits almost entirely inside the resupply bag's body, which the
// subset check caught at 6% outside it. The sun is what breaks the
// containment - it is the one part of this glyph in a corner nothing else
// reaches - and it happens to be the difference between a mountain and a
// view of one, which is what this category actually means.
viewpoint: [
[
[0.02, 0.93],
[0.31, 0.3],
[0.5, 0.62],
[0.7, 0.15],
[0.98, 0.93],
],
arc(0.19, 0.17, 0.13, 0, 360),
],
// The letter P, the one waypoint here that is a letter rather than a
// picture - and it earns the exception, because it is the sign a driver
// has been reading at every car park for sixty years. A drawn car would be
// less legible at 38px and less recognised at any size.
parking: [
[
[0.22, 0.04],
[0.55, 0.04],
...arc(0.55, 0.3, 0.26, -90, 90),
[0.42, 0.56],
[0.42, 0.96],
[0.22, 0.96],
],
// The counter, cut out even-odd exactly as the shelter's doorway is -
// without it the P is a lollipop.
arc(0.55, 0.3, 0.11, 0, 360),
],
// An outhouse: a wide roof over a narrow box, with the crescent cut into
// the door. The crescent is the whole reason this is not read as a small
// shelter at a glance, which at 38px in sun is a real confusion and an
// embarrassing one - it is also the mark actually carved into privy doors.
//
// Drawn as A-minus-B rather than as two rings, because two overlapping
// circles under an even-odd fill would leave a second, unwanted hole where
// B sits outside A. The arc endpoints are the two circles' real
// intersection points, so the ring closes on itself exactly.
privy: [
[
[0.14, 0.16],
[0.86, 0.16],
[0.86, 0.3],
[0.72, 0.3],
[0.72, 0.96],
[0.28, 0.96],
[0.28, 0.3],
[0.14, 0.3],
],
[...arc(0.485, 0.56, 0.12, 51.6, 308.4), ...arc(0.545, 0.56, 0.093, 278.8, 81.2)],
],
// Diamond: deliberately not any of the above, and obviously a placeholder.
[UNKNOWN_POI_TYPE]: [
[
[0.5, 0.13],
[0.95, 0.5],
[0.5, 0.87],
[0.05, 0.5],
],
],
}
/**
* The category silhouette as SVG path data in a unit box (`viewBox="0 0 1 1"`),
* for chrome that wants the same shape language as the pins - the waypoint
* card's photo placeholder is the customer. One subpath per ring, so an
* `evenodd` fill keeps the shelter's doorway open exactly as the rasteriser's
* crossing count below does.
*
* Same fallback as {@link buildPoiIcon}: a type this build has never heard of
* gets the diamond, not an empty path - the placeholder should show SOMETHING
* for a POI the map is already drawing as a neutral pin.
*/
export function poiGlyphPath(type: string): string {
return glyphPath(GLYPHS[type] ?? GLYPHS[UNKNOWN_POI_TYPE])
}
/**
* Any glyph as SVG path data, for the ones that are not keyed by POI type -
* the hazard triangle (map/warningPin.ts) is the customer, and it is spelled
* out there rather than in {@link GLYPHS} because a serious warning is not a
* waypoint.
*/
export function glyphPath(glyph: Glyph): string {
return glyph
.map(
(ring) =>
`M${ring
// The arcs carry full float precision, which nobody rendering a
// 56px glyph can see and every DOM snapshot has to carry.
.map(([x, y]) => `${Number(x.toFixed(4))} ${Number(y.toFixed(4))}`)
.join('L')}Z`,
)
.join('')
}
/**
* The disc colour for a POI type, falling back for one this build has never
* heard of - the same pairing {@link buildPoiIcon} draws with, so a pin and
* anything drawn to match it cannot disagree about the accent.
*/
export function poiColor(type: string): string {
return type in POI_COLORS ? POI_COLORS[type as PoiType] : POI_FALLBACK_COLOR
}
/** Even-odd crossing count, which is what gives the tent its doorway. */
function insideGlyph(glyph: Glyph, x: number, y: number): boolean {
let inside = false
for (const ring of glyph) {
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i]
const [xj, yj] = ring[j]
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
inside = !inside
}
}
}
return inside
}
/** True where the rim has ink: everywhere on a verified pin, on the dashes
* only when nobody has verified the POI exists. */
function rimHasInk(dx: number, dy: number, confidence: PoiConfidence): boolean {
if (confidence === 'high') return true
const turns = (Math.atan2(dy, dx) / (Math.PI * 2) + 1) % 1
return Math.floor(turns * RIM_DASHES * 2) % 2 === 0
}
function parseHex(hex: string): readonly [number, number, number] {
const value = Number.parseInt(hex.slice(1), 16)
return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]
}
export interface PoiIconImage {
width: number
height: number
data: Uint8ClampedArray
}
export interface PinSpec {
/** Rendered size in CSS pixels - {@link POI_PIN_SIZE} for a waypoint. */
sizePx: number
/** Drawn at this multiple of `sizePx`, and declared to MapLibre alongside
* the image so it lands at the right size on a phone. */
pixelRatio: number
/** The silhouette, in a 0-1 box with y running down the screen. */
glyph: Glyph
/** Fill behind the glyph. */
color: string
/** Solid rim, or the broken one that means "nobody has verified this". */
confidence: PoiConfidence
/**
* The categories riding this pin, in the order they are drawn (#524).
*
* Empty or omitted draws the plain pin, unchanged - which is what every pin
* that is not a site anchor gets, and what a phone that downloaded before #523
* gets for everything.
*/
members?: readonly string[]
}
/**
* One pin, as raw RGBA pixels.
*
* Sub-samples each pixel and averages in PREMULTIPLIED alpha. Averaging the
* raw channels instead would fringe the whole outer edge with a ring of
* half-transparent dark pixels, because the transparent samples outside the
* circle carry a colour of their own into the mean.
*
* Exported so the serious-warning pin (map/warningPin.ts) is drawn by THIS
* rasteriser at a different size and colour, rather than by a second one that
* would drift from it. Every proportion it uses comes from
* {@link pinGeometry}, so the two are the same pin at two sizes.
*/
export function buildPinImage({
sizePx,
pixelRatio,
glyph,
color,
confidence,
members = [],
}: PinSpec): PoiIconImage {
const geometry = pinGeometry(sizePx * pixelRatio)
const disc = parseHex(color)
const halo = parseHex(PIN_HALO_COLOR)
const edge = parseHex(PIN_EDGE_COLOR)
const { rOuter, rDisc, edgeWidth, glyphBox, badge } = geometry
// The image is the pin plus whatever the badges hang past it, and the pin sits
// in the middle of it. A pin carrying nothing pads by nothing and is therefore
// the exact image it always was, byte for byte.
const pad = sitePinPadding(members.length, sizePx) * pixelRatio
const pixels = sizePx * pixelRatio + pad * 2
const center = pixels / 2
// Each badge, with its own accent and silhouette. The colour pair is the SAME
// one the contrast assertion in poiIcons.test.ts already proves for every type
// - a type's colour against PIN_HALO_COLOR - so a badge clears WCAG AA by
// numbers that were already measured rather than by new ones.
const badges = badgeCenters(members.length, badge).map((spot, index) => ({
...spot,
glyph: GLYPHS[members[index]] ?? GLYPHS[UNKNOWN_POI_TYPE],
ink: parseHex(poiColor(members[index])),
}))
/** The badge covering this offset from the centre, if any covers it. */
function badgeInkAt(dx: number, dy: number): readonly [number, number, number] | null {
for (const spot of badges) {
const bx = dx - spot.x
const by = dy - spot.y
const distance = Math.hypot(bx, by)
if (distance > badge.radius) continue
if (distance > badge.radius - badge.edgeWidth) return edge
if (distance > badge.rDisc) return halo
const gx = (bx + badge.glyphBox / 2) / badge.glyphBox
const gy = (by + badge.glyphBox / 2) / badge.glyphBox
return insideGlyph(spot.glyph, gx, gy) ? halo : spot.ink
}
return null
}
const data = new Uint8ClampedArray(pixels * pixels * 4)
const step = 1 / SUPERSAMPLE
const samples = SUPERSAMPLE * SUPERSAMPLE
// How far a pixel's furthest SAMPLE can sit from its centre. Samples are on a
// sub-grid inset by half a step, so this is the half-diagonal of that grid -
// and it is what makes the two skips below exact rather than approximate: a
// pixel further than this from every shape cannot have a sample in one.
const reach = Math.SQRT2 * (0.5 - step / 2)
for (let py = 0; py < pixels; py += 1) {
for (let px = 0; px < pixels; px += 1) {
// Padding a site pin's image out to hold its badges leaves a lot of empty
// corner - 53% of a three-member image is neither pin nor badge - and
// sampling it nine times a pixel to find nothing was most of what made
// this slow enough to time out a test that builds every icon (#611).
const cx = px + 0.5 - center
const cy = py + 0.5 - center
let nearBadge = false
for (const spot of badges) {
if (Math.hypot(cx - spot.x, cy - spot.y) <= badge.radius + reach) {
nearBadge = true
break
}
}
if (!nearBadge && Math.hypot(cx, cy) > rOuter + reach) continue
let r = 0
let g = 0
let b = 0
let hits = 0
for (let sy = 0; sy < SUPERSAMPLE; sy += 1) {
for (let sx = 0; sx < SUPERSAMPLE; sx += 1) {
const x = px + (sx + 0.5) * step
const y = py + (sy + 0.5) * step
const dx = x - center
const dy = y - center
const distance = Math.hypot(dx, dy)
// Badges are drawn OVER the pin, so they are asked first. They never
// reach the disc - see `badge.ring` - so what one can cover is the halo
// ring, the rim and the paper outside it, never the anchor's own glyph.
let ink = nearBadge ? badgeInkAt(dx, dy) : null
if (ink === null) {
if (distance <= rDisc) {
const gx = (dx + glyphBox / 2) / glyphBox
const gy = (dy + glyphBox / 2) / glyphBox
ink = insideGlyph(glyph, gx, gy) ? halo : disc
} else if (distance <= rOuter && rimHasInk(dx, dy, confidence)) {
ink = distance <= rOuter - edgeWidth ? halo : edge
}
}
if (ink !== null) {
r += ink[0]
g += ink[1]
b += ink[2]
hits += 1
}
}
}
const at = (py * pixels + px) * 4
// Divided by `hits`, not by `samples`: the colour is the mean of the
// samples that HAD colour, and coverage is carried by alpha alone.
if (hits > 0) {
data[at] = r / hits
data[at + 1] = g / hits
data[at + 2] = b / hits
data[at + 3] = (hits / samples) * 255
}
}
}
return { width: pixels, height: pixels, data }
}
/** One waypoint pin, at the one size and palette every waypoint uses. */
export function buildPoiIcon(
type: string,
confidence: PoiConfidence,
members: readonly string[] = [],
): PoiIconImage {
return buildPinImage({
sizePx: POI_PIN_SIZE,
pixelRatio: POI_PIN_PIXEL_RATIO,
glyph: GLYPHS[type] ?? GLYPHS[UNKNOWN_POI_TYPE],
color: poiColor(type),
confidence,
members,
})
}
/**
* Every member combination a site pin can carry, as the style will ask for it.
*
* The non-empty subsets of SITE_MEMBER_TYPES in that array's own order, which is
* seven - and the reason the glyph strip is buildable at all where a `+N` badge
* is not. Distinct categories are bounded at three, so the whole matrix can be
* pre-registered; N is unbounded, and a site with five campsites would want a
* "+5" image nobody built.
*/
export function siteMemberCombinations(): readonly string[][] {
const combinations: string[][] = []
for (let mask = 1; mask < 2 ** SITE_MEMBER_TYPES.length; mask += 1) {
combinations.push(SITE_MEMBER_TYPES.filter((_, index) => (mask >> index) & 1))
}
return combinations
}
export interface RegisteredPoiIcon {
id: string
image: PoiIconImage
pixelRatio: number
}
/**
* Built once, then handed out.
*
* Every trip to the More tab and back builds a new map, and every new map calls
* {@link buildPoiIcons} - which is a few hundred milliseconds of scanline
* rasterising on the main thread, paid again for an answer that cannot have
* changed. The inputs are module constants, so the second call and the fiftieth
* have the same output as the first, byte for byte.
*
* Safe to share rather than copy: `map.addImage` reads the pixels into its own
* atlas texture and nothing in this app writes to them afterwards.
*/
let cachedPoiIcons: RegisteredPoiIcon[] | undefined
/**
* Every pin the style can ask for: each published POI type plus the unknown
* fallback, each in both confidences.
*
* Built from {@link POI_TYPES} rather than from a list kept here, so adding a
* POI type to config.ts cannot leave the map with a `match` arm pointing at an
* image that was never registered.
*/
export function buildPoiIcons(): RegisteredPoiIcon[] {
if (cachedPoiIcons !== undefined) return cachedPoiIcons
const types: string[] = [...POI_TYPES, UNKNOWN_POI_TYPE]
const confidences: PoiConfidence[] = ['high', 'low']
const plain = types.flatMap((type) =>
confidences.map((confidence) => ({
id: poiIconId(type, confidence),
image: buildPoiIcon(type, confidence),
pixelRatio: POI_PIN_PIXEL_RATIO,
})),
)
// Site variants for the ANCHOR types only (#524). A viewpoint never anchors a
// site, so building it a footer strip would be 14 images the style can never
// ask for - and the matrix is small enough to be worth keeping honest.
const sited = SITE_ANCHOR_TYPES.flatMap((type) =>
confidences.flatMap((confidence) =>
siteMemberCombinations().map((members) => ({
id: poiIconId(type, confidence, members),
image: buildPoiIcon(type, confidence, members),
pixelRatio: POI_PIN_PIXEL_RATIO,
})),
),
)
cachedPoiIcons = [...plain, ...sited]
return cachedPoiIcons
}