feat: offline map download via MapLibre OfflineManager
New src/services/offline.ts: - downloadRegion(): createPack with bounds, zoom 6-16, progress callback - listRegions() / deleteRegion(): pack management - expandBounds(): adds 5km buffer around the visible area - formatBytes(): human-readable size string RecordingScreen: - MapRef attached to Map component to read getBounds() - '↓ Offline' overlay button (Liberty style + idle state only) - Modal: name input → download → progress bar with % and MB counter - Raster styles show no download button (not supported by OfflineManager) Settings → App → Offline maps: - Lists all downloaded regions with size and tile count - Delete with confirm alert - Placeholder text when no regions exist
This commit is contained in:
@@ -1,23 +1,34 @@
|
|||||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, Modal, TextInput, Pressable } from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
|
||||||
import { Map, Camera, GeoJSONSource, Layer, UserLocation } from '@maplibre/maplibre-react-native';
|
import { Map, Camera, GeoJSONSource, Layer, UserLocation, type MapRef } from '@maplibre/maplibre-react-native';
|
||||||
import type { LineLayerStyle, CircleLayerStyle } from '@maplibre/maplibre-react-native';
|
import type { LineLayerStyle, CircleLayerStyle } from '@maplibre/maplibre-react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRecordingStore } from '../store/recording';
|
import { useRecordingStore } from '../store/recording';
|
||||||
import { startGpsRecording, stopGpsRecording, requestLocationPermissions, requestForegroundLocation } from '../services/gps';
|
import { startGpsRecording, stopGpsRecording, requestLocationPermissions, requestForegroundLocation } from '../services/gps';
|
||||||
import { RootStackParamList } from '../types';
|
import { RootStackParamList } from '../types';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
import { useTheme } from '../ThemeContext';
|
import { useTheme } from '../ThemeContext';
|
||||||
import { MAP_STYLES } from '../mapStyles';
|
import { MAP_STYLES } from '../mapStyles';
|
||||||
|
import { downloadRegion, formatBytes, expandBounds, OFFLINE_STYLE_URL } from '../services/offline';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export function RecordingScreen() {
|
export function RecordingScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
const nav = useNavigation<Nav>();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const { accent, accentDim, scale, boldLabels, mapOrientation, mapTileStyle } = useTheme();
|
const { accent, accentDim, scale, boldLabels, mapOrientation, mapTileStyle } = useTheme();
|
||||||
const mapStyle = MAP_STYLES[mapTileStyle].style;
|
const mapStyle = MAP_STYLES[mapTileStyle].style;
|
||||||
|
const mapRef = useRef<MapRef>(null);
|
||||||
|
|
||||||
|
// Offline download state
|
||||||
|
const [dlVisible, setDlVisible] = useState(false);
|
||||||
|
const [dlName, setDlName] = useState('');
|
||||||
|
const [dlProgress, setDlProgress] = useState<number | null>(null);
|
||||||
|
const [dlSize, setDlSize] = useState(0);
|
||||||
|
const isVectorStyle = mapTileStyle === 'liberty';
|
||||||
|
|
||||||
// Map orientation setting → MapLibre trackUserLocation value
|
// Map orientation setting → MapLibre trackUserLocation value
|
||||||
const trackUserLocation = mapOrientation === 'north' ? 'default'
|
const trackUserLocation = mapOrientation === 'north' ? 'default'
|
||||||
@@ -88,6 +99,28 @@ export function RecordingScreen() {
|
|||||||
async function handleResume() { resume(); await startGpsRecording(); }
|
async function handleResume() { resume(); await startGpsRecording(); }
|
||||||
async function handleStop() { await stopGpsRecording(); stop(); nav.navigate('PostRecording'); }
|
async function handleStop() { await stopGpsRecording(); stop(); nav.navigate('PostRecording'); }
|
||||||
|
|
||||||
|
async function handleDownloadConfirm() {
|
||||||
|
if (!dlName.trim()) { Alert.alert('Name required'); return; }
|
||||||
|
const bounds = await mapRef.current?.getBounds();
|
||||||
|
if (!bounds) { Alert.alert('Could not read map bounds'); return; }
|
||||||
|
const expanded = expandBounds(bounds, 5); // add 5 km buffer
|
||||||
|
setDlProgress(0);
|
||||||
|
try {
|
||||||
|
await downloadRegion(
|
||||||
|
dlName.trim(),
|
||||||
|
expanded,
|
||||||
|
(pct, _tiles, sizeBytes) => { setDlProgress(pct); setDlSize(sizeBytes); },
|
||||||
|
(msg) => Alert.alert('Download error', msg),
|
||||||
|
);
|
||||||
|
setDlVisible(false);
|
||||||
|
setDlProgress(null);
|
||||||
|
Alert.alert('Download complete', `"${dlName.trim()}" is now available offline.`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setDlProgress(null);
|
||||||
|
Alert.alert('Download failed', e?.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatDuration = (secs: number) => {
|
const formatDuration = (secs: number) => {
|
||||||
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
|
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
|
||||||
const m = Math.floor((secs % 3600) / 60).toString().padStart(2, '0');
|
const m = Math.floor((secs % 3600) / 60).toString().padStart(2, '0');
|
||||||
@@ -109,7 +142,7 @@ export function RecordingScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.mapArea}>
|
<View style={styles.mapArea}>
|
||||||
<Map mapStyle={mapStyle} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
<Map ref={mapRef} mapStyle={mapStyle} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
||||||
<Camera
|
<Camera
|
||||||
trackUserLocation={trackUserLocation}
|
trackUserLocation={trackUserLocation}
|
||||||
initialViewState={{ zoom: 14 }}
|
initialViewState={{ zoom: 14 }}
|
||||||
@@ -123,10 +156,61 @@ export function RecordingScreen() {
|
|||||||
</GeoJSONSource>
|
</GeoJSONSource>
|
||||||
)}
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
<TouchableOpacity style={[styles.sensorBtn, { borderColor: colors.border }]} onPress={() => nav.navigate('SensorPairing')}>
|
<View style={[styles.mapOverlay, { top: insets.top > 0 ? insets.top + 8 : 10 }]}>
|
||||||
<Text style={[styles.sensorBtnText, { color: accent }]}>⚡ Sensors</Text>
|
<TouchableOpacity style={[styles.overlayBtn, { borderColor: colors.border }]} onPress={() => nav.navigate('SensorPairing')}>
|
||||||
|
<Text style={[styles.overlayBtnText, { color: accent }]}>⚡ Sensors</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isVectorStyle && status === 'idle' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.overlayBtn, { borderColor: colors.border }]}
|
||||||
|
onPress={() => { setDlName(''); setDlVisible(true); }}
|
||||||
|
>
|
||||||
|
<Text style={[styles.overlayBtnText, { color: accent }]}>↓ Offline</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Offline download modal */}
|
||||||
|
<Modal visible={dlVisible} transparent animationType="fade" onRequestClose={() => dlProgress === null && setDlVisible(false)}>
|
||||||
|
<Pressable style={styles.modalOverlay} onPress={() => dlProgress === null && setDlVisible(false)}>
|
||||||
|
<Pressable style={styles.modalBox} onPress={() => {}}>
|
||||||
|
{dlProgress === null ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.modalHeading}>Download visible area</Text>
|
||||||
|
<Text style={styles.modalSub}>
|
||||||
|
Downloads the current map view (zoom 6–16) for offline use.{'\n'}
|
||||||
|
Only available with the Liberty vector style.
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.modalInput}
|
||||||
|
value={dlName}
|
||||||
|
onChangeText={setDlName}
|
||||||
|
placeholder="Region name (e.g. Alps 2026)"
|
||||||
|
placeholderTextColor={colors.placeholder}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity style={styles.modalBtn} onPress={() => setDlVisible(false)}>
|
||||||
|
<Text style={[styles.modalBtnText, { color: colors.textSub }]}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.modalBtn, { backgroundColor: accent }]} onPress={handleDownloadConfirm}>
|
||||||
|
<Text style={[styles.modalBtnText, { color: '#fff' }]}>Download</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.modalHeading}>Downloading…</Text>
|
||||||
|
<View style={styles.progressBarBg}>
|
||||||
|
<View style={[styles.progressBarFill, { width: `${dlProgress}%` as any, backgroundColor: accent }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalSub}>{dlProgress.toFixed(0)}% · {formatBytes(dlSize)} downloaded</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<View style={styles.controls}>
|
<View style={styles.controls}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -183,8 +267,20 @@ const styles = StyleSheet.create({
|
|||||||
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8, borderBottomWidth: 1, borderBottomColor: colors.border },
|
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8, borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||||
statBox: { width: '25%', padding: 8, alignItems: 'center' },
|
statBox: { width: '25%', padding: 8, alignItems: 'center' },
|
||||||
mapArea: { flex: 1, overflow: 'hidden' },
|
mapArea: { flex: 1, overflow: 'hidden' },
|
||||||
sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: 'rgba(9,9,11,0.8)', borderRadius: 8, borderWidth: 1, paddingVertical: 6, paddingHorizontal: 12 },
|
mapOverlay: { position: 'absolute', right: 10, flexDirection: 'column', gap: 8 },
|
||||||
sensorBtnText: { fontSize: 13, fontWeight: '600' },
|
overlayBtn: { backgroundColor: 'rgba(9,9,11,0.85)', borderRadius: 8, borderWidth: 1, paddingVertical: 6, paddingHorizontal: 12 },
|
||||||
|
overlayBtnText: { fontSize: 13, fontWeight: '600' },
|
||||||
|
// Download modal
|
||||||
|
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 32 },
|
||||||
|
modalBox: { backgroundColor: colors.surface, borderRadius: 14, padding: 20, gap: 14 },
|
||||||
|
modalHeading: { color: colors.text, fontSize: 17, fontWeight: '700' },
|
||||||
|
modalSub: { color: colors.textMuted, fontSize: 13, lineHeight: 18 },
|
||||||
|
modalInput: { backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 8, padding: 12, fontSize: 15 },
|
||||||
|
modalButtons: { flexDirection: 'row', gap: 10 },
|
||||||
|
modalBtn: { flex: 1, borderRadius: 8, padding: 12, alignItems: 'center', backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border },
|
||||||
|
modalBtnText: { fontSize: 15, fontWeight: '600' },
|
||||||
|
progressBarBg: { height: 8, backgroundColor: colors.border, borderRadius: 4, overflow: 'hidden' },
|
||||||
|
progressBarFill:{ height: '100%', borderRadius: 4 },
|
||||||
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20, borderTopWidth: 1, borderTopColor: colors.border },
|
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20, borderTopWidth: 1, borderTopColor: colors.border },
|
||||||
awakeBtn: { backgroundColor: colors.surface, borderRadius: 20, borderWidth: 1, borderColor: colors.border, paddingVertical: 8, paddingHorizontal: 14 },
|
awakeBtn: { backgroundColor: colors.surface, borderRadius: 20, borderWidth: 1, borderColor: colors.border, paddingVertical: 8, paddingHorizontal: 14 },
|
||||||
awakeBtnText: { color: colors.textSub, fontSize: 13 },
|
awakeBtnText: { color: colors.textSub, fontSize: 13 },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { login, logout, loadAuthState } from '../services/auth';
|
|||||||
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
|
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
|
||||||
import { useTheme, type MapOrientation } from '../ThemeContext';
|
import { useTheme, type MapOrientation } from '../ThemeContext';
|
||||||
import { MAP_STYLES, MAP_TILE_STYLE_ORDER } from '../mapStyles';
|
import { MAP_STYLES, MAP_TILE_STYLE_ORDER } from '../mapStyles';
|
||||||
|
import { listRegions, deleteRegion, formatBytes, type OfflineRegion } from '../services/offline';
|
||||||
|
|
||||||
type Tab = 'ui' | 'app' | 'sync';
|
type Tab = 'ui' | 'app' | 'sync';
|
||||||
|
|
||||||
@@ -122,11 +123,13 @@ const MAP_ORIENTATION_OPTIONS: { key: MapOrientation; label: string; sub: string
|
|||||||
function AppTab() {
|
function AppTab() {
|
||||||
const { accent, accentDim, mapOrientation, setMapOrientation } = useTheme();
|
const { accent, accentDim, mapOrientation, setMapOrientation } = useTheme();
|
||||||
const [kmNotifications, setKmNotifications] = useState(true);
|
const [kmNotifications, setKmNotifications] = useState(true);
|
||||||
|
const [regions, setRegions] = useState<OfflineRegion[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
AsyncStorage.getItem('kmNotifications').then((v) => {
|
AsyncStorage.getItem('kmNotifications').then((v) => {
|
||||||
if (v !== null) setKmNotifications(v === 'true');
|
if (v !== null) setKmNotifications(v === 'true');
|
||||||
});
|
});
|
||||||
|
listRegions().then(setRegions).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleKmToggle(value: boolean) {
|
async function handleKmToggle(value: boolean) {
|
||||||
@@ -134,6 +137,16 @@ function AppTab() {
|
|||||||
await AsyncStorage.setItem('kmNotifications', String(value));
|
await AsyncStorage.setItem('kmNotifications', String(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRegion(id: string, name: string) {
|
||||||
|
Alert.alert('Delete offline region?', `"${name}" will be removed from the device.`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Delete', style: 'destructive', onPress: async () => {
|
||||||
|
await deleteRegion(id);
|
||||||
|
setRegions((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
}},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.sectionTitle}>Map orientation</Text>
|
<Text style={styles.sectionTitle}>Map orientation</Text>
|
||||||
@@ -167,6 +180,32 @@ function AppTab() {
|
|||||||
thumbColor={colors.text}
|
thumbColor={colors.text}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>Offline maps</Text>
|
||||||
|
{regions.length === 0 ? (
|
||||||
|
<View style={[styles.row, { opacity: 0.5 }]}>
|
||||||
|
<Text style={styles.rowSub}>
|
||||||
|
No offline regions yet.{'\n'}
|
||||||
|
Tap "↓ Offline" on the map (Liberty style) to download an area.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
regions.map((r) => (
|
||||||
|
<View key={r.id} style={styles.row}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.rowLabel}>{r.name}</Text>
|
||||||
|
<Text style={styles.rowSub}>
|
||||||
|
{r.state === 'complete'
|
||||||
|
? `${formatBytes(r.totalSizeBytes)} · ${r.completedTiles} tiles`
|
||||||
|
: `${r.percentage.toFixed(0)}% downloading…`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => handleDeleteRegion(r.id, r.name)}>
|
||||||
|
<Text style={{ color: colors.error, fontSize: 13, fontWeight: '600' }}>Delete</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { OfflineManager, type OfflinePackStatus } from '@maplibre/maplibre-react-native';
|
||||||
|
|
||||||
|
type LngLatBounds = [number, number, number, number]; // [west, south, east, north]
|
||||||
|
|
||||||
|
// Vector style URL — only vector styles support offline packs
|
||||||
|
export const OFFLINE_STYLE_URL = 'https://tiles.openfreemap.org/styles/liberty';
|
||||||
|
|
||||||
|
// Zoom levels: 6 (region overview) → 16 (street detail, good for cycling)
|
||||||
|
const MIN_ZOOM = 6;
|
||||||
|
const MAX_ZOOM = 16;
|
||||||
|
|
||||||
|
export interface OfflineRegion {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
bounds: LngLatBounds;
|
||||||
|
createdAt: string;
|
||||||
|
completedTiles: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
state: 'active' | 'inactive' | 'complete';
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Download ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function downloadRegion(
|
||||||
|
name: string,
|
||||||
|
bounds: LngLatBounds,
|
||||||
|
onProgress: (pct: number, tilesDown: number, sizeBytes: number) => void,
|
||||||
|
onError: (msg: string) => void,
|
||||||
|
): Promise<string> {
|
||||||
|
OfflineManager.setProgressEventThrottle(500);
|
||||||
|
|
||||||
|
const pack = await OfflineManager.createPack(
|
||||||
|
{
|
||||||
|
mapStyle: OFFLINE_STYLE_URL,
|
||||||
|
bounds,
|
||||||
|
minZoom: MIN_ZOOM,
|
||||||
|
maxZoom: MAX_ZOOM,
|
||||||
|
metadata: { name, createdAt: new Date().toISOString() },
|
||||||
|
},
|
||||||
|
(_pack, status: OfflinePackStatus) => {
|
||||||
|
onProgress(status.percentage, status.completedTileCount, status.completedTileSize);
|
||||||
|
},
|
||||||
|
(_pack, error) => {
|
||||||
|
onError(error.message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return pack.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listRegions(): Promise<OfflineRegion[]> {
|
||||||
|
const packs = await OfflineManager.getPacks();
|
||||||
|
const regions: OfflineRegion[] = [];
|
||||||
|
|
||||||
|
for (const pack of packs) {
|
||||||
|
const status = await pack.status();
|
||||||
|
const meta = pack.metadata as { name?: string; createdAt?: string };
|
||||||
|
regions.push({
|
||||||
|
id: pack.id,
|
||||||
|
name: meta.name ?? 'Unnamed region',
|
||||||
|
bounds: pack.bounds,
|
||||||
|
createdAt: meta.createdAt ?? '',
|
||||||
|
completedTiles: status.completedTileCount,
|
||||||
|
totalSizeBytes: status.completedTileSize,
|
||||||
|
state: status.state,
|
||||||
|
percentage: status.percentage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function deleteRegion(id: string): Promise<void> {
|
||||||
|
await OfflineManager.deletePack(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand a [w,s,e,n] bounds by `km` kilometres in every direction. */
|
||||||
|
export function expandBounds(bounds: LngLatBounds, km: number): LngLatBounds {
|
||||||
|
const latDelta = km / 111;
|
||||||
|
const lonDelta = km / (111 * Math.cos((bounds[1] + bounds[3]) / 2 * Math.PI / 180));
|
||||||
|
return [
|
||||||
|
bounds[0] - lonDelta,
|
||||||
|
bounds[1] - latDelta,
|
||||||
|
bounds[2] + lonDelta,
|
||||||
|
bounds[3] + latDelta,
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user