forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPoiCard.tsx
More file actions
942 lines (882 loc) · 44.5 KB
/
Copy pathPoiCard.tsx
File metadata and controls
942 lines (882 loc) · 44.5 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
// What a tapped pin says about itself (WIREFRAMES.md Interactions), as a card
// floating beside the pin rather than a sheet docked at the bottom edge.
//
// The move is not cosmetic. A bottom sheet answers "what did I tap" with a
// name and asks the hiker to match it back to the map; a card anchored to the
// pin answers with position - it hangs off the exact point it describes, and
// tracks it through every pan and zoom the way MapLibre's own popups do. The
// anchoring maths lives in poiCardPlacement.ts, pure and tested; this file
// wires it to the live map.
//
// Only what the app actually holds, and no more. Every line here is a fact
// the download carried; there is no "last confirmed" line, because no
// published artifact carries a confirmation date yet - and a "Last confirmed:
// unknown" row would read as a data glitch rather than as the truth, which is
// that nobody has built the mechanism for a hiker to confirm anything
// (WIREFRAMES.md §11, features/DATA_NUDGES.md - both post-MVP).
//
// The photo slot is held to the same rule. The pipeline can now carry
// imagery (fetch_poi_images.py matches openly-licensed, recent Wikimedia
// Commons photos to POIs), but most waypoints will never have an eligible
// photo - so `photoUrl` stays optional and the empty slot shows the
// category's own silhouette on its accent: honest iconography, not a stock
// photo pretending to be the shelter. When a photo does ship, its credit
// line ships with it - naming the photographer and licence is the condition
// CC BY/BY-SA attach to using the photo at all, and dating it is this app's
// own honesty-about-uncertainty rule (OurHikeValues.md #4) applied to
// somebody else's camera.
//
// The one line that is not a bare fact is the unverified sentence, and it is
// the reason this card is worth having. The pin already says it with a broken
// rim (map/poiIcons.ts), which is a channel someone has to have learned to
// read. OurHikeValues.md #4 asks for uncertainty in words as well - "a smaller
// feature set hikers can trust beats a flashy one they have to second-guess" -
// so tapping the pin is where the words are.
//
// ONE CARD FOR A PLACE WITH PARTS. A shelter, its privy and its campsite are
// one site drawing one pin (#524, map/poiSites.ts), so since that landed the
// members have had no pin to tap and no gesture anywhere in the app reached
// them. The strip of chips under the name is that gesture (#526,
// features/POI_SITES.md §5): every part of the site, each carrying the icon the
// map draws for it, and tapping one swaps the card to that part's own detail -
// its photo and gallery, its description, its coordinates, its unverified line.
//
// The part you are on is a chip too, first in the row and marked as current.
// The issue's own sketch listed only the members, on the reasoning that the
// anchor is the card you are already reading; including it makes the row a
// complete picture of the place rather than a list with one part missing from
// it, and - since tapping a chip replaces the body - it is also the way back.
//
// Not a modal. The map behind it stays live and pannable - panning is how the
// card is USED, it rides along with its pin - and claiming `aria-modal` would
// tell a screen-reader user the rest of the screen is inert when it is not.
// Same call ClosureSheet makes.
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type RefObject,
} from 'react'
import type { Map as MapLibreMap } from 'maplibre-gl'
import { typeLabel } from './legendLabels'
import { sourceLabel } from './poiSources'
import { placePoiCard, type CardPlacement } from './poiCardPlacement'
import { poiColor, poiGlyphPath } from '../map/poiIcons'
import { MapIcon } from '../map/MapIcon'
import { siteDistanceFeet } from '../map/poiSites'
import { describeNearby, type NearbyPart } from '../lib/nearbyClause'
import { formatShortDistance, type UnitSystem } from '../lib/units'
export interface PoiDetail {
id: string
name: string
type: string
lat: number
lon: number
confidence: 'high' | 'low'
/**
* Which published source listed it - see poiSources.ts.
*
* Optional because a phone that downloaded before the client started
* reading this field has POIs in IndexedDB without one, and re-downloading
* a corridor to gain a provenance line is not a trade worth forcing. The
* line is simply omitted until the next download.
*/
source?: string
/**
* Distance along the trail, when the centerline index could place it.
*
* Same optionality as SearchablePoi's, and for the same reason: it comes
* from the trail lines, which are a separate download that can legitimately
* be missing. A shelter with no mile is still worth a card.
*/
mile?: number
/**
* How many people the shelter sleeps, when the pipeline could publish a
* number for it.
*
* Optional twice over: only shelters have one at all, and ATC's layer does
* not carry capacity, so the figure comes from a list joined on by
* pipeline/build_shelter_capacity.py that leaves some shelters blank on
* purpose. Absent means unknown - the line is omitted rather than shown
* empty, because a hiker deciding whether to push on to the next shelter
* is better served by no answer than by a made-up one.
*/
capacity?: number
/**
* How far the nearest water source is, in feet, by ATC's own measurement
* (pipeline/build_water_distance.py).
*
* Carried by shelters, campsites, and the water members the pipeline
* synthesizes onto their sites from the same figure (#694). Those members
* inherit the site's coordinates because ATC states how far and never
* where - which is exactly why partDistance prefers this number over a
* coordinate-derived one: measuring the inherited position would print
* "0 m" for a distance the data actually knows. Absent means nobody has
* published one, never "no water" - the capacity rule.
*/
waterDistanceFt?: number
/**
* One sentence about the place - what it is built of, what it has, when it
* went up - for shelters and campsites.
*
* The pipeline composes it from ATC's inventory columns, so it is a run of
* stated facts rather than anybody's prose, and where ATC's maintainers
* wrote a note of their own it is quoted as theirs. Optional: no other
* waypoint type has one, and a phone that downloaded before it existed has
* none at all.
*/
description?: string
/**
* What is around this one, when it anchors a site: a privy, a campsite, the
* water ATC's own index puts nearest (#614, #625).
*
* A phrase and a distance per part rather than the finished sentence the
* pipeline used to publish - `describeNearby` writes the sentence, in the
* units this hiker chose, which is a question no artifact composed months ago
* could have answered. Absent on every POI that anchors nothing, and on any
* copy downloaded before the field existed.
*/
nearby?: NearbyPart[]
/**
* A photo of the place, when one exists.
*
* Published as photo_* properties on the POI artifacts (pipeline
* fetch_poi_images.py + export_poi.py: openly-licensed Wikimedia Commons
* photos with a recent EXIF capture date, matched by proximity). Most
* waypoints have no eligible photo, and a photo is optional either way:
* its absence is the placeholder, never a broken image or a withheld card.
*/
photoUrl?: string
/** The Commons file page - full licence terms, history, original file.
* The credit line links here when present. */
photoPage?: string
/** Who took it. For CC BY/BY-SA photos the pipeline guarantees this is
* set - naming the author is the condition of use, not a courtesy. */
photoAuthor?: string
/** The licence's short name, e.g. "CC BY-SA 4.0". */
photoLicense?: string
/** EXIF capture date, ISO "YYYY-MM-DD". Shown as a month: a photo's age
* is a fact the hiker gets, same rule as the unverified sentence. */
photoTaken?: string
/**
* Every photo of this place, card photo first, when there is more than one.
*
* ATC's facility layers carry up to ten per POI. Absent for a single photo
* and for any release published before galleries existed - the flat fields
* above are then the whole story, and the card renders exactly as it did.
*/
photos?: CardPhoto[]
}
export interface PoiCardProps {
poi: PoiDetail
/**
* Every part of the site this waypoint belongs to, anchor first - built by
* map/poiSites.ts's `siteRoster` and handed down by the shell, which is the
* only layer holding the other POIs.
*
* Absent or a single entry means there is nothing to offer and the card
* renders exactly as it did before sites existed: no strip, no group, no
* change of any kind. That is the same backward-compatibility rule every
* optional field above states - a phone that downloaded before #523 published
* the grouping has no site keys at all, so its cards must be the old cards
* rather than one chip that leads nowhere.
*/
site?: readonly PoiDetail[]
/**
* The live map the card is anchored on.
*
* Null is tolerated rather than forbidden - the shell learns about the map
* from an effect, so there is an instant where a card could exist first.
* With no map there is no anchor, and the card renders unpositioned at the
* canvas origin: still readable, still closable, correctly placed one
* projection later.
*/
map: MapLibreMap | null
/**
* Feet or metres, for every distance on this card (lib/units.ts).
*
* Handed down like MapScreen's own, and defaulted the way every other
* `units` prop in the app is - a caller that has not thought about it gets
* the trail's own units rather than a crash. It reaches two places, and they
* are the two that used to disagree: the chips, and the nearby sentence.
*/
units?: UnitSystem
onClose: () => void
}
function mile(value: number): string {
return value.toLocaleString('en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
}
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
/**
* "Jun 2025" from an ISO capture date - month precision is honest about what
* an EXIF date is worth and short enough for a credit line. Formatted by
* hand rather than through Date: "2025-06-18" parsed as a Date is UTC
* midnight, which toLocaleDateString renders as the previous month's last
* day in any western-hemisphere timezone.
*/
function photoMonth(taken: string | undefined): string | null {
if (taken === undefined) return null
const match = /^(\d{4})-(\d{2})/.exec(taken)
if (match === null) return null
const month = Number(match[2])
if (month < 1 || month > 12) return null
return `${MONTHS[month - 1]} ${match[1]}`
}
/**
* The whole credit line, or null when there is nothing to say. Author and
* licence are the parts CC attribution requires; the month is this app's own
* honesty rule applied to the photo. A photo can legitimately have no
* author (public domain) - the line simply shortens.
*/
function photoCredit(photo: CardPhoto): string | null {
const parts = [photo.author, photo.license, photoMonth(photo.taken)].filter(
(part): part is string => typeof part === 'string' && part !== '',
)
if (parts.length === 0) return null
return `Photo: ${parts.join(' · ')}`
}
/** One photo as the card renders it, whichever shape the POI carried. */
interface CardPhoto {
url: string
page?: string
author?: string
license?: string
taken?: string
}
/**
* Every photo this card can show, in order, card photo first.
*
* Two shapes converge here. A POI published before galleries existed has only
* the flat `photo*` fields; one published after also carries `photos`, whose
* first entry describes the same image. Normalising to one list means the
* render path has a single shape and the controls appear exactly when there
* is somewhere to go.
*/
function cardPhotos(poi: PoiDetail): CardPhoto[] {
if (poi.photos !== undefined && poi.photos.length > 0) return poi.photos
if (poi.photoUrl === undefined) return []
return [
{
url: poi.photoUrl,
...(poi.photoPage !== undefined ? { page: poi.photoPage } : {}),
...(poi.photoAuthor !== undefined ? { author: poi.photoAuthor } : {}),
...(poi.photoLicense !== undefined ? { license: poi.photoLicense } : {}),
...(poi.photoTaken !== undefined ? { taken: poi.photoTaken } : {}),
},
]
}
/**
* Five decimal places is about a metre, which is finer than any of these
* points is surveyed and exactly what someone reading a position out over a
* radio needs.
*
* A plain hyphen-minus, never a typographic one: these numbers exist to be
* copied into another device, and U+2212 is rejected by most of them.
*/
function coordinates(lat: number, lon: number): string {
return `${lat.toFixed(5)}, ${lon.toFixed(5)}`
}
/**
* One metre, in feet - pipeline/lib/poi_description.py's `MIN_PART_FT`, which
* floors the distances the pipeline publishes for the same reason.
*
* A stated distance arrives unfloored (`water_distance_ft` is its own column,
* not a nearby part), and a card claiming a hiker walks zero of anything to
* reach water reads as a bug rather than as the very short walk it is
* asserting. Stated in the coarser unit, so neither system rounds it away:
* flooring at 1 ft would still print "0 m" for a metric hiker, which is the
* defect arriving in the other unit. #694 floored it at a metre for exactly
* this reason, back when this line printed only metres.
*/
const MIN_PART_FT = 3.28084
/**
* How far a part of the site is from the pin, for its chip.
*
* FROM THE PIN, NOT FROM THE PART CURRENTLY OPEN. The pin is the one point on
* this site the hiker can see, and it is where they are standing when they ask;
* measuring from whichever chip was tapped last would rewrite every other
* number in the row on every tap, which is churn in a strip that is meant to be
* readable at a glance - and would change the strip's height in the process.
*
* "The pin" and not "the anchor", which this said until #607/#609 made them
* different things: a site whose anchor the legend filters out gives the pin to
* a member, and the number a hiker wants is the offset from what they can
* actually see. See the note where the caller resolves it.
*
* MEASURED HERE RATHER THAN READ OFF THE ARTIFACT, and that is the reason:
* `nearby`'s distances are measured from the ANCHOR, because that is the point
* the pipeline knows a hiker can see. When the two are the same point - which
* is every site the legend has not filtered - both come out of the same
* equirectangular formula with the same constant, so the chip and the sentence
* agree to well inside the rounding. When they are not, the chip is right and
* the sentence is answering a different question, which is what it did before
* this card existed.
*
* The hiker's own units since #625 (lib/units.ts). This was the single line in
* the app exempt from that standard, held open while the same distances were
* also published as prose in metres: converting one half would have put
* `Privy · 130 ft` over a sentence saying 40 m. Both halves moved together in
* the end, which is what the exemption was waiting for.
*
* A STATED DISTANCE BEATS A COORDINATE DISTANCE (#694). A water member the
* pipeline synthesized from ATC's distance-to-water inherits the site's own
* coordinates - ATC states how far, never where - so measuring it would print
* "Water · 0 ft" beside a sentence saying 121 ft, the drift above in its worst
* form. Such a member carries the stated figure as `waterDistanceFt`, and it
* wins whenever present; real mapped members carry none and keep the measured
* offset exactly as before.
*
* That figure needs no conversion here, which is the one simplification #625
* hands #694: ATC states it in feet, the artifact publishes it in feet, and
* feet is what lib/units.ts formats from. It reached this line as metres only
* because this line printed metres.
*/
function partDistance(pin: PoiDetail, part: PoiDetail, units: UnitSystem): string {
const feet =
part.type === 'water' && part.waterDistanceFt !== undefined
? Math.max(MIN_PART_FT, part.waterDistanceFt)
: siteDistanceFeet(pin, part)
return formatShortDistance(feet, units)
}
/**
* The card's screen position, re-projected on every camera move.
*
* Layout effect rather than effect, so the first placement lands before the
* first paint - the card must never flash at the canvas origin on its way to
* the pin. Measuring the card in the same pass is safe for the same reason:
* by the time this runs the DOM holds the final content, and reading
* `offsetWidth` forces layout synchronously.
*
* TWO POIS, AND THEY ARE NOT INTERCHANGEABLE. `anchor` is the point projected:
* the one thing on this site with a pin, since #524 removed the members' own.
* Projecting the shown part instead would hang the card off a position where
* nothing is drawn - the mild form of the refusal features/POI_SITES.md makes
* of spiderfying, which is that drawing a privy 80 px from where it is says
* something untrue about where it is. `shown` is only ever a dependency, and it
* is there because it changes the card's HEIGHT (see below).
*/
function usePinAnchor(
map: MapLibreMap | null,
anchor: PoiDetail,
shown: PoiDetail,
card: RefObject<HTMLDivElement | null>,
): CardPlacement | null {
const [placement, setPlacement] = useState<CardPlacement | null>(null)
useLayoutEffect(() => {
if (map === null) return
const update = () => {
// Unreachable, and kept for the type checker: the ref is attached to
// the element this hook's caller always renders, and effects run after
// it is in the DOM.
/* v8 ignore next */
if (card.current === null) return
const canvas = map.getCanvas()
const next = placePoiCard(
map.project([anchor.lon, anchor.lat]),
{ width: card.current.offsetWidth, height: card.current.offsetHeight },
// The canvas's CSS size, which is the coordinate space `project`
// answers in - `canvas.width` is that times the device pixel ratio.
{ width: canvas.clientWidth, height: canvas.clientHeight },
)
// 'move' fires every animation frame of a pan; only re-render for a
// placement that actually moved.
setPlacement((previous) =>
previous !== null && previous.left === next.left && previous.top === next.top
? previous
: next,
)
}
update()
map.on('move', update)
map.on('resize', update)
return () => {
map.off('move', update)
map.off('resize', update)
}
// `anchor`, not `anchor.lon`/`anchor.lat`: a mile arriving late or a source
// line appearing changes the card's HEIGHT, and a placement measured
// against the old height would leave a flipped card overlapping its pin.
//
// `shown` is in here for that same reason and a much larger dose of it.
// Tapping a chip is not a camera move, so nothing on the map fires and
// nothing else would re-measure - the placement would keep the height of
// the part the hiker just left until the next pan. A privy is several lines
// shorter than its shelter (no capacity, usually no description, often no
// photo credit), and a card placed BELOW its pin is positioned by its own
// height, so a stale one sits over the pin it is describing.
}, [map, anchor, shown, card])
return placement
}
export function PoiCard({
poi,
site = [],
map,
units = 'imperial',
onClose,
}: PoiCardProps) {
const cardRef = useRef<HTMLDivElement | null>(null)
// THE ANCHOR IS NOT THE POINT THIS CARD HANGS OFF, AND THE DIFFERENCE IS NOT
// COSMETIC. `siteRoster` puts the anchor first whichever part of the site it
// was asked about, so `site[0]` is the site's own identity - what the place is
// called - and that is the ONE thing it is used for below.
//
// Everything positional keys on `poi` instead, because `poi` is the point
// CARRYING THE PIN. That is a precondition of this card, not a coincidence:
// map/poiLayers.ts builds its features from `composeSites().drawn` and writes
// the carrier's id, and a tap is the only thing that opens a card, so what the
// shell selected is by construction what is drawn.
//
// AND THE CARRIER IS NOT ALWAYS THE ANCHOR. #607/#609 gave a site's members
// their pins back when the legend filters the anchor out: hide shelters, and
// the site redraws as its highest-priority drawn member, so tapping it selects
// the PRIVY. Keying the projection on `site[0]` there would hang the card off
// the shelter - a point with nothing drawn at it, 42 m away at the median,
// which is 11 px at z14 and 165 px at z18 - which is the mild form of the
// spiderfying features/POI_SITES.md refuses, and the exact failure keying on
// the anchor was meant to avoid.
//
// So when #527 lets search open a member's card directly, `poi` will no longer
// be the carrier and this card will have to be TOLD which point is - the shell
// computes the composition and knows. It is not knowable from here, and
// guessing `site[0]` is wrong in the case that already exists.
const anchor = site[0] ?? poi
// Which part of the site the card is showing. Held here rather than lifted to
// the shell's `selectedPoiId`, which is fed to a map that has no pin for a
// member to select and whose setter closes the legend on the way past.
//
// Reset on the waypoint, because MapScreen renders this card without a React
// key: the state survives a change of pin, and opening a different shelter
// must not land on the last one's privy.
const [shownId, setShownId] = useState(poi.id)
useEffect(() => setShownId(poi.id), [poi.id])
// The `?? poi` is load-bearing rather than defensive. On the render between a
// new waypoint arriving and that reset effect firing, `shownId` still names
// the previous site's privy - and falling back to the waypoint the shell asked
// for makes that frame already correct instead of blank. The waypoint, not
// `anchor`: what this card opens on is what was selected, which is the anchor
// only until something selects a member (see above).
const shown = site.find((part) => part.id === shownId) ?? poi
const placement = usePinAnchor(map, poi, shown, cardRef)
const source = sourceLabel(shown.source)
// `shown`, not `poi`: tapping a chip swaps this card to that part's own
// detail, and a part that anchors a site of its own - a campsite with a privy
// beside it - has parts of its own to name. Read off whichever waypoint the
// card is currently showing, like the description and the source line above.
const nearby = describeNearby(shown.nearby, units)
// The two regions a chip swaps, named so `aria-controls` can point at them.
// Through `useId` rather than a pair of constants because ids have to be
// unique in a document and nothing here can promise there is one card - a
// test rendering two, or a compare view, would otherwise have both cards'
// chips controlling the first card's boxes.
const regionId = useId()
const mediaId = `${regionId}media`
const bodyId = `${regionId}body`
// What to say out loud when a chip replaces the card under someone who cannot
// see it happen. `aria-current` below is an ARIA *property*: a screen reader
// announces it on ARRIVAL at the chip rather than when it flips - unlike
// aria-pressed or aria-selected - so activating a chip otherwise moves the
// heading, the coordinates, the provenance, the unverified sentence and the
// photograph in complete silence. This is the half of screens/Tabs.tsx's
// contract that role="tab"/role="tabpanel" would have given for free and that
// the plain-button markup has to say for itself.
//
// Empty until a chip is tapped, and reset with the waypoint: a reader arriving
// at a freshly opened card is about to be read the card, and a region already
// holding "Showing X" would either say it twice or announce the last card's
// part on this one.
const [announced, setAnnounced] = useState('')
useEffect(() => setAnnounced(''), [poi.id])
const photos = cardPhotos(shown)
// Which photo of this place is on screen. Reset when the card changes to a
// different waypoint - opening a shelter after paging to photo 4 of the
// last one must start at its own first photo, not its fourth. Keyed on the
// part SHOWN, not on the tapped pin, because tapping through to the privy is
// that same situation: photo 4 of the shelter must not decide which
// photograph of the privy comes up first.
const [photoIndex, setPhotoIndex] = useState(0)
useEffect(() => setPhotoIndex(0), [shown.id])
// Guarded rather than trusted: a re-render with a shorter list (a fresh
// download of the same waypoint) must not index off the end.
const current = photos[Math.min(photoIndex, photos.length - 1)]
// A photo that 404s becomes the placeholder, not a broken-image glyph over
// the name. State resets when the URL changes: a fresh download pointing at
// a working photo should get to show it, and so should stepping to the next
// photo after a broken one.
const [photoFailed, setPhotoFailed] = useState(false)
useEffect(() => setPhotoFailed(false), [current?.url])
const accent = poiColor(shown.type)
const showPhoto = current !== undefined && !photoFailed
const credit = current === undefined ? null : photoCredit(current)
// Controls only where they lead somewhere. Wrapping rather than disabling at
// the ends: two photos and a dead "next" is a worse answer than a loop.
const hasGallery = photos.length > 1
const step = (delta: number) =>
setPhotoIndex((i) => (i + delta + photos.length) % photos.length)
// A strip only where it says something. One chip is the card you are already
// reading, which is a control that answers a question nobody asked.
const parts = site.length > 1 ? site : []
return (
<div
ref={cardRef}
className="poi-card"
role="dialog"
aria-label="Waypoint"
style={{
// A transform rather than left/top, so following a pan is a
// composite step per frame instead of a relayout per frame.
transform:
placement === null
? undefined
: `translate(${placement.left}px, ${placement.top}px)`,
// The category accent, for the placeholder's wash and glyph. Inline
// because only this file knows the type; the stylesheet cannot.
['--poi-accent' as string]: accent,
}}
>
{/* Identified rather than anonymous, because the chips below claim to
control it - see `aria-controls` there. */}
<div className="poi-card__media" id={mediaId}>
{showPhoto ? (
<img
className="poi-card__photo"
data-testid="poi-card-photo"
src={current.url}
// Empty on purpose: the app knows nothing about the photo beyond
// which waypoint it belongs to, and the name is the next line
// down. Announcing "photo of {name}" would say the name twice.
alt=""
onError={() => setPhotoFailed(true)}
/>
) : (
<div className="poi-card__placeholder" data-testid="poi-card-placeholder">
<svg
className="poi-card__glyph"
viewBox="0 0 1 1"
aria-hidden="true"
focusable="false"
>
<path d={poiGlyphPath(shown.type)} fillRule="evenodd" />
</svg>
</div>
)}
{/* The credit rides the photo, never the placeholder: it is a fact
about a photo on screen, and the licence's price for it being
there. A link when the file page is known - full terms live
there - and plain text when it is not, because a credit is owed
either way. */}
{showPhoto &&
credit !== null &&
(current.page !== undefined ? (
<a
className="poi-card__credit"
href={current.page}
target="_blank"
rel="noreferrer"
>
{credit}
</a>
) : (
<span className="poi-card__credit">{credit}</span>
))}
{/* Only when there is more than one photo. The count is the honest
part: "2 of 7" says how much more there is without making anyone
tap to find out, and it is what tells a hiker on a ridge whether
the gallery is worth the data.
This used to require `showPhoto` too, on the reasoning that paging
a placeholder leads nowhere (#481). That is true when EVERY photo
has failed, and the gate fired when the CURRENT one had - which for
a freshly opened card is always the first. So a shelter whose photo
1 was missing from the cache showed a placeholder with no controls,
and its other six photographs were unreachable. Offline-first makes
that routine rather than rare: a URL the cache no longer holds is
the ordinary condition here, not an error.
Gated on the list instead, so the arrows over a placeholder are the
way out of a bad image rather than chrome over a blank. */}
{hasGallery && (
<div className="poi-card__gallery">
<button
type="button"
className="poi-card__gallery-step"
data-testid="poi-card-photo-prev"
onClick={() => step(-1)}
>
<span className="visually-hidden">Previous photo</span>
<span aria-hidden="true">‹</span>
</button>
<span className="poi-card__gallery-count" data-testid="poi-card-photo-count">
{Math.min(photoIndex, photos.length - 1) + 1} of {photos.length}
</span>
<button
type="button"
className="poi-card__gallery-step"
data-testid="poi-card-photo-next"
onClick={() => step(1)}
>
<span className="visually-hidden">Next photo</span>
<span aria-hidden="true">›</span>
</button>
</div>
)}
<button type="button" className="poi-card__close" onClick={onClose}>
<span className="visually-hidden">Close waypoint details</span>
<span aria-hidden="true">×</span>
</button>
</div>
<div className="poi-card__body" id={bodyId}>
{/* The part on screen, not the site. features/POI_SITES.md's open
question 5 asked whether the card names the place once at the top or
re-names it per member, and the lines underneath decide it: the
coordinates, the unverified sentence and the provenance below this
heading all belong to the part being shown, and a privy's
coordinates under a shelter's name is precisely the kind of false
statement this card exists not to make. The site is still named
once - on the strip below, where it costs no height. */}
<h2 className="poi-card__name">{shown.name}</h2>
{/* Every part of this place, the one you are on included.
NOT a `role="tablist"`, and the reason is structural rather than a
preference. The photo, the gallery and the credit are as
member-specific as the text is, and they are ABOVE this strip in the
card - so a `tabpanel` here could only contain the text, while the
image it claimed to control changed silently over the hiker's head.
The alternatives are worse: reordering the card to put the photo
inside a panel moves the media box off the card's top edge and
re-parents the close button out of the corner it is drawn for. So:
plain buttons and `aria-current` on the one you are reading.
What screens/Tabs.tsx's pattern is reused for is the part that
matters, which is its rule: ONE panel rendered, not three hidden with
CSS. There is one media box and one body here, both driven from
`shown`, so a part nobody is looking at has no gallery buttons in the
tab order and nothing for a screen reader to announce.
The rest of that pattern's contract is what the two things after the
strip put back: `aria-controls` naming both regions a chip drives -
the objection above is to a tabpanel WRAPPER, and does not reach an
attribute that takes an ID-reference LIST - and a live region that
actually produces the announcement, since `aria-current` changing is
not one. */}
{parts.length > 0 && (
<div
className="poi-card__chips"
role="group"
// The anchor's own name, which is what the pipeline publishes as
// `site_name` (features/POI_SITES.md §3). Taken from the anchor
// itself - the same point the first chip stands for - so the two
// cannot disagree about what this place is called.
aria-label={`Parts of ${anchor.name}`}
>
{parts.map((part) => {
// "This is the part you are on", which since the words came off
// every chip is read in one place only - `aria-current`, and the
// inset ring chrome.css hangs off it. It was two readings while the
// selected chip also spelt itself out, and they could drift: a chip
// wearing the current ring with its label hidden is a pin with a
// circle round it and nothing saying what it is. Now the ring is the
// whole of the marking, and the words for that part are on the meta
// line below rather than in the strip.
const isShown = part.id === shown.id
return (
<button
key={part.id}
type="button"
className="poi-card__chip"
data-testid="poi-card-chip"
// `aria-current`, the "one of a set of related items you are on"
// attribute, rather than `aria-pressed`: these are not toggles,
// and exactly one of them is true at a time.
aria-current={isShown}
// Both boxes, because a chip really does swap both, and a list
// is what the attribute is for. It is the programmatic link
// between the control and what it changes that the plain-button
// markup would otherwise be missing.
aria-controls={`${mediaId} ${bodyId}`}
onClick={() => {
setShownId(part.id)
setAnnounced(`Showing ${part.name}`)
}}
>
<MapIcon
className="poi-card__chip-icon"
type={part.type}
// The rim, unlike the legend's (Legend.tsx passes none, on the
// grounds that a key says what a category's symbol IS and a
// symbol that changed as you panned would not be a key). A
// chip is not a key: it stands for one privy, so the broken
// rim is a fact about that privy, the same fact its own panel
// spells out in words once you tap it.
confidence={part.confidence}
/>
{/* EVERY CHIP IS ITS PIN, THE ONE YOU ARE READING INCLUDED.
#711 took the words off the UNSELECTED chips and left the
selected one spelling itself out, and its own table named what
that left behind: `Campsite · 181 ft` was 172 px of the 364 a
five-part strip still wanted out of 240. Finishing the job has
two consequences worth stating.
The strip goes back to FIXED GEOMETRY, which #711 knowingly
spent. The current-chip marker is an inset ring (chrome.css)
precisely so that marking a chip does not resize it; a chip
that grew when selected undid that, and the row shifted
sideways under the thumb that had just tapped it.
And the whole strip fits at every site size the trail has.
Measured in Chromium 1194 at the card's real width against this
file's own fixtures plus the four-fact case the meta line needs
below (2026-08-16), as chip boxes plus gaps rather than
scrollWidth - which floors at the container and hides the
headroom, so #711's "240" for a fitting case and its "240" for
the container are the same number by accident:
3 parts, as it opens 180 -> 140 fits (was: fits)
3 parts, campsite open 244 -> 140 fits
5 parts, as it opens 276 -> 236 fits
5 parts, campsite open 348 -> 236 fits
Five 44 px chips and four 4 px gaps is 236 of 240, so five
parts - the largest site on the trail (features/POI_SITES.md
§5) - is the last size that fits, with 4 px to spare. SIX would
ask 284 and scroll, and nothing here changes what happens then:
`overflow-x: auto` with no scrollbar is reachable and not
discoverable, which is #711's bug returning at a site size that
does not exist yet. That is the number to re-run this against
if #529's water gap closes and sites grow.
HIDING THE SELECTED CHIP'S WORDS COSTS NOTHING, which is why
this is small rather than a trade. Its category was already on
the meta line below and its name in the heading above; the one
fact that lived nowhere else is its distance, and that moves
down to the meta line rather than going away.
`visually-hidden` rather than `display: none`, unchanged from
#711: the words stay in the accessibility tree, so the button's
name is still "Privy 131 ft" and nothing a screen reader does
here changes at all. What a sighted hiker gives up is unchanged
too, and still real - a chip is a symbol they have to recognise
until they tap it. @unvalidated, and inherited rather than
introduced: that a 44 px pin is legible and hittable with a
gloved thumb in sun is the field test HIKER_SAFETY.md §5
declines to guess at, which #711 flagged for the chips it had
already made pins and this extends to one more per card. */}
<span className="poi-card__chip-label visually-hidden">
{typeLabel(part.type)}
{part.id !== poi.id && (
<>
{/* The middot is punctuation for eyes only, as it is on
the meta line - but a button's accessible name is its
contents CONCATENATED, and with the separator hidden
there is nothing left between the two facts: this
announced "Privy40 m" until the spaces were made real
text nodes of their own. They cost nothing visually,
because a flex container drops a whitespace-only run
instead of making an item of it, and the gap is what
does the spacing - which is why the span wrapping them
is a flex container of its own and not a plain
inline. */}{' '}
<span aria-hidden="true">·</span>{' '}
<span className="poi-card__chip-distance">
{partDistance(poi, part, units)}
</span>
</>
)}
</span>
</button>
)
})}
</div>
)}
{/* The announcement itself, empty until a chip is tapped. Rendered
whenever there is a strip rather than conditionally on there being
something to say: a live region has to be in the DOM BEFORE its text
changes, or the change is the region appearing and nothing is read.
Visually hidden because the swap is not news to anyone who can see
the card - they watched it happen. */}
{parts.length > 0 && (
<p className="visually-hidden" role="status">
{announced}
</p>
)}
{/* One line, up to four facts, separate elements: the mile stays
mono like every other mile on this screen, and the dots between
them are punctuation for eyes only.
THIS LINE IS WHERE THE CHIP'S WORDS WENT. The strip above is pins
alone, so the category and the distance of the part being read are
said once, here, under the row rather than inside it - and the
heading above already carries that part's name. */}
<p className="poi-card__meta">
<span>{typeLabel(shown.type)}</span>
{shown.mile !== undefined && (
<>
<span aria-hidden="true">·</span>
<span className="poi-card__mile">{`mi ${mile(shown.mile)}`}</span>
</>
)}
{shown.capacity !== undefined && (
<>
<span aria-hidden="true">·</span>
{/* "Sleeps 8", not "8": the bare number beside a mile reads as
another distance. */}
<span>{`Sleeps ${shown.capacity}`}</span>
</>
)}
{/* How far the part being read is from the pin - the same
`partDistance` the chips used, measured from the same point, so
nothing about the number changed when it moved down here. Absent on
the pin's own part, exactly as it was absent from the pin's own
chip: the card hangs off that point, and "0 ft away" from the thing
you are standing on is noise.
"away" rather than a bare figure, and rather than a phrasing of its
own. A bare `131 ft` next to `mi 2189.4` reads as a second distance
of the same kind - the hazard "Sleeps 8" is spelt out for two lines
up. `describeNearby` already says "away" for this exact claim on
this same card, so borrowing its word keeps one voice rather than
inventing a second; what "away" leaves implicit there and here is
the point measured FROM, which is the pin and not the hiker. That
ambiguity is inherited, not introduced, and it is the one thing on
this line worth revisiting if somebody reports reading it as
distance-to-walk. */}
{shown.id !== poi.id && (
<>
<span aria-hidden="true">·</span>
<span className="poi-card__part-distance">
{`${partDistance(poi, shown, units)} away`}
</span>
</>
)}
</p>
{shown.description !== undefined && (
<p className="poi-card__description">{shown.description}</p>
)}
{/* What is around this one, as its own paragraph rather than appended
to the description above (#625).
The pipeline spliced it onto the end of that sentence while it
composed the words; now that the phone composes them, keeping it
there would mean concatenating two strings from two places to make
one paragraph - and a description that failed to compose (a shelter
ATC states nothing about) would take the privy down with it. Two
paragraphs render identically when both are present, and each stands
up when the other is missing. */}
{nearby !== null && <p className="poi-card__nearby">{nearby}</p>}
{shown.confidence === 'low' && (
<p className="poi-card__unverified" role="note">
Unverified — nobody has confirmed this one is really there.
</p>
)}
<p className="poi-card__coords">
<span className="visually-hidden">Latitude, longitude: </span>
{coordinates(shown.lat, shown.lon)}
</p>
{source !== null && <p className="poi-card__source">{`From ${source}.`}</p>}
</div>
</div>
)
}