Enhance documentation and type definitions for location tracking and property components
This commit is contained in:
@@ -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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,32 +2,44 @@ 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 }}
|
*/
|
||||||
|
const PriceMarker: React.FC<PriceMarkerProps> = ({ item, selected, onPress }) => {
|
||||||
|
coordinate = {{ latitude: item.location.lat, longitude: item.location.lng }
|
||||||
|
}
|
||||||
onPress = {(e) => {
|
onPress = {(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onPress && onPress(item);
|
onPress && onPress(item);
|
||||||
|
|||||||
@@ -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,7 +220,8 @@ const PropertyMap: React.FC<PropertyMapProps> = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
</View >
|
</View >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user