diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index af9e597..7816639 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,46 +1,104 @@ import { Tabs } from "expo-router"; import React from "react"; - +import { View, Image, StyleSheet } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { HapticTab } from "@/components/haptic-tab"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { Colors } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; import Feather from "@expo/vector-icons/Feather"; +import lightIcon from "@/assets/logo/logo-light.png"; +import darkIcon from "@/assets/logo/logo-dark.png"; + +interface TabHeaderProps { + readonly backgroundColor: string; + readonly logoSource: number; +} + +function TabHeader({ backgroundColor, logoSource }: TabHeaderProps) { + const insets = useSafeAreaInsets(); + + return ( + + + + ); +} + +function MapTabIcon({ color }: { readonly color: string }) { + return ; +} + +function HomeTabIcon({ color }: { readonly color: string }) { + return ; +} + +function CalendarTabIcon({ color }: { readonly color: string }) { + return ; +} export default function TabLayout() { - const colorScheme = useColorScheme(); + const colorScheme = useColorScheme() ?? "light"; + const logos = { + light: lightIcon, + dark: darkIcon, + }; + + const logoSource = logos[colorScheme]; return ( ( + + ), tabBarButton: HapticTab, + tabBarActiveTintColor: Colors[colorScheme].tint, + tabBarInactiveTintColor: Colors[colorScheme].text, + tabBarStyle: { + backgroundColor: Colors[colorScheme].background, + borderTopWidth: 0, + elevation: 5, + }, }} > , + tabBarIcon: MapTabIcon, }} /> ( - - ), + tabBarIcon: HomeTabIcon, }} /> , + tabBarIcon: CalendarTabIcon, }} /> ); } + +const styles = StyleSheet.create({ + header: { + paddingBottom: 15, + justifyContent: "center", + alignItems: "center", + borderBottomWidth: StyleSheet.hairlineWidth + }, + logo: { + width: 120, + height: 40, + } +}); diff --git a/app/(tabs)/calendar.tsx b/app/(tabs)/calendar.tsx index 71518f9..64aa3df 100644 --- a/app/(tabs)/calendar.tsx +++ b/app/(tabs)/calendar.tsx @@ -8,6 +8,7 @@ import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Fonts } from '@/constants/theme'; +import React from 'react'; export default function TabTwoScreen() { return ( diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 786b736..48917ef 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -6,6 +6,7 @@ import ParallaxScrollView from '@/components/parallax-scroll-view'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { Link } from 'expo-router'; +import React from 'react'; export default function HomeScreen() { return ( diff --git a/app/(tabs)/map-tab.tsx b/app/(tabs)/map-tab.tsx index 8b12526..efeddfa 100644 --- a/app/(tabs)/map-tab.tsx +++ b/app/(tabs)/map-tab.tsx @@ -1,4 +1,5 @@ import MapViewer from "@/components/map/map-viewer"; +import React from "react"; export default function MapTab() { return ; diff --git a/app/_layout.tsx b/app/_layout.tsx index f518c9b..9dc4e6a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -4,6 +4,7 @@ import { StatusBar } from 'expo-status-bar'; import 'react-native-reanimated'; import { useColorScheme } from '@/hooks/use-color-scheme'; +import React from 'react'; export const unstable_settings = { anchor: '(tabs)', diff --git a/app/modal.tsx b/app/modal.tsx index 6dfbc1a..548a55a 100644 --- a/app/modal.tsx +++ b/app/modal.tsx @@ -3,6 +3,7 @@ import { StyleSheet } from 'react-native'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; +import React from 'react'; export default function ModalScreen() { return ( diff --git a/assets/logo/logo-dark.png b/assets/logo/logo-dark.png new file mode 100644 index 0000000..8a7988b Binary files /dev/null and b/assets/logo/logo-dark.png differ diff --git a/assets/logo/logo-light.png b/assets/logo/logo-light.png new file mode 100644 index 0000000..5ac2e47 Binary files /dev/null and b/assets/logo/logo-light.png differ diff --git a/components/map/building-info-popup.tsx b/components/map/building-info-popup.tsx new file mode 100644 index 0000000..94ccbc2 --- /dev/null +++ b/components/map/building-info-popup.tsx @@ -0,0 +1,341 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + Animated, + PanResponder, + StyleSheet, + Text, + View, + ScrollView, + TouchableOpacity, + Linking, + useColorScheme, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { BuildingInfo } from "@/data/parsedBuildings"; +import { Colors } from "@/constants/theme"; + +interface Props { + readonly building: BuildingInfo | null; +} + +const CLOSE_HEIGHT = 520; +const COLLAPSED_HEIGHT = 175; +const OPEN_TRANSLATE_Y = 0; +const COLLAPSED_TRANSLATE_Y = CLOSE_HEIGHT - COLLAPSED_HEIGHT; + +const WEEKDAYS = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +]; + +const DEFAULT_OPENING_HOURS = [ + "7:00 AM – 11:00 PM", + "7:00 AM – 11:00 PM", + "7:00 AM – 11:00 PM", + "7:00 AM – 11:00 PM", + "7:00 AM – 11:00 PM", + "7:00 AM – 9:00 PM", + "7:00 AM – 9:00 PM", +]; + +export default function BuildingInfoPopup({ building }: Props) { + const colorScheme = useColorScheme() ?? "light"; + const theme = Colors[colorScheme]; + const styles = makeStyles(theme); + + const translateY = useRef(new Animated.Value(COLLAPSED_TRANSLATE_Y)).current; + const currentTranslateY = useRef(COLLAPSED_TRANSLATE_Y); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + if (building) { + Animated.spring(translateY, { + toValue: COLLAPSED_TRANSLATE_Y, + useNativeDriver: true, + }).start(() => { + currentTranslateY.current = COLLAPSED_TRANSLATE_Y; + }); + setExpanded(false); + } else { + Animated.spring(translateY, { + toValue: CLOSE_HEIGHT, + useNativeDriver: true, + }).start(); + } + }, [building, translateY]); + + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 5, + onPanResponderGrant: () => + translateY.stopAnimation((y) => { + currentTranslateY.current = y; + }), + onPanResponderMove: (_, g) => { + const next = Math.max( + OPEN_TRANSLATE_Y, + Math.min( + COLLAPSED_TRANSLATE_Y, + currentTranslateY.current + g.dy + ) + ); + translateY.setValue(next); + }, + onPanResponderRelease: (_, g) => { + const midpoint = + (OPEN_TRANSLATE_Y + COLLAPSED_TRANSLATE_Y) / 2; + const expand = + currentTranslateY.current + g.dy < midpoint || g.vy < -0.5; + const snapPoint = expand + ? OPEN_TRANSLATE_Y + : COLLAPSED_TRANSLATE_Y; + + Animated.spring(translateY, { + toValue: snapPoint, + velocity: g.vy, + tension: 80, + friction: 14, + useNativeDriver: true, + }).start(() => { + currentTranslateY.current = snapPoint; + }); + + setExpanded(expand); + }, + }) + ).current; + + if (!building) return null; + + const todayIdx = + new Date().getDay() === 0 ? 6 : new Date().getDay() - 1; + + const ACTIONS = [ + { label: "Directions", icon: "navigate-outline", type: "directions" as const }, + { label: "Website", icon: "globe-outline", type: "website" as const }, + ]; + + const handleAction = async (type: "directions" | "website") => { + const urls: Record = { + directions: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(building.address)}`, + website: building.link || "", + }; + + const url = urls[type]; + if (!url) { + console.warn(`No URL available for action: ${type}`); + return; + } + + try { + const canOpen = await Linking.canOpenURL(url); + if (!canOpen) { + console.warn(`Cannot open URL: ${url}`); + return; + } + await Linking.openURL(url); + } catch (error) { + console.error(`Failed to open URL (${type}):`, error); + } + }; + + const formatCamelCase = (text: string) => + text + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/\s+/g, " ") + .trim() + .replace(/^./, (c) => c.toUpperCase()); + + return ( + + + + + {building.buildingCode} – {building.buildingName} + + + + {building.campus} Campus | {building.address} + + + + Today: {DEFAULT_OPENING_HOURS[todayIdx]} + + + + {ACTIONS.map((a) => ( + handleAction(a.type)} + theme={theme} + /> + ))} + + + {expanded && ( + + + + {building.accessibility.length > 0 && ( + <> + Accessibility + {building.accessibility.map((item) => ( + + ))} + + )} + + Opening Hours + {DEFAULT_OPENING_HOURS.map((h, i) => ( + + {" "} + {WEEKDAYS[i]}: {h} + + ))} + + )} + + ); +} + +/* ---------- Subcomponents ---------- */ + +const ListItem = ({ + text, + theme, +}: { + readonly text: string; + readonly theme: typeof Colors.light; +}) => ( + + + {text} + +); + +const ActionButton = ({ + label, + icon, + onPress, + theme, +}: { + readonly label: string; + readonly icon: string; + readonly onPress: () => void; + readonly theme: typeof Colors.light; +}) => ( + + + + {label} + + +); + +const makeStyles = (theme: typeof Colors.light) => + StyleSheet.create({ + card: { + position: "absolute", + bottom: 0, + left: 0, + right: 0, + backgroundColor: theme.buildingInfoPopup.background, + paddingHorizontal: 20, + paddingTop: 10, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + elevation: 15 + }, + handle: { + width: 40, + height: 5, + backgroundColor: theme.buildingInfoPopup.handle, + borderRadius: 3, + alignSelf: "center", + marginBottom: 8 + }, + title: { + fontSize: 22, + fontWeight: "600", + color: theme.buildingInfoPopup.title, + marginBottom: 4 + }, + line: { + color: theme.buildingInfoPopup.text, + marginTop: 4 + }, + openStatus: { + color: theme.buildingInfoPopup.openStatus, + marginTop: 4, + fontWeight: "500" + }, + actionsRow: { + flexDirection: "row", + marginTop: 12, + gap: 10 + }, + sectionTitle: { + marginTop: 14, + fontWeight: "600", + color: theme.buildingInfoPopup.title + }, + rule: { + borderBottomColor: theme.buildingInfoPopup.divider, + borderBottomWidth: StyleSheet.hairlineWidth, + marginVertical: 12 + }, + todayHighlight: { + fontWeight: "700", + color: theme.buildingInfoPopup.openStatus + } + }); diff --git a/components/map/map-viewer.tsx b/components/map/map-viewer.tsx index 216d6d1..8f3873d 100644 --- a/components/map/map-viewer.tsx +++ b/components/map/map-viewer.tsx @@ -1,117 +1,260 @@ -import { CAMPUS_LOCATIONS } from "@/constants/mapData"; -import { Coordinate, CoordinateDelta } from "@/types/mapTypes"; +import React, { useRef, useState, useCallback, useMemo } from "react"; +import { StyleSheet, View, Text, Platform } from "react-native"; +import MapViewCluster from "react-native-map-clustering"; +import MapView, { Marker, Polygon, Region } from "react-native-maps"; import * as LocationPermissions from "expo-location"; -import { useRef, useState } from "react"; -import { StyleSheet, View } from "react-native"; -import MapView, { Polygon, Region } from "react-native-maps"; +import { useColorScheme } from "@/hooks/use-color-scheme"; +import { Colors } from "@/constants/theme"; +import { CAMPUS_LOCATIONS } from "@/constants/mapData"; +import { concordiaBuildings, BuildingInfo } from "@/data/parsedBuildings"; +import { Coordinate, CoordinateDelta, Building as MapBuilding } from "@/types/mapTypes"; import LocationButton, { LocationButtonProps } from "./location-button"; import LocationModal from "./location-modal"; +import BuildingInfoPopup from "./building-info-popup"; interface Props { - userLocationDelta?: CoordinateDelta; - initialRegion?: Region; - polygonFillColor?: string; - polygonStrokeColor?: string; + readonly userLocationDelta?: CoordinateDelta; + readonly initialRegion?: Region; +} + +interface Cluster { + id: string | number; + geometry: { + coordinates: [number, number]; // [longitude, latitude] + }; + properties: { + point_count: number; + }; + onPress: () => void; } export default function MapViewer({ userLocationDelta = defaultFocusDelta, initialRegion = defaultInitialRegion, - polygonFillColor = "rgba(255,0,0,0.5)", - polygonStrokeColor = "black", }: Props) { + const colorScheme = useColorScheme() ?? "light"; + const mapColors = Colors[colorScheme].map; + const mapViewRef = useRef(null); + const suppressNextMapPress = useRef(false); const [userLocation, setUserLocation] = useState(null); const [locationState, setLocationState] = useState("off"); const [modalOpen, setModalOpen] = useState(false); - const mapViewRef = useRef(null); + const [selectedBuilding, setSelectedBuilding] = useState(null); + const currentRegion = useRef(defaultInitialRegion); + + const focusBuilding = useCallback((building: MapBuilding) => { + mapViewRef.current?.animateToRegion({ + latitude: building.location.latitude, + longitude: building.location.longitude, + latitudeDelta: currentRegion?.current.latitudeDelta < 0.0025 ? currentRegion?.current.latitudeDelta : 0.0025, + longitudeDelta: currentRegion?.current.longitudeDelta < 0.0025 ? currentRegion?.current.longitudeDelta : 0.0025, + }); + }, []); + + const selectBuildingByCode = useCallback((code: string) => { + const info = concordiaBuildings.find((b) => b.buildingCode === code) ?? null; + setSelectedBuilding(info); + }, []); + + const handlePolygonPress = useCallback((building: MapBuilding) => { + suppressNextMapPress.current = true; + selectBuildingByCode(building.code); + focusBuilding(building); + + requestAnimationFrame(() => { + suppressNextMapPress.current = false; + }); + }, [selectBuildingByCode, focusBuilding]); + + const requestLocation = useCallback(async () => { + if (userLocation) return; - const requestLocation = async () => { - if (userLocation) { - return; - } const locationEnabled = await LocationPermissions.hasServicesEnabledAsync(); if (!locationEnabled) { setModalOpen(true); return; } + const { status } = await LocationPermissions.requestForegroundPermissionsAsync(); - if (status !== "granted") { - return; - } + if (status !== "granted") return; + const location = await LocationPermissions.getCurrentPositionAsync(); - setUserLocation({ - longitude: location.coords.longitude, - latitude: location.coords.latitude, - }); + setUserLocation({ latitude: location.coords.latitude, longitude: location.coords.longitude }); setLocationState("on"); - return; - }; + }, [userLocation]); + + const centerLocation = useCallback(() => { + if (!userLocation) return; - const centerLocation = () => { - if (!userLocation) { - return; - } setLocationState("centered"); - const region = { - ...userLocation, - latitudeDelta: userLocationDelta.latitudeDelta, - longitudeDelta: userLocationDelta.longitudeDelta, - }; - mapViewRef.current?.animateToRegion(region); - }; + mapViewRef.current?.animateToRegion({ ...userLocation, ...userLocationDelta }); + }, [userLocation, userLocationDelta]); + + const renderPolygons = useMemo(() => { + return CAMPUS_LOCATIONS.flatMap((building) => + building.polygons.map((polygon, index) => { + const isSelected = selectedBuilding?.buildingCode === building.code; + + return ( + handlePolygonPress(building)} + /> + ); + }) + ); + }, [mapColors, selectedBuilding?.buildingCode, handlePolygonPress]); + + const renderMarkers = useMemo(() => { + return CAMPUS_LOCATIONS.map((building) => { + const isSelected = selectedBuilding?.buildingCode === building.code; + + // Offset markers to prevent overlaps + let coordinate = building.location; + if (building.code === "VE") { + // VE overlaps with VL + coordinate = { + latitude: building.location.latitude + 0.00008, + longitude: building.location.longitude - 0.00015, + }; + } else if (building.code === "RA") { + // RA overlaps with PC + coordinate = { + latitude: building.location.latitude - 0.00008, + longitude: building.location.longitude - 0.00015, + }; + } + + return ( + { + selectBuildingByCode(building.code); + focusBuilding(building); + }} + > + + + {building.code} + + + + ); + }); + }, [mapColors, selectedBuilding?.buildingCode, focusBuilding, selectBuildingByCode]); + + const renderCluster = useCallback( + (cluster: Cluster) => { + const { id, geometry, properties, onPress } = cluster; + const count = properties.point_count ?? 0; + + return ( + + + {count > 9 ? "9+" : count} + + + ); + }, + [mapColors] + ); return ( - (userLocation ? setLocationState("on") : null)} - onUserLocationChange={({ nativeEvent: { coordinate } }) => { - if (!coordinate) { - return; - } - if (!userLocation) { - setLocationState("on"); + clusteringEnabled={Platform.OS !== "ios"} + onRegionChangeComplete={(region) => {currentRegion.current = region}} + onPanDrag={() => { + if (userLocation) setLocationState("on"); + }} + onUserLocationChange={({ nativeEvent }) => { + const coordinate = nativeEvent?.coordinate; + if (!coordinate || typeof coordinate.latitude !== "number" || typeof coordinate.longitude !== "number") return; + if (!userLocation) setLocationState("on"); + setUserLocation({ latitude: coordinate.latitude, longitude: coordinate.longitude }); + }} + spiralEnabled={false} + onPress={(e) => { + if (suppressNextMapPress.current) return; + const action = e?.nativeEvent?.action; + + if (!action || action === "press") { + setSelectedBuilding(null); } - setUserLocation(coordinate); }} + renderCluster={renderCluster} > - {CAMPUS_LOCATIONS.map((building, i) => { - return building.polygons.map((polygon, i) => ( - - )); - })} - + {renderPolygons} + {renderMarkers} + + { - if (locationState === "on") { - centerLocation(); - } else if (locationState === "off") { - requestLocation(); - } + onPress={() => { + if (locationState === "on") centerLocation(); + else if (locationState === "off") requestLocation(); }} /> - setModalOpen(false)} visible={modalOpen} /> + setModalOpen(false)} /> + ); } const styles = StyleSheet.create({ - container: { - flex: 1, + container: { flex: 1 }, + map: { width: "100%", height: "100%" }, + marker: { + paddingHorizontal: 5, + paddingVertical: 4, + borderRadius: 12, + borderWidth: 2 + }, + markerText: { + fontWeight: "700", + fontSize: 12, + }, + clusterMarker: { + paddingHorizontal: 6, + paddingVertical: 4, + borderRadius: 50, + borderWidth: 2 }, - map: { - width: "100%", - height: "100%", + clusterText: { + fontWeight: "800", + fontSize: 12 }, }); diff --git a/components/themed-view.tsx b/components/themed-view.tsx index 6f181d8..983228b 100644 --- a/components/themed-view.tsx +++ b/components/themed-view.tsx @@ -1,6 +1,6 @@ import { View, type ViewProps } from 'react-native'; - import { useThemeColor } from '@/hooks/use-theme-color'; +import React from 'react'; export type ThemedViewProps = ViewProps & { lightColor?: string; diff --git a/constants/theme.ts b/constants/theme.ts index f06facd..75ff3f6 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -1,30 +1,84 @@ -/** - * Below are the colors that are used in the app. The colors are defined in the light and dark mode. - * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. - */ +import { Platform } from "react-native"; -import { Platform } from 'react-native'; - -const tintColorLight = '#0a7ea4'; -const tintColorDark = '#fff'; +const tintColorLight = "#5e0e16"; +const tintColorDark = "#ffffff"; export const Colors = { light: { - text: '#11181C', - background: '#fff', + text: "#11181C", + background: "#fbf6ec", tint: tintColorLight, - icon: '#687076', - tabIconDefault: '#687076', + icon: "#5e0e16", + tabIconDefault: "#11181C", tabIconSelected: tintColorLight, + + map: { + polygonFill: "#a0686d", + polygonHighlighted: "#701922", + polygonStroke: "black", + marker: "#200003", + markerSelected: "#fff", + markerText: "#fff", + markerTextSelected: "#200003", + markerBorder: "#fff", + markerBorderSelected: "#200003", + clusterMarker: "#200003", + clusterText: "#fff", + }, + + buildingInfoPopup: { + background: "#ffffff", + handle: "#cccccc", + title: "#11181C", + text: "#333333", + divider: "#dddddd", + + openStatus: "#1e8e3e", + accessibilityIcon: "#1e8e3e", + + actionButtonBackground: "#e8f0fe", + actionButtonText: "#1a73e8", + actionButtonIcon: "#1a73e8", + } }, + dark: { - text: '#ECEDEE', - background: '#151718', + text: "#b28e8b", + background: "#5e0e16", tint: tintColorDark, - icon: '#9BA1A6', - tabIconDefault: '#9BA1A6', + icon: "#ffffff", + tabIconDefault: "#ffffff", tabIconSelected: tintColorDark, - }, + + map: { + polygonFill: "#a0686d", + polygonHighlighted: "#701922", + polygonStroke: "black", + marker: "#330703", + markerSelected: "#fff", + markerText: "#fff", + markerTextSelected: "#330703", + markerBorder: "#fff", + markerBorderSelected: "#330703", + clusterMarker: "#330703", + clusterText: "#fff", + }, + + buildingInfoPopup: { + background: "#ffffff", + handle: "#cccccc", + title: "#11181C", + text: "#333333", + divider: "#dddddd", + + openStatus: "#1e8e3e", + accessibilityIcon: "#1e8e3e", + + actionButtonBackground: "#e8f0fe", + actionButtonText: "#1a73e8", + actionButtonIcon: "#1a73e8", + } + } }; export const Fonts = Platform.select({ diff --git a/data/buildings-polygons.json b/data/buildings-polygons.json index a6d3a64..3ed35f3 100644 --- a/data/buildings-polygons.json +++ b/data/buildings-polygons.json @@ -127,8 +127,8 @@ "formatted_address": "1665 Rue Sainte-Catherine E, Montréal, QC H2L 2J5, Canada", "geometry": { "location": { - "lat": 45.52217, - "lng": -73.5531 + "lat": 45.494258641914726, + "lng": -73.5790133953522 }, "location_type": "ROOFTOP", "viewport": { @@ -145,8 +145,8 @@ "navigation_points": [ { "location": { - "latitude": 45.5220977, - "longitude": -73.5529365 + "latitude": 45.494258641914726, + "longitude": -73.5790133953522 } } ], diff --git a/package-lock.json b/package-lock.json index 7ea22b4..e5621fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", + "react-native-map-clustering": "^4.0.0", "react-native-maps": "^1.20.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", @@ -4053,6 +4054,26 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/geo-viewport": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@mapbox/geo-viewport/-/geo-viewport-0.4.1.tgz", + "integrity": "sha512-5g6eM3EOSl7+0p0VY+vHWEYjUlNzof936VKHTi/NuJVABjbYe8D2NAVJ0qt5C9Np4glUlhKFepgAgQ0OEybrjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@mapbox/sphericalmercator": "~1.1.0" + } + }, + "node_modules/@mapbox/sphericalmercator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/sphericalmercator/-/sphericalmercator-1.1.0.tgz", + "integrity": "sha512-pEsfZyG4OMThlfFQbCte4gegvHUjxXCjz0KZ4Xk8NdOYTQBLflj6U8PL05RPAiuRAMAQNUUKJuL6qYZ5Y4kAWA==", + "bin": { + "bbox": "bin/bbox.js", + "to4326": "bin/to4326.js", + "to900913": "bin/to900913.js", + "xyz": "bin/xyz.js" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -15382,6 +15403,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -17504,6 +17531,20 @@ "react-native": "*" } }, + "node_modules/react-native-map-clustering": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/react-native-map-clustering/-/react-native-map-clustering-4.0.0.tgz", + "integrity": "sha512-+YNh4frhZIHQReURxYGHNy9MJ50GYWpW6psoBEjvTG6vb33eYu00GmO8Pu/9VwMB1YL5lOxZ9+sJClJ8Mz1Bxw==", + "license": "MIT", + "dependencies": { + "@mapbox/geo-viewport": "^0.4.1", + "supercluster": "^8.0.0" + }, + "peerDependencies": { + "react-native": "*", + "react-native-maps": "*" + } + }, "node_modules/react-native-maps": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.20.1.tgz", @@ -18971,6 +19012,15 @@ "node": ">= 6" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index c02a12f..e35be1e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", + "react-native-map-clustering": "^4.0.0", "react-native-maps": "^1.20.1", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", diff --git a/tsconfig.json b/tsconfig.json index 32f91a4..fbbfa60 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { + "jsx": "react", "strict": true, "paths": { "@/*": ["./*"]