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
+
+
+ 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