diff --git a/.gitignore b/.gitignore index c8152d6..6a682ee 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,9 @@ android .DS_Store # Temporary files created by Metro to check the health of the file watcher -.metro-health-check* \ No newline at end of file +.metro-health-check* + +# Package Files +pnpm-lock.yaml +package.json +package-lock.json \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..9038401 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 1759689324623 + + + + \ No newline at end of file diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 39c1f26..10a565a 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,14 +1,14 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; -import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; import * as turf from "@turf/turf"; import { Stack } from "expo-router"; import { useCallback, useMemo, useRef, useState } from "react"; -import { View } from "react-native"; +import { View, Image } from "react-native"; import MapView, { Polygon, Marker, LatLng } from "react-native-maps"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Toast from "react-native-toast-message"; import AvoidanceAreaBottomSheet from "~/components/AvoidanceAreaBottomSheet"; +import POIBottomSheet from "~/components/POIBottomSheet"; import { Button } from "~/components/Button"; import ReportModal from "~/components/ReportModal"; import { @@ -18,24 +18,108 @@ import { } from "~/utils/api-hooks"; import useMapIcons from "~/utils/useMapIcons"; +const BASE_ICON_SIZE = 16; +const BASE_ZOOM = 16; +const MIN_ZOOM_FOR_POIS = 14; +const ICON_SCALE = 16; +const MAX_ICON_SIZE = 50; + +const CLUSTER_RADIUS = 10; + +function getPOISubtype(poi: any): string { + switch (poi.poi_type) { + case "accessible_entrance": + return `accessible_entrance__${poi.metadata?.auto_opene ? "auto" : "manual"}`; + default: + return poi.poi_type; + } +} + +function clusterPOIs(pois: any[]): any[] { + const visited = new Set(); + const clusters: any[] = []; + + for (let i = 0; i < pois.length; i++) { + if (visited.has(i)) continue; + + const current = pois[i]; + const currentSubtype = getPOISubtype(current); + const group = [current]; + visited.add(i); + + for (let j = i + 1; j < pois.length; j++) { + if (visited.has(j)) continue; + + const candidate = pois[j]; + if (getPOISubtype(candidate) !== currentSubtype) continue; + + const pointA = turf.point([ + current.location_geojson.coordinates[0], + current.location_geojson.coordinates[1], + ]); + const pointB = turf.point([ + candidate.location_geojson.coordinates[0], + candidate.location_geojson.coordinates[1], + ]); + const distance = turf.distance(pointA, pointB, { units: "meters" }); + + if (distance <= CLUSTER_RADIUS) { + group.push(candidate); + visited.add(j); + } + } + + if (group.length === 1) { + clusters.push(current); + } else { + const multiPoint = turf.multiPoint( + group.map((p) => [ + p.location_geojson.coordinates[0], + p.location_geojson.coordinates[1], + ]), + ); + const centroid = turf.centroid(multiPoint); + clusters.push({ + ...current, + location_geojson: { + ...current.location_geojson, + coordinates: centroid.geometry.coordinates, + }, + clusteredPOIs: group, + }); + } + } + + return clusters; +} + export default function Home() { // hooks const insets = useSafeAreaInsets(); const mapIcons = useMapIcons(); - const bottomTabBarHeight = useBottomTabBarHeight(); - const bottomSheetRef = useRef(null); + const bottomTabBarHeight = 50; + const avoidanceAreaBottomSheetRef = useRef(null); + const poiBottomSheetRef = useRef(null); // states const [isReportMode, setIsReportMode] = useState(false); const [aaPointsReport, setAAPointsReport] = useState([]); const [clickedPoint, setClickedPoint] = useState(null); const [reportStep, setReportStep] = useState(0); + const [zoomLevel, setZoomLevel] = useState(15); + + const markerSize = useMemo(() => { + const scale = Math.pow(2, zoomLevel - BASE_ZOOM); + return Math.min(Math.max(BASE_ICON_SIZE * scale, ICON_SCALE), MAX_ICON_SIZE); + }, [zoomLevel]); // query hooks const { data: avoidanceAreas } = useAvoidanceAreas(); const { data: POIs } = usePOIs(); const { mutateAsync: insertAvoidanceArea } = useInsertAvoidanceArea(); + const clusteredPOIs = useMemo(() => clusterPOIs(POIs || []), [POIs]); + const getMapIcon = useCallback( (poiType: any, metadata: any) => { switch (poiType) { @@ -83,14 +167,21 @@ export default function Home() { }); } } else { - bottomSheetRef.current?.close(); + avoidanceAreaBottomSheetRef.current?.close(); + poiBottomSheetRef.current?.close(); } }; // Handle avoidance area click const handleAvoidanceAreaPress = (polygonId: string) => { if (isReportMode) return; - bottomSheetRef.current?.present({ id: polygonId }); + avoidanceAreaBottomSheetRef.current?.present({ id: polygonId }); + }; + + // Handle POI click + const handlePOIPress = (poi: any) => { + if (isReportMode) return; + poiBottomSheetRef.current?.present({ poi, clusteredPOIs: poi.clusteredPOIs ?? [poi] }); }; const polygons = useMemo( @@ -125,36 +216,45 @@ export default function Home() { ); const markers = useMemo( - () => [ - // User selected aaPoints to report - ...aaPointsReport.map((point, index) => ({ - id: `report-point-${index}`, - coordinate: point, - icon: mapIcons.point || undefined, - })), - // Clicked point - ...(clickedPoint - ? [ - { - id: "clicked-point", - coordinate: clickedPoint, - icon: mapIcons.crosshair || undefined, - }, - ] - : []), - // POIs only show if not in report mode - ...(!isReportMode - ? (POIs || []).map((poi) => ({ + () => { + const poiMarkers = !isReportMode && zoomLevel >= MIN_ZOOM_FOR_POIS + ? clusteredPOIs.map((poi) => ({ id: String(poi.id), coordinate: { longitude: poi.location_geojson.coordinates[0], latitude: poi.location_geojson.coordinates[1], } satisfies LatLng, icon: getMapIcon(poi.poi_type, poi.metadata) || undefined, + isPOI: true, + poiData: poi, })) - : []), - ], - [POIs, aaPointsReport, mapIcons, getMapIcon, isReportMode, clickedPoint], + : []; + + return [ + ...aaPointsReport.map((point, index) => ({ + id: `report-point-${index}`, + coordinate: point, + icon: mapIcons.point || undefined, + isPOI: false, + poiData: null, + })), + // Clicked point + ...(clickedPoint + ? [ + { + id: "clicked-point", + coordinate: clickedPoint, + icon: mapIcons.crosshair || undefined, + isPOI: false, + poiData: null, + }, + ] + : []), + // POIs only show if not in report mode + ...poiMarkers, + ]; + }, + [clusteredPOIs, aaPointsReport, mapIcons, getMapIcon, isReportMode, clickedPoint, zoomLevel], ); return ( @@ -162,7 +262,10 @@ export default function Home() { {/* Avoidance Area Bottom Sheet */} - + + + {/* POI Bottom Sheet */} + { + // Calculate zoom level from latitudeDelta + const zoom = Math.round(Math.log(360 / region.latitudeDelta) / Math.LN2); + setZoomLevel(zoom); + }} > {/* Render polygons */} {polygons.map((polygon, index) => ( @@ -196,9 +304,22 @@ export default function Home() { + onPress={() => { + if (marker.poiData) handlePOIPress(marker.poiData); + }} + > + {marker.icon && ( + + )} + ))} @@ -242,11 +363,15 @@ export default function Home() { ) : ( // Bottom right button to enter report mode