Enhance documentation and type definitions for location tracking and property components

This commit is contained in:
2026-02-04 23:36:51 +05:30
parent d1e383baf5
commit 986266348a
5 changed files with 163 additions and 98 deletions

View File

@@ -18,7 +18,7 @@ import React from 'react';
import { MapView, Marker, useLocationTracking, Polyline } from '@lynkedup/map-sdk'; import { MapView, Marker, useLocationTracking, Polyline } from '@lynkedup/map-sdk';
export default function App() { export default function App() {
const { isTracking, path, startTracking, stopTracking, clear } = useLocationTracking(); const { isTracking, path, startTracking, stopTracking, clear } = useLocationTracking(/* opts?: TrackingOptions, geolocation?: GeolocationAPI */);
return ( return (
<MapView style={{ flex: 1 }} initialRegion={{ latitude: 37.78825, longitude: -122.4324, latitudeDelta: 0.0922, longitudeDelta: 0.0421 }}> <MapView style={{ flex: 1 }} initialRegion={{ latitude: 37.78825, longitude: -122.4324, latitudeDelta: 0.0922, longitudeDelta: 0.0421 }}>
@@ -27,6 +27,8 @@ export default function App() {
</MapView> </MapView>
); );
} }
> Tip: `useLocationTracking()` accepts an optional `TrackingOptions` object and an optional `GeolocationAPI` (handy for testing with a mocked geolocation provider). Ensure you request platform location permissions before starting tracking.
``` ```
Tracking notes: Tracking notes:
@@ -103,11 +105,14 @@ export default function PropertiesScreen() {
Props summary: Props summary:
- `apiUrl?: string` — POST endpoint used for property searches (defaults to the demo host). | Prop | Type | Default | Description |
- `initialRegion?: Region` — starting region. | ---- | ---- | ------- | ----------- |
- `radius?: number` — search radius in meters (default: 500). | `apiUrl` | `string` | demo host | POST endpoint used for property searches. Should accept `{ location, radius }` and return `{ success, properties, location }`.
- `showSearch?: boolean` — render search bar (default: true). | `initialRegion` | `Region` | `DEFAULT_REGION` | Starting region for the map.
- `onSelectProperty?: (item) => void` — callback for property selection. | `radius` | `number` | `500` | Search radius in meters.
| `showSearch` | `boolean` | `true` | Show or hide the built-in search bar.
| `onSelectProperty` | `(item: PropertyItem) => void` | — | Callback invoked when a property is selected.
| `mapProps` | `Partial<RNMapViewProps>` | — | Props forwarded to the underlying `react-native-maps` view (e.g., `mapType`, `showsMyLocationButton`).
--- ---

View File

@@ -2,41 +2,53 @@ import React from 'react';
import { View, Text, StyleSheet } from 'react-native'; import { View, Text, StyleSheet } from 'react-native';
import { Marker } from 'react-native-maps'; import { Marker } from 'react-native-maps';
/**
* Minimal property item shape used by the SDK components.
*/
export type PropertyItem = { export type PropertyItem = {
id: string | number; id: string | number;
location?: { lat: number; lng: number }; location?: { lat: number; lng: number };
price?: number; price?: number;
price_display?: string; price_display?: string;
address?: string;
bedrooms?: number;
bathrooms?: number;
polygon?: number[][]; // [[lat, lng], ...]
}; };
/**
* Simple price formatter that returns human-readable price strings (K/M abbreviations).
*/
export const formatPrice = (value?: number | string) => { export const formatPrice = (value?: number | string) => {
if (!value) return ''; if (value === null || value === undefined || value === '') return '';
const num = typeof value === 'string' ? Number(value) : value; const num = typeof value === 'string' ? Number(value) : value;
if (!num) return String(value); if (!num && num !== 0) return String(value);
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1000) return `${Math.round(num / 1000)}K`; if (num >= 1000) return `${Math.round(num / 1000)}K`;
return num.toString(); return num.toString();
}; };
const PriceMarker: React.FC<{ export type PriceMarkerProps = {
item: PropertyItem; item: PropertyItem;
selected?: boolean; selected?: boolean;
onPress?: (item: PropertyItem) => void; onPress?: (item: PropertyItem) => void;
}> = ({ item, selected, onPress }) => { };
if (!item?.location) return null;
return ( /**
<Marker * A small marker component that displays a price label and forwards press events.
coordinate={{ latitude: item.location.lat, longitude: item.location.lng }} */
onPress={(e) => { const PriceMarker: React.FC<PriceMarkerProps> = ({ item, selected, onPress }) => {
coordinate = {{ latitude: item.location.lat, longitude: item.location.lng }
}
onPress = {(e) => {
e.stopPropagation(); e.stopPropagation();
onPress && onPress(item); onPress && onPress(item);
}} }}
> >
<View style={[styles.pin, selected && styles.selectedPin]}> <View style={[styles.pin, selected && styles.selectedPin]}>
<Text style={styles.pinText}>{item.price_display ?? formatPrice(item.price)}</Text> <Text style={styles.pinText}>{item.price_display ?? formatPrice(item.price)}</Text>
</View> </View>
</Marker> </Marker >
); );
}; };

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
Platform,
Text, Text,
FlatList, FlatList,
Image, Image,
@@ -13,21 +12,32 @@ import {
TouchableOpacity, TouchableOpacity,
} from 'react-native'; } from 'react-native';
import ClusteredMapView from 'react-native-map-clustering'; import ClusteredMapView from 'react-native-map-clustering';
import { Polygon } from 'react-native-maps'; import RNMapView, { Region, Polygon, LatLng } from 'react-native-maps';
import Geolocation from '@react-native-community/geolocation'; import type { MapViewProps as RNMapViewProps } from 'react-native-maps';
import PriceMarker, { formatPrice, PropertyItem } from './PriceMarker'; import PriceMarker, { formatPrice, PropertyItem } from './PriceMarker';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
/**
* Props for the `PropertyMap` high-level component.
*/
export type PropertyMapProps = { export type PropertyMapProps = {
/** API endpoint used for POST property searches. Should accept `{ location, radius }` and return `{ success, properties, location }` */
apiUrl?: string; apiUrl?: string;
initialRegion?: any; /** Initial map region */
initialRegion?: Region;
/** Search radius in meters (default: 500) */
radius?: number; radius?: number;
/** Show/hide the search bar */
showSearch?: boolean; showSearch?: boolean;
/** Callback when a property/item is selected */
onSelectProperty?: (item: PropertyItem) => void; onSelectProperty?: (item: PropertyItem) => void;
/** Optional props forwarded to the underlying map view */
mapProps?: Partial<RNMapViewProps>;
}; };
export const DEFAULT_REGION = { /** Default region used when none is provided */
export const DEFAULT_REGION: Region = {
latitude: 25.48131, latitude: 25.48131,
longitude: 81.854249, longitude: 81.854249,
latitudeDelta: 0.02, latitudeDelta: 0.02,
@@ -35,20 +45,24 @@ export const DEFAULT_REGION = {
}; };
export const getPolygonCoords = (polygon?: number[][]) => { export const getPolygonCoords = (polygon?: number[][]) => {
if (!polygon) return []; if (!polygon) return [] as LatLng[];
return polygon.map(([lat, lng]) => ({ latitude: lat, longitude: lng })); return polygon.map(([lat, lng]) => ({ latitude: lat, longitude: lng }));
}; };
/**
* `PropertyMap` — a ready-to-use map screen for property searches. Includes clustering, polygon rendering and a bottom property card.
*/
const PropertyMap: React.FC<PropertyMapProps> = ({ const PropertyMap: React.FC<PropertyMapProps> = ({
apiUrl = 'http://64.227.108.180:5000/property-search', apiUrl = 'http://64.227.108.180:5000/property-search',
initialRegion = DEFAULT_REGION, initialRegion = DEFAULT_REGION,
radius = 500, radius = 500,
showSearch = true, showSearch = true,
onSelectProperty, onSelectProperty,
mapProps = {},
}) => { }) => {
const mapRef = useRef<any>(null); const mapRef = useRef<RNMapView | null>(null);
const [region, setRegion] = useState<any>(initialRegion); const [region, setRegion] = useState<Region>(initialRegion);
const [properties, setProperties] = useState<PropertyItem[]>([]); const [properties, setProperties] = useState<PropertyItem[]>([]);
const [selectedItem, setSelectedItem] = useState<PropertyItem | null>(null); const [selectedItem, setSelectedItem] = useState<PropertyItem | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -70,12 +84,16 @@ const PropertyMap: React.FC<PropertyMapProps> = ({
const requestLocation = async () => { const requestLocation = async () => {
try { try {
Geolocation.getCurrentPosition((pos) => { // prefer bundled community geolocation if available
const geo = (global.navigator && (global.navigator as any).geolocation) || null;
if (geo?.getCurrentPosition) {
geo.getCurrentPosition((pos: any) => {
const { latitude, longitude } = pos.coords; const { latitude, longitude } = pos.coords;
const newRegion = { latitude, longitude, latitudeDelta: 0.02, longitudeDelta: 0.02 }; const newRegion = { latitude, longitude, latitudeDelta: 0.02, longitudeDelta: 0.02 } as Region;
setRegion(newRegion); setRegion(newRegion);
mapRef.current?.animateToRegion?.(newRegion, 1000); mapRef.current?.animateToRegion?.(newRegion, 1000);
}); });
}
} catch (err) { } catch (err) {
// ignore // ignore
} }
@@ -96,13 +114,13 @@ const PropertyMap: React.FC<PropertyMapProps> = ({
const json = await response.json(); const json = await response.json();
if (json?.success && json?.location?.coordinates?.lat && json?.location?.coordinates?.lng) { if (json?.success && json?.location?.coordinates?.lat && json?.location?.coordinates?.lng) {
const newRegion = { const newRegion: Region = {
latitude: json.location.coordinates.lat, latitude: json.location.coordinates.lat,
longitude: json.location.coordinates.lng, longitude: json.location.coordinates.lng,
latitudeDelta: 0.02, latitudeDelta: 0.02,
longitudeDelta: 0.02, longitudeDelta: 0.02,
}; };
setProperties(json.properties || []); setProperties((json.properties as PropertyItem[]) || []);
setRegion(newRegion); setRegion(newRegion);
mapRef.current?.animateToRegion?.(newRegion, 1000); mapRef.current?.animateToRegion?.(newRegion, 1000);
} }
@@ -149,7 +167,7 @@ const PropertyMap: React.FC<PropertyMapProps> = ({
ref={mapRef} ref={mapRef}
style={StyleSheet.absoluteFillObject} style={StyleSheet.absoluteFillObject}
region={region} region={region}
onRegionChangeComplete={(r: any) => { onRegionChangeComplete={(r: Region) => {
if (!r?.longitudeDelta) return; if (!r?.longitudeDelta) return;
setRegion(r); setRegion(r);
}} }}
@@ -157,10 +175,7 @@ const PropertyMap: React.FC<PropertyMapProps> = ({
animationEnabled animationEnabled
clusterPressMaxZoom={16} clusterPressMaxZoom={16}
spiralEnabled={false} spiralEnabled={false}
> {...mapProps}
{properties.map((item) =>
(item as any).polygon ? (
<Polygon
key={`poly-${item.id}`} key={`poly-${item.id}`}
coordinates={getPolygonCoords((item as any).polygon)} coordinates={getPolygonCoords((item as any).polygon)}
strokeColor={selectedItem?.id === item.id ? '#F4C430' : '#8B0000'} strokeColor={selectedItem?.id === item.id ? '#F4C430' : '#8B0000'}
@@ -175,13 +190,16 @@ const PropertyMap: React.FC<PropertyMapProps> = ({
))} ))}
</ClusteredMapView> </ClusteredMapView>
{loading && ( {
loading && (
<View style={styles.loader}> <View style={styles.loader}>
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
</View> </View>
)} )
}
{selectedItem && ( {
selectedItem && (
<Animated.View style={[styles.popupContainer, { transform: [{ translateY: popupTranslateY }] }]}> <Animated.View style={[styles.popupContainer, { transform: [{ translateY: popupTranslateY }] }]}>
<FlatList <FlatList
horizontal horizontal
@@ -202,8 +220,9 @@ const PropertyMap: React.FC<PropertyMapProps> = ({
)} )}
/> />
</Animated.View> </Animated.View>
)} )
</View> }
</View >
); );
}; };

View File

@@ -1,22 +1,49 @@
import { useRef, useState, useCallback } from 'react'; import { useRef, useState, useCallback } from 'react';
import type { LatLng } from 'react-native-maps'; import type { LatLng } from 'react-native-maps';
/**
* Options for location tracking.
* - distanceFilter: minimum change in meters to receive updates.
* - interval / fastestInterval: Android/JS timers (ms).
* - accuracy: 'high' enables high accuracy via GPS.
*/
export type TrackingOptions = { export type TrackingOptions = {
distanceFilter?: number; // meters distanceFilter?: number;
interval?: number; // ms interval?: number;
fastestInterval?: number; // ms fastestInterval?: number;
accuracy?: 'high' | 'balanced' | 'low'; accuracy?: 'high' | 'balanced' | 'low';
}; };
export function useLocationTracking(opts: TrackingOptions = {}) { /**
* Minimal Geolocation API interface (compatible with browser and @react-native-community/geolocation).
*/
export type GeolocationAPI = {
watchPosition: (
success: (pos: { coords: { latitude: number; longitude: number; accuracy?: number } }) => void,
error?: (err: any) => void,
options?: { enableHighAccuracy?: boolean; distanceFilter?: number; interval?: number }
) => number | string;
clearWatch: (id: number | string) => void;
};
/**
* Hook to track user's location path.
*
* @param opts TrackingOptions - config for watchPosition
* @param geolocation optional custom geolocation implementation (useful for testing)
* @returns object with tracking state and control functions
*/
export function useLocationTracking(opts: TrackingOptions = {}, geolocation?: GeolocationAPI) {
const [isTracking, setIsTracking] = useState(false); const [isTracking, setIsTracking] = useState(false);
const [path, setPath] = useState<LatLng[]>([]); const [path, setPath] = useState<LatLng[]>([]);
const [current, setCurrent] = useState<LatLng | null>(null); const [current, setCurrent] = useState<LatLng | null>(null);
const watchIdRef = useRef<number | null>(null); const watchIdRef = useRef<number | string | null>(null);
const onPosition = useCallback((pos: any) => { const geo = geolocation ?? (global.navigator && (global.navigator as any).geolocation) as GeolocationAPI | undefined;
const lat = pos.coords?.latitude;
const lng = pos.coords?.longitude; const onPosition = useCallback((pos: { coords: { latitude: number; longitude: number } }) => {
const lat = pos?.coords?.latitude;
const lng = pos?.coords?.longitude;
if (typeof lat === 'number' && typeof lng === 'number') { if (typeof lat === 'number' && typeof lng === 'number') {
const point = { latitude: lat, longitude: lng } as LatLng; const point = { latitude: lat, longitude: lng } as LatLng;
setPath((p) => [...p, point]); setPath((p) => [...p, point]);
@@ -26,33 +53,33 @@ export function useLocationTracking(opts: TrackingOptions = {}) {
const startTracking = useCallback(() => { const startTracking = useCallback(() => {
if (isTracking) return; if (isTracking) return;
if (!global.navigator?.geolocation?.watchPosition) { if (!geo || !geo.watchPosition) {
console.warn('[useLocationTracking] Geolocation.watchPosition not available.'); console.warn('[useLocationTracking] Geolocation.watchPosition not available.');
return; return;
} }
const id = global.navigator.geolocation.watchPosition( const id = geo.watchPosition(
onPosition, onPosition,
(err: any) => console.warn('geolocation error', err), (err: any) => console.warn('geolocation error', err),
{ {
enableHighAccuracy: opts.accuracy === 'high', enableHighAccuracy: opts.accuracy === 'high',
distanceFilter: opts.distanceFilter ?? 0, distanceFilter: opts.distanceFilter ?? 0,
interval: opts.interval interval: opts.interval,
} }
); );
watchIdRef.current = id as unknown as number; watchIdRef.current = id;
setIsTracking(true); setIsTracking(true);
}, [isTracking, onPosition, opts.accuracy, opts.distanceFilter, opts.interval]); }, [isTracking, onPosition, geo, opts.accuracy, opts.distanceFilter, opts.interval]);
const stopTracking = useCallback(() => { const stopTracking = useCallback(() => {
const id = watchIdRef.current; const id = watchIdRef.current;
if (id != null && global.navigator?.geolocation?.clearWatch) { if (id != null && geo && geo.clearWatch) {
global.navigator.geolocation.clearWatch(id); geo.clearWatch(id);
} }
watchIdRef.current = null; watchIdRef.current = null;
setIsTracking(false); setIsTracking(false);
}, []); }, [geo]);
const clear = useCallback(() => { const clear = useCallback(() => {
setPath([]); setPath([]);

View File

@@ -3,9 +3,11 @@ export type { MapHandle } from './MapView';
export { Marker, Polyline, PROVIDER_GOOGLE } from 'react-native-maps'; export { Marker, Polyline, PROVIDER_GOOGLE } from 'react-native-maps';
export { CameraPresets } from './cameraPresets'; export { CameraPresets } from './cameraPresets';
export { useLocationTracking } from './hooks/useLocationTracking'; export { useLocationTracking } from './hooks/useLocationTracking';
export type { TrackingOptions, GeolocationAPI } from './hooks/useLocationTracking';
// High-level components // High-level components
export { default as PropertyMap } from './components/PropertyMap'; export { default as PropertyMap } from './components/PropertyMap';
export type { PropertyMapProps } from './components/PropertyMap';
export { default as PriceMarker } from './components/PriceMarker'; export { default as PriceMarker } from './components/PriceMarker';
export { formatPrice } from './components/PriceMarker'; export { formatPrice } from './components/PriceMarker';
export type { PropertyItem } from './components/PriceMarker'; export type { PropertyItem, PriceMarkerProps } from './components/PriceMarker';