Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@ android
.DS_Store

# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
.metro-health-check*

# Package Files
pnpm-lock.yaml
package.json
package-lock.json
57 changes: 57 additions & 0 deletions .idea/workspace.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

195 changes: 160 additions & 35 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<number>();
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<BottomSheetModal>(null);
const bottomTabBarHeight = 50;
const avoidanceAreaBottomSheetRef = useRef<BottomSheetModal>(null);
const poiBottomSheetRef = useRef<BottomSheetModal>(null);

// states
const [isReportMode, setIsReportMode] = useState(false);
const [aaPointsReport, setAAPointsReport] = useState<LatLng[]>([]);
const [clickedPoint, setClickedPoint] = useState<LatLng | null>(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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -125,44 +216,56 @@ 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 (
<>
<Stack.Screen options={{ title: "Home", headerShown: false }} />

{/* Avoidance Area Bottom Sheet */}
<AvoidanceAreaBottomSheet ref={bottomSheetRef} />
<AvoidanceAreaBottomSheet ref={avoidanceAreaBottomSheetRef} />

{/* POI Bottom Sheet */}
<POIBottomSheet ref={poiBottomSheetRef} allPOIs={POIs ?? []} />

<MapView
style={{ flex: 1 }}
Expand All @@ -173,6 +276,11 @@ export default function Home() {
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
onRegionChangeComplete={(region) => {
// Calculate zoom level from latitudeDelta
const zoom = Math.round(Math.log(360 / region.latitudeDelta) / Math.LN2);
setZoomLevel(zoom);
}}
>
{/* Render polygons */}
{polygons.map((polygon, index) => (
Expand All @@ -196,9 +304,22 @@ export default function Home() {
<Marker
key={marker.id}
coordinate={marker.coordinate}
image={marker.icon}
anchor={{ x: 0.5, y: 0.5 }}
/>
onPress={() => {
if (marker.poiData) handlePOIPress(marker.poiData);
}}
>
{marker.icon && (
<Image
source={marker.icon}
style={{
width: marker.isPOI ? markerSize : BASE_ICON_SIZE,
height: marker.isPOI ? markerSize : BASE_ICON_SIZE,
resizeMode: "contain",
}}
/>
)}
</Marker>
))}
</MapView>

Expand Down Expand Up @@ -242,11 +363,15 @@ export default function Home() {
) : (
// Bottom right button to enter report mode
<Button
className="absolute bottom-4 right-4"
title={"Report"}
title="Report"
onPress={() => setIsReportMode(true)}
style={{
position: "absolute",
bottom: 16,
right: 16,
}}
/>
)}
</>
);
}
}
1 change: 1 addition & 0 deletions assets/geojson/buildings_simple.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions assets/map_icons/star_fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading