Implement custom camera with real-time overlay and resolution settings
This commit is contained in:
+111
-76
@@ -1,32 +1,12 @@
|
||||
/**
|
||||
* Mapa de features. Dibuja la geometría GeoJSON del proyecto (puntos, líneas,
|
||||
* polígonos) sobre Google Maps y permite seleccionar una feature tocándola.
|
||||
*
|
||||
* Nota: las tiles de Google Maps requieren conexión; la geometría sí se dibuja
|
||||
* sin red. Requiere una API key de Google Maps (ver app.config.js / README).
|
||||
* Mapa de features usando OpenStreetMap via Leaflet y WebView.
|
||||
* Elimina la dependencia de la API Key de Google Maps.
|
||||
*/
|
||||
import * as Location from 'expo-location';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import MapView, { Marker, Polygon, Polyline, PROVIDER_DEFAULT, PROVIDER_GOOGLE } from 'react-native-maps';
|
||||
|
||||
// Android usa Google Maps (requiere GOOGLE_MAPS_API_KEY).
|
||||
// iOS usa Apple Maps por defecto (sin key, PROVIDER_DEFAULT).
|
||||
const MAP_PROVIDER = Platform.OS === 'android' ? PROVIDER_GOOGLE : PROVIDER_DEFAULT;
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { Feature } from '../api/types';
|
||||
import { COLORS } from './components';
|
||||
import { geometryToShapes, LatLng, regionFor } from './geojson';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: '#9aa0a6',
|
||||
in_progress: '#1f6f43',
|
||||
completed: '#2e7d32',
|
||||
blocked: '#b00020',
|
||||
};
|
||||
|
||||
function colorFor(status?: string): string {
|
||||
return (status && STATUS_COLOR[status]) || COLORS.primary;
|
||||
}
|
||||
|
||||
export function FeatureMap({
|
||||
features,
|
||||
@@ -37,66 +17,121 @@ export function FeatureMap({
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number) => void;
|
||||
}) {
|
||||
const mapRef = useRef<MapView>(null);
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
|
||||
const { shaped, region } = useMemo(() => {
|
||||
const all: LatLng[] = [];
|
||||
const shaped = features.map((f) => {
|
||||
const shapes = geometryToShapes(f.geometry);
|
||||
all.push(...shapes.points, ...shapes.lines.flat(), ...shapes.polygons.flat());
|
||||
return { feature: f, shapes };
|
||||
});
|
||||
return { shaped, region: regionFor(all) };
|
||||
}, [features]);
|
||||
// Convertimos las features a un objeto GeoJSON simple para Leaflet
|
||||
const geoData = useMemo(() => {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: features.map((f) => ({
|
||||
type: 'Feature',
|
||||
id: f.id,
|
||||
geometry: typeof f.geometry === 'string' ? JSON.parse(f.geometry) : f.geometry,
|
||||
properties: {
|
||||
name: f.name,
|
||||
status: f.status,
|
||||
selected: f.id === selectedId,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}, [features, selectedId]);
|
||||
|
||||
const recenter = async () => {
|
||||
const perm = await Location.requestForegroundPermissionsAsync();
|
||||
if (!perm.granted) return;
|
||||
const pos = await Location.getCurrentPositionAsync({});
|
||||
mapRef.current?.animateToRegion({
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
latitudeDelta: 0.01,
|
||||
longitudeDelta: 0.01,
|
||||
});
|
||||
const htmlContent = useMemo(() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; }
|
||||
#map { height: 100vh; width: 100vw; background: #f0f0f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<script>
|
||||
const map = L.map('map', { zoomControl: false });
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(map);
|
||||
|
||||
const geoData = ${JSON.stringify(geoData)};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
pending: '#9aa0a6',
|
||||
in_progress: '#1f6f43',
|
||||
completed: '#2e7d32',
|
||||
blocked: '#b00020'
|
||||
};
|
||||
|
||||
const geoLayer = L.geoJSON(geoData, {
|
||||
style: (feature) => ({
|
||||
color: feature.properties.selected ? '#000' : (STATUS_COLORS[feature.properties.status] || '#1f6f43'),
|
||||
weight: feature.properties.selected ? 4 : 2,
|
||||
fillOpacity: 0.4,
|
||||
fillColor: STATUS_COLORS[feature.properties.status] || '#1f6f43'
|
||||
}),
|
||||
pointToLayer: (feature, latlng) => {
|
||||
return L.circleMarker(latlng, {
|
||||
radius: 8,
|
||||
fillColor: STATUS_COLORS[feature.properties.status] || '#1f6f43',
|
||||
color: feature.properties.selected ? '#000' : '#fff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
});
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
layer.on('click', () => {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'select', id: feature.id }));
|
||||
});
|
||||
}
|
||||
}).addTo(map);
|
||||
|
||||
if (geoData.features.length > 0) {
|
||||
try {
|
||||
map.fitBounds(geoLayer.getBounds(), { padding: [20, 20] });
|
||||
} catch(e) {
|
||||
map.setView([40.4167, -3.7037], 13);
|
||||
}
|
||||
} else {
|
||||
map.setView([40.4167, -3.7037], 13); // Madrid default
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`, [geoData]);
|
||||
|
||||
const onMessage = (event: any) => {
|
||||
try {
|
||||
const data = JSON.parse(event.nativeEvent.data);
|
||||
if (data.type === 'select') {
|
||||
onSelect(data.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error parsing WebView message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!region) {
|
||||
return (
|
||||
<View style={styles.empty}>
|
||||
<Text style={{ color: COLORS.muted }}>Las features no tienen geometría.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: '#f0f0f0', justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<Text style={{ color: COLORS.muted, textAlign: 'center' }}>
|
||||
Mapa desactivado temporalmente para pruebas.{"\n"}
|
||||
Requiere API Key de Google Maps válida.
|
||||
</Text>
|
||||
<View style={styles.container}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={['*']}
|
||||
source={{ html: htmlContent }}
|
||||
onMessage={onMessage}
|
||||
style={styles.map}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
||||
locBtn: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#fff',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
locIcon: { fontSize: 22, color: COLORS.primary },
|
||||
container: { flex: 1, backgroundColor: '#f0f0f0' },
|
||||
map: { flex: 1 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user