@wasto/cms-components 1.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +47 -0
  2. package/dist/index.d.mts +465 -0
  3. package/dist/index.d.ts +465 -0
  4. package/dist/index.js +10445 -0
  5. package/dist/index.mjs +10553 -0
  6. package/package.json +90 -0
  7. package/src/components/announcement-modal/AnnouncementModal.tsx +183 -0
  8. package/src/components/article/Article.tsx +22 -0
  9. package/src/components/article/ArticleCard.tsx +81 -0
  10. package/src/components/article/ArticleExcerpts.tsx +80 -0
  11. package/src/components/article/ArticleList.tsx +114 -0
  12. package/src/components/article/ArticleMarkdown.tsx +170 -0
  13. package/src/components/article/ArticleReaderModal.tsx +93 -0
  14. package/src/components/article/ArticleSingle.tsx +57 -0
  15. package/src/components/bottom-tab/BottomTab.tsx +392 -0
  16. package/src/components/button/Button.tsx +154 -0
  17. package/src/components/challenge/Badges.tsx +84 -0
  18. package/src/components/challenge/BadgesModal.tsx +166 -0
  19. package/src/components/challenge/Challenge.tsx +380 -0
  20. package/src/components/challenge/ChallengeModal.tsx +231 -0
  21. package/src/components/challenge/ChallengeVignette.tsx +249 -0
  22. package/src/components/challenge/ChallengesModal.tsx +243 -0
  23. package/src/components/challenge/QuizModal.tsx +381 -0
  24. package/src/components/challenge/ResultModal.tsx +144 -0
  25. package/src/components/chat/Chat.tsx +928 -0
  26. package/src/components/collection-calendar/CollectionCalendar.tsx +274 -0
  27. package/src/components/columns/Columns.tsx +34 -0
  28. package/src/components/consignes-modal/ConsignesModal.tsx +267 -0
  29. package/src/components/content-inset/ContentInset.tsx +33 -0
  30. package/src/components/header/Header.tsx +391 -0
  31. package/src/components/image/Image.tsx +16 -0
  32. package/src/components/image/ImageFrame.tsx +102 -0
  33. package/src/components/image/ImageMedia.tsx +31 -0
  34. package/src/components/image/ImageUrl.tsx +30 -0
  35. package/src/components/locations-map/LocationSheet.tsx +417 -0
  36. package/src/components/locations-map/LocationsMap.native.tsx +260 -0
  37. package/src/components/locations-map/LocationsMap.tsx +13 -0
  38. package/src/components/locations-map/LocationsMap.web.tsx +10 -0
  39. package/src/components/locations-map/LocationsMapShell.tsx +527 -0
  40. package/src/components/locations-map/LocationsMapView.tsx +108 -0
  41. package/src/components/locations-map/LocationsMapView.web.tsx +331 -0
  42. package/src/components/nav-button/NavButton.tsx +59 -0
  43. package/src/components/next-collection/NextCollection.tsx +621 -0
  44. package/src/components/notifications/NotificationsScreen.tsx +247 -0
  45. package/src/components/page/Page.tsx +64 -0
  46. package/src/components/parapraph/Paragraph.tsx +67 -0
  47. package/src/components/profile/ProfileScreen.tsx +236 -0
  48. package/src/components/recycling-info/RecyclingInfo.tsx +189 -0
  49. package/src/components/safe-area/OptionalSafeAreaProvider.tsx +17 -0
  50. package/src/components/search/Search.tsx +320 -0
  51. package/src/components/select-street-modal/SelectStreetModal.tsx +475 -0
  52. package/src/components/title/Title.tsx +100 -0
  53. package/src/components/vision/Vision.tsx +447 -0
  54. package/src/components/vision/VisionActionCard.tsx +117 -0
  55. package/src/components/vision/VisionCapture.tsx +30 -0
  56. package/src/components/vision/VisionTapOverlay.tsx +122 -0
  57. package/src/contexts/AnnouncementsContext.tsx +171 -0
  58. package/src/contexts/ChallengesContext.tsx +182 -0
  59. package/src/contexts/LocalContext.tsx +227 -0
  60. package/src/contexts/ThemeContext.tsx +37 -0
  61. package/src/contexts/WastoContext.tsx +127 -0
  62. package/src/helpers/blogPost.ts +72 -0
  63. package/src/helpers/generateQuizQuestions.ts +247 -0
  64. package/src/helpers/getContrastColor.ts +19 -0
  65. package/src/helpers/haptics.ts +20 -0
  66. package/src/helpers/hoursSchedule.ts +230 -0
  67. package/src/helpers/htmlContentToMarkdown.ts +114 -0
  68. package/src/helpers/layout.ts +54 -0
  69. package/src/helpers/loadLinearGradient.ts +22 -0
  70. package/src/helpers/maquetteTheme.ts +76 -0
  71. package/src/helpers/markdownUnderlinePlugin.ts +32 -0
  72. package/src/helpers/normalizeWasteTypeRecyclingInfo.ts +80 -0
  73. package/src/helpers/publicMediaUrl.ts +8 -0
  74. package/src/helpers/resolveApiAssetUrl.ts +21 -0
  75. package/src/helpers/systemRoutes.ts +7 -0
  76. package/src/helpers/themeStyles.ts +114 -0
  77. package/src/helpers/useSafeAreaPadding.ts +14 -0
  78. package/src/hooks/useBadges.ts +100 -0
  79. package/src/hooks/useBlogPosts.ts +105 -0
  80. package/src/hooks/useChallenges.ts +110 -0
  81. package/src/hooks/useCities.ts +56 -0
  82. package/src/hooks/useCityAnnouncements.ts +118 -0
  83. package/src/hooks/useCollectionCalendar.ts +421 -0
  84. package/src/hooks/useColumns.ts +20 -0
  85. package/src/hooks/useConsignes.ts +62 -0
  86. package/src/hooks/useLocations.ts +248 -0
  87. package/src/hooks/useNextCollection.ts +238 -0
  88. package/src/hooks/useRecyclingInfo.ts +90 -0
  89. package/src/hooks/useWastoClient.ts +16 -0
  90. package/src/index.ts +142 -0
  91. package/src/navigation/NavigationContext.tsx +56 -0
  92. package/src/sentry/initSentry.ts +120 -0
  93. package/src/types/Theme.ts +72 -0
  94. package/src/types/css-modules.d.ts +1 -0
@@ -0,0 +1,527 @@
1
+ import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ Pressable,
5
+ ScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ } from "react-native";
10
+ import { ChevronRight, MapPin, X } from "lucide-react-native";
11
+ import Animated, {
12
+ Easing,
13
+ interpolate,
14
+ useAnimatedStyle,
15
+ useSharedValue,
16
+ withTiming,
17
+ } from "react-native-reanimated";
18
+ import { useTheme } from "../../contexts/ThemeContext";
19
+ import type { ResolvedTheme } from "../../types/Theme";
20
+ import {
21
+ LOCATION_CATEGORY_META,
22
+ useLocations,
23
+ type LocationCategory,
24
+ type MapLocation,
25
+ } from "../../hooks/useLocations";
26
+ import { LocationSheet } from "./LocationSheet";
27
+ import type { LocationsMapViewProps } from "./LocationsMapView";
28
+ import { cardStyle, resolveTheme } from "../../helpers/themeStyles";
29
+ import { getContrastColor } from "../../helpers/getContrastColor";
30
+ import { Header } from "../header/Header";
31
+ import { SPACE } from "../../helpers/layout";
32
+
33
+ export type LocationsMapProps = {
34
+ fullScreen?: boolean;
35
+ showFilters?: boolean;
36
+ /** Header flottant au-dessus de la carte (titre + cloche + profil). */
37
+ overlayHeader?: boolean;
38
+ overlayTitle?: string;
39
+ };
40
+
41
+ const CATEGORIES = Object.keys(LOCATION_CATEGORY_META) as LocationCategory[];
42
+ const NOOP_NAV = { canGoBack: () => false, goBack: () => undefined };
43
+ const PANEL_OPEN = { duration: 300, easing: Easing.out(Easing.cubic) };
44
+ const PANEL_CLOSE = { duration: 260, easing: Easing.in(Easing.cubic) };
45
+
46
+ export type LocationsMapShellProps = LocationsMapProps & {
47
+ MapCanvas: ComponentType<LocationsMapViewProps>;
48
+ };
49
+
50
+ function RoundCtrl({
51
+ onPress,
52
+ children,
53
+ label,
54
+ radius,
55
+ backgroundColor,
56
+ borderColor,
57
+ }: {
58
+ onPress: () => void;
59
+ children: ReactNode;
60
+ label: string;
61
+ radius: number;
62
+ backgroundColor: string;
63
+ borderColor: string;
64
+ }) {
65
+ return (
66
+ <Pressable
67
+ onPress={onPress}
68
+ accessibilityLabel={label}
69
+ style={[
70
+ styles.roundCtrl,
71
+ {
72
+ borderRadius: radius,
73
+ backgroundColor,
74
+ borderColor,
75
+ },
76
+ ]}
77
+ >
78
+ {children}
79
+ </Pressable>
80
+ );
81
+ }
82
+
83
+ function FilterPanel({
84
+ title,
85
+ onClose,
86
+ children,
87
+ }: {
88
+ title: string;
89
+ onClose: () => void;
90
+ children: ReactNode;
91
+ }) {
92
+ const { theme } = useTheme();
93
+ const t = resolveTheme(theme);
94
+ const progress = useSharedValue(0);
95
+ const widthSv = useSharedValue(0);
96
+ const closing = useRef(false);
97
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
98
+
99
+ useEffect(() => {
100
+ return () => {
101
+ if (closeTimer.current) clearTimeout(closeTimer.current);
102
+ };
103
+ }, []);
104
+
105
+ const close = () => {
106
+ if (closing.current) return;
107
+ closing.current = true;
108
+ progress.value = withTiming(0, PANEL_CLOSE);
109
+ closeTimer.current = setTimeout(onClose, PANEL_CLOSE.duration);
110
+ };
111
+
112
+ const panelStyle = useAnimatedStyle(() => {
113
+ const width = widthSv.value;
114
+ if (width <= 0) return { opacity: 0 };
115
+ return {
116
+ opacity: 1,
117
+ transform: [
118
+ { translateX: interpolate(progress.value, [0, 1], [-width, 0]) },
119
+ ],
120
+ };
121
+ }, []);
122
+
123
+ return (
124
+ <Animated.View
125
+ onLayout={(event) => {
126
+ const nextWidth = event.nativeEvent.layout.width;
127
+ if (nextWidth <= 0) return;
128
+ const firstLayout = widthSv.value <= 0;
129
+ widthSv.value = nextWidth;
130
+ if (firstLayout) progress.value = withTiming(1, PANEL_OPEN);
131
+ }}
132
+ style={[styles.filterPanelOuter, panelStyle]}
133
+ >
134
+ <View
135
+ style={[
136
+ styles.filterPanel,
137
+ cardStyle(theme),
138
+ { borderRadius: t.radii.lg },
139
+ ]}
140
+ >
141
+ <View style={styles.filterPanelHead}>
142
+ <Text style={[styles.filterPanelTitle, { color: theme.colors.onSurface }]}>
143
+ {title}
144
+ </Text>
145
+ <Pressable
146
+ onPress={close}
147
+ hitSlop={8}
148
+ accessibilityRole="button"
149
+ accessibilityLabel="Fermer les filtres"
150
+ style={[
151
+ styles.closeBtn,
152
+ {
153
+ backgroundColor: theme.colors.background,
154
+ borderRadius: t.radii.pill,
155
+ },
156
+ ]}
157
+ >
158
+ <X size={16} color={theme.colors.onSurface} />
159
+ </Pressable>
160
+ </View>
161
+ {children}
162
+ </View>
163
+ </Animated.View>
164
+ );
165
+ }
166
+
167
+ export function LocationsMapShell({
168
+ fullScreen = false,
169
+ showFilters = true,
170
+ overlayHeader = false,
171
+ overlayTitle = "Carte",
172
+ MapCanvas,
173
+ }: LocationsMapShellProps): ReactNode {
174
+ const { theme } = useTheme();
175
+ const t = resolveTheme(theme);
176
+ const themedStyles = createThemedStyles(theme);
177
+ const { locations, isLoading, error, center } = useLocations();
178
+ const [filters, setFilters] = useState<Set<LocationCategory>>(new Set());
179
+ const [selected, setSelected] = useState<MapLocation | null>(null);
180
+ const [filtersOpen, setFiltersOpen] = useState(false);
181
+
182
+ const toggle = (category: LocationCategory) => {
183
+ setFilters((prev) => {
184
+ const next = new Set(prev);
185
+ if (next.has(category)) next.delete(category);
186
+ else next.add(category);
187
+ return next;
188
+ });
189
+ };
190
+
191
+ const visible = useMemo(() => {
192
+ if (filters.size === 0) return locations;
193
+ return locations.filter((loc) => filters.has(loc.category));
194
+ }, [filters, locations]);
195
+
196
+ const selectedCategories = CATEGORIES.filter((c) => filters.has(c));
197
+ const showChipBar = selectedCategories.length > 0;
198
+
199
+ const filterLabel =
200
+ filters.size === 0
201
+ ? "Tous les points"
202
+ : filters.size === 1
203
+ ? LOCATION_CATEGORY_META[[...filters][0]!].label
204
+ : `${filters.size} filtres`;
205
+
206
+ const selectLocation = (location: MapLocation) => {
207
+ setSelected(location);
208
+ setFiltersOpen(false);
209
+ };
210
+
211
+ const roundRadius = t.radii.pill;
212
+
213
+ return (
214
+ <View style={[styles.wrap, fullScreen && styles.fullScreen]}>
215
+ <View
216
+ style={[
217
+ styles.mapShell,
218
+ themedStyles.mapShell,
219
+ fullScreen ? styles.mapShellFull : styles.mapShellFixed,
220
+ ]}
221
+ >
222
+ {isLoading ? (
223
+ <View style={styles.center}>
224
+ <ActivityIndicator color={theme.colors.primary} />
225
+ <Text style={themedStyles.muted}>Chargement de la carte…</Text>
226
+ </View>
227
+ ) : (
228
+ <>
229
+ <MapCanvas
230
+ locations={visible}
231
+ center={center}
232
+ selectedId={selected?.id}
233
+ onSelect={selectLocation}
234
+ />
235
+ {error ? (
236
+ <View style={[styles.center, styles.mapOverlayMessage]} pointerEvents="none">
237
+ <Text style={{ color: theme.colors.error, textAlign: "center" }}>
238
+ {error}
239
+ </Text>
240
+ </View>
241
+ ) : null}
242
+ {!error && visible.length === 0 ? (
243
+ <View style={[styles.center, styles.mapOverlayMessage]} pointerEvents="none">
244
+ <Text style={themedStyles.muted}>Aucun point à afficher.</Text>
245
+ </View>
246
+ ) : null}
247
+ </>
248
+ )}
249
+ </View>
250
+
251
+ <View style={styles.topOverlay} pointerEvents="box-none">
252
+ {overlayHeader ? (
253
+ <Header
254
+ overlay
255
+ options={{ title: overlayTitle, headerBackVisible: false }}
256
+ route={{ name: overlayTitle }}
257
+ navigation={NOOP_NAV}
258
+ />
259
+ ) : null}
260
+
261
+ {showFilters ? (
262
+ <View style={styles.filtersOverlay} pointerEvents="box-none">
263
+ {showChipBar ? (
264
+ <ScrollView
265
+ horizontal
266
+ showsHorizontalScrollIndicator={false}
267
+ style={styles.chipScrollView}
268
+ contentContainerStyle={styles.chipScroll}
269
+ >
270
+ <RoundCtrl
271
+ label="Ouvrir les filtres"
272
+ onPress={() => setFiltersOpen(true)}
273
+ radius={roundRadius}
274
+ backgroundColor={theme.colors.surface}
275
+ borderColor={theme.colors.border}
276
+ >
277
+ <ChevronRight size={16} color={theme.colors.onSurface} />
278
+ </RoundCtrl>
279
+ {selectedCategories.map((category) => {
280
+ const meta = LOCATION_CATEGORY_META[category];
281
+ const fg = getContrastColor(meta.color);
282
+ return (
283
+ <Pressable
284
+ key={category}
285
+ onPress={() => toggle(category)}
286
+ style={[
287
+ styles.chip,
288
+ {
289
+ backgroundColor: meta.color,
290
+ borderRadius: t.radii.pill,
291
+ },
292
+ ]}
293
+ >
294
+ <Text style={[styles.chipText, { color: fg }]}>
295
+ {meta.label}
296
+ </Text>
297
+ </Pressable>
298
+ );
299
+ })}
300
+ <RoundCtrl
301
+ label="Effacer les filtres"
302
+ onPress={() => setFilters(new Set())}
303
+ radius={roundRadius}
304
+ backgroundColor={theme.colors.surface}
305
+ borderColor={theme.colors.border}
306
+ >
307
+ <X size={16} color={theme.colors.onSurface} />
308
+ </RoundCtrl>
309
+ </ScrollView>
310
+ ) : (
311
+ <Pressable
312
+ onPress={() => setFiltersOpen(true)}
313
+ style={[
314
+ styles.filterTrigger,
315
+ cardStyle(theme),
316
+ { borderRadius: t.radii.pill },
317
+ ]}
318
+ >
319
+ <Text style={[styles.filterTriggerText, { color: theme.colors.onSurface }]}>
320
+ {filterLabel}
321
+ </Text>
322
+ <ChevronRight size={16} color={theme.colors.onSurface} />
323
+ </Pressable>
324
+ )}
325
+ {filtersOpen ? (
326
+ <FilterPanel title={filterLabel} onClose={() => setFiltersOpen(false)}>
327
+ <View style={styles.filterList}>
328
+ {CATEGORIES.map((category) => {
329
+ const meta = LOCATION_CATEGORY_META[category];
330
+ const active = filters.has(category);
331
+ const bg = active ? meta.color : theme.colors.background;
332
+ const fg = active ? getContrastColor(meta.color) : t.colors.muted;
333
+ return (
334
+ <Pressable
335
+ key={category}
336
+ onPress={() => toggle(category)}
337
+ style={[
338
+ styles.filterRow,
339
+ {
340
+ backgroundColor: bg,
341
+ borderRadius: t.radii.md,
342
+ },
343
+ ]}
344
+ >
345
+ <MapPin size={16} color={fg} />
346
+ <Text style={[styles.filterRowText, { color: fg }]}>
347
+ {meta.label}
348
+ </Text>
349
+ </Pressable>
350
+ );
351
+ })}
352
+ </View>
353
+ </FilterPanel>
354
+ ) : null}
355
+ </View>
356
+ ) : null}
357
+ </View>
358
+
359
+ {selected ? (
360
+ <View style={styles.sheetOverlay} pointerEvents="box-none">
361
+ <LocationSheet location={selected} onClose={() => setSelected(null)} />
362
+ </View>
363
+ ) : null}
364
+ </View>
365
+ );
366
+ }
367
+
368
+ const createThemedStyles = (theme: ResolvedTheme) =>
369
+ StyleSheet.create({
370
+ mapShell: {
371
+ backgroundColor: theme.colors.surface,
372
+ borderRadius: theme.radii.lg,
373
+ borderWidth: 1,
374
+ borderColor: theme.colors.border,
375
+ ...theme.shadow.soft,
376
+ },
377
+ muted: {
378
+ color: theme.colors.muted ?? theme.colors.onBackground,
379
+ },
380
+ });
381
+
382
+ const styles = StyleSheet.create({
383
+ wrap: {
384
+ position: "relative",
385
+ },
386
+ fullScreen: {
387
+ flex: 1,
388
+ },
389
+ topOverlay: {
390
+ position: "absolute",
391
+ top: 0,
392
+ left: 0,
393
+ right: 0,
394
+ zIndex: 2,
395
+ },
396
+ filtersOverlay: {
397
+ overflow: "visible",
398
+ paddingBottom: 8,
399
+ },
400
+ filterTrigger: {
401
+ alignSelf: "flex-start",
402
+ flexDirection: "row",
403
+ alignItems: "center",
404
+ gap: 8,
405
+ marginHorizontal: SPACE.pageX,
406
+ paddingHorizontal: 14,
407
+ paddingVertical: 10,
408
+ },
409
+ filterTriggerText: {
410
+ fontSize: 14,
411
+ fontWeight: "700",
412
+ },
413
+ chipScrollView: {
414
+ alignSelf: "stretch",
415
+ flexGrow: 0,
416
+ overflow: "visible",
417
+ },
418
+ chipScroll: {
419
+ flexDirection: "row",
420
+ alignItems: "center",
421
+ gap: 8,
422
+ paddingHorizontal: SPACE.pageX,
423
+ paddingVertical: 8,
424
+ flexGrow: 0,
425
+ },
426
+ chip: {
427
+ paddingHorizontal: 14,
428
+ paddingVertical: 10,
429
+ },
430
+ chipText: {
431
+ fontSize: 13,
432
+ fontWeight: "700",
433
+ },
434
+ roundCtrl: {
435
+ width: SPACE.iconHit,
436
+ height: SPACE.iconHit,
437
+ alignItems: "center",
438
+ justifyContent: "center",
439
+ borderWidth: 1,
440
+ ...({
441
+ shadowColor: "#0F172A",
442
+ shadowOffset: { width: 0, height: 1 },
443
+ shadowOpacity: 0.08,
444
+ shadowRadius: 3,
445
+ elevation: 2,
446
+ } as const),
447
+ },
448
+ closeBtn: {
449
+ width: 28,
450
+ height: 28,
451
+ alignItems: "center",
452
+ justifyContent: "center",
453
+ overflow: "hidden",
454
+ flexShrink: 0,
455
+ },
456
+ filterPanelOuter: {
457
+ position: "absolute",
458
+ top: 0,
459
+ left: SPACE.pageX,
460
+ right: SPACE.pageX,
461
+ zIndex: 4,
462
+ },
463
+ filterPanel: {
464
+ overflow: "hidden",
465
+ paddingHorizontal: 16,
466
+ paddingTop: 18,
467
+ paddingBottom: 20,
468
+ gap: 16,
469
+ },
470
+ filterPanelHead: {
471
+ flexDirection: "row",
472
+ justifyContent: "space-between",
473
+ alignItems: "center",
474
+ },
475
+ filterPanelTitle: {
476
+ fontSize: 15,
477
+ fontWeight: "800",
478
+ },
479
+ filterList: {
480
+ gap: 10,
481
+ },
482
+ filterRow: {
483
+ flexDirection: "row",
484
+ alignItems: "center",
485
+ gap: 10,
486
+ paddingHorizontal: 14,
487
+ paddingVertical: 12,
488
+ },
489
+ filterRowText: {
490
+ fontSize: 14,
491
+ fontWeight: "600",
492
+ },
493
+ mapShell: {
494
+ overflow: "hidden",
495
+ position: "relative",
496
+ },
497
+ mapShellFixed: {
498
+ height: 320,
499
+ },
500
+ mapShellFull: {
501
+ flex: 1,
502
+ minHeight: 280,
503
+ borderRadius: 0,
504
+ borderWidth: 0,
505
+ },
506
+ sheetOverlay: {
507
+ position: "absolute",
508
+ left: 0,
509
+ right: 0,
510
+ top: 0,
511
+ bottom: 0,
512
+ zIndex: 3,
513
+ justifyContent: "flex-end",
514
+ },
515
+ center: {
516
+ flex: 1,
517
+ alignItems: "center",
518
+ justifyContent: "center",
519
+ padding: 16,
520
+ gap: 8,
521
+ },
522
+ mapOverlayMessage: {
523
+ ...StyleSheet.absoluteFillObject,
524
+ backgroundColor: "rgba(255,255,255,0.72)",
525
+ zIndex: 1,
526
+ },
527
+ });
@@ -0,0 +1,108 @@
1
+ import { Pressable, StyleSheet, View, type LayoutChangeEvent } from "react-native";
2
+ import { MapPin } from "lucide-react-native";
3
+ import { useMemo, useState, type ReactNode } from "react";
4
+ import {
5
+ LOCATION_CATEGORY_META,
6
+ type LocationCategory,
7
+ type MapLocation,
8
+ } from "../../hooks/useLocations";
9
+ import { getContrastColor } from "../../helpers/getContrastColor";
10
+
11
+ export type LocationsMapViewProps = {
12
+ locations: MapLocation[];
13
+ center: { lat: number; lon: number };
14
+ selectedId?: string | null;
15
+ onSelect: (location: MapLocation) => void;
16
+ };
17
+
18
+ const PIN = 32;
19
+
20
+ export function LocationsMapView({
21
+ locations,
22
+ center,
23
+ selectedId,
24
+ onSelect,
25
+ }: LocationsMapViewProps): ReactNode {
26
+ const [size, setSize] = useState({ width: 1, height: 1 });
27
+
28
+ const bounds = useMemo(() => {
29
+ const lats = locations.map((l) => l.lat).concat(center.lat);
30
+ const lons = locations.map((l) => l.lon).concat(center.lon);
31
+ const minLat = Math.min(...lats);
32
+ const maxLat = Math.max(...lats);
33
+ const minLon = Math.min(...lons);
34
+ const maxLon = Math.max(...lons);
35
+ const latPad = Math.max((maxLat - minLat) * 0.18, 0.008);
36
+ const lonPad = Math.max((maxLon - minLon) * 0.18, 0.008);
37
+ return {
38
+ minLat: minLat - latPad,
39
+ maxLat: maxLat + latPad,
40
+ minLon: minLon - lonPad,
41
+ maxLon: maxLon + lonPad,
42
+ };
43
+ }, [center.lat, center.lon, locations]);
44
+
45
+ const onLayout = (event: LayoutChangeEvent) => {
46
+ const { width, height } = event.nativeEvent.layout;
47
+ if (width > 0 && height > 0) setSize({ width, height });
48
+ };
49
+
50
+ const toXY = (lat: number, lon: number) => {
51
+ const x =
52
+ ((lon - bounds.minLon) / Math.max(bounds.maxLon - bounds.minLon, 0.0001)) *
53
+ size.width;
54
+ const y =
55
+ (1 - (lat - bounds.minLat) / Math.max(bounds.maxLat - bounds.minLat, 0.0001)) *
56
+ size.height;
57
+ return { x, y };
58
+ };
59
+
60
+ return (
61
+ <View style={styles.canvas} onLayout={onLayout}>
62
+ {locations.map((loc: MapLocation) => {
63
+ const { x, y } = toXY(loc.lat, loc.lon);
64
+ const color = LOCATION_CATEGORY_META[loc.category as LocationCategory].color;
65
+ const selected = loc.id === selectedId;
66
+ const iconColor = getContrastColor(color);
67
+ return (
68
+ <Pressable
69
+ key={loc.id}
70
+ onPress={() => onSelect(loc)}
71
+ style={[
72
+ styles.marker,
73
+ {
74
+ left: x - PIN / 2,
75
+ top: y - PIN / 2,
76
+ backgroundColor: color,
77
+ borderColor: selected ? "#111827" : "#ffffff",
78
+ transform: [{ scale: selected ? 1.12 : 1 }],
79
+ zIndex: selected ? 2 : 1,
80
+ },
81
+ ]}
82
+ accessibilityLabel={loc.name}
83
+ >
84
+ <MapPin size={16} color={iconColor} />
85
+ </Pressable>
86
+ );
87
+ })}
88
+ </View>
89
+ );
90
+ }
91
+
92
+ const styles = StyleSheet.create({
93
+ canvas: {
94
+ flex: 1,
95
+ minHeight: 220,
96
+ backgroundColor: "#E8E8E8",
97
+ overflow: "hidden",
98
+ },
99
+ marker: {
100
+ position: "absolute",
101
+ width: PIN,
102
+ height: PIN,
103
+ borderRadius: PIN / 2,
104
+ borderWidth: 3,
105
+ alignItems: "center",
106
+ justifyContent: "center",
107
+ },
108
+ });