Grapevine_Disease_Detection/VinEye/src/screens/MapScreen.tsx
Yanis d30f4f250c feat(map): rebuild Map screen on WebView+Leaflet with scan markers
Replace the Google-Maps-backed react-native-maps screen with a self-
contained WebView running Leaflet + Carto/OSM tiles. No API key, no
native compilation surface. The map is now driven by the real scan
history (useHistory) instead of mock parcels.

What's on the screen now:
- Markers for every ScanRecord that carries lat/lng, colored by
  status (healthy / infected / uncertain) derived from diseaseClass.
- Tapping a marker animates the camera and opens ScanDetail.
- Bottom sheet lists the same located scans with rename support: a
  pencil opens a modal-input that calls renameScan() to set
  ScanRecord.customName (empty value clears it). When the history is
  empty the sheet auto-snaps higher and shows a CTA to the Scanner.
- Region chips (Bordeaux/Bourgogne/Champagne) animate the camera and
  draw the actual department polygon as a dashed green outline. The
  GeoJSON is fetched on the React Native side (avoids the opaque
  origin CORS issue inside `source={{ html }}`) and cached in a
  useRef Map.
- "Ma position" filter + Locate FAB drop a circular green pin with a
  smiley SVG and a pulse halo at the user's GPS coords.
- FloatingActions and FloatingSearch tags restyled to match the
  Apple-inspired Bento spec (rounded-full FABs, 56x56, soft shadows,
  primary[900] active state).

VineyardMarker (orphan since markers are SVG inside the WebView) and
the data/mockScans.ts file were removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:32:14 +02:00

235 lines
7 KiB
TypeScript

import { useCallback, useMemo, useRef, useState } from "react";
import { View, StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import BottomSheet from "@gorhom/bottom-sheet";
import { LinearGradient } from "expo-linear-gradient";
import { toast } from "sonner-native";
import { useTranslation } from "react-i18next";
import { VineyardMapView, type VineyardMapHandle, type MapRegion } from "@/components/map/MapView";
import { FloatingSearch } from "@/components/map/FloatingSearch";
import { FloatingActions } from "@/components/map/FloatingActions";
import { MapBottomSheet } from "@/components/map/MapBottomSheet";
import { useHistory } from "@/hooks/useHistory";
import { useScanLocation } from "@/hooks/useScanLocation";
import { getRegionById } from "@/data/wineRegions";
import type { ScanRecord } from "@/types/detection";
import type { RootStackParamList } from "@/types/navigation";
type Nav = NativeStackNavigationProp<RootStackParamList>;
const DEFAULT_REGION: MapRegion = {
latitude: 44.8378,
longitude: -0.5792,
latitudeDelta: 0.12,
longitudeDelta: 0.12,
};
function hasLocation(scan: ScanRecord): scan is ScanRecord & { latitude: number; longitude: number } {
return typeof scan.latitude === "number" && typeof scan.longitude === "number";
}
function computeInitialRegion(scans: ScanRecord[]): MapRegion {
const located = scans.filter(hasLocation);
if (located.length === 0) return DEFAULT_REGION;
if (located.length === 1) {
return {
latitude: located[0].latitude,
longitude: located[0].longitude,
latitudeDelta: 0.04,
longitudeDelta: 0.04,
};
}
const lats = located.map((s) => s.latitude);
const lngs = located.map((s) => s.longitude);
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
return {
latitude: (minLat + maxLat) / 2,
longitude: (minLng + maxLng) / 2,
latitudeDelta: Math.max((maxLat - minLat) * 1.4, 0.04),
longitudeDelta: Math.max((maxLng - minLng) * 1.4, 0.04),
};
}
export default function MapScreen() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const navigation = useNavigation<Nav>();
const { history, renameScan, reload } = useHistory();
const { requestAndGetLocation } = useScanLocation();
const mapRef = useRef<VineyardMapHandle>(null);
const sheetRef = useRef<BottomSheet>(null);
const geojsonCache = useRef<Map<string, object>>(new Map());
const activeFilterRef = useRef<string | null>(null);
const [activeFilter, setActiveFilter] = useState<string | null>(null);
activeFilterRef.current = activeFilter;
useFocusEffect(
useCallback(() => {
reload();
}, [reload])
);
const locatedScans = useMemo(() => history.filter(hasLocation), [history]);
const initialRegion = useMemo(() => computeInitialRegion(history), [history]);
function handleScanPress(scan: ScanRecord) {
if (hasLocation(scan)) {
mapRef.current?.animateToRegion({
latitude: scan.latitude,
longitude: scan.longitude,
latitudeDelta: 0.04,
longitudeDelta: 0.04,
});
}
setActiveFilter(null);
mapRef.current?.highlightGeoJSON(null);
navigation.navigate("ScanDetail", { scanId: scan.id });
}
async function handleLocateUser() {
const coords = await requestAndGetLocation();
if (coords) {
mapRef.current?.animateToRegion({
latitude: coords.latitude,
longitude: coords.longitude,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
});
mapRef.current?.setUserLocation({
latitude: coords.latitude,
longitude: coords.longitude,
});
mapRef.current?.highlightGeoJSON(null);
setActiveFilter("myLocation");
} else {
toast.info(t("location.permissionDeniedTitle"), {
description: t("location.permissionDenied"),
});
}
}
function handleComingSoon() {
toast.info(t("map.comingSoon"));
}
async function fetchRegionGeoJSON(url: string): Promise<object | null> {
const cached = geojsonCache.current.get(url);
if (cached) return cached;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as object;
geojsonCache.current.set(url, json);
return json;
} catch (err) {
if (__DEV__) console.warn("[MapScreen] geojson fetch failed:", err);
return null;
}
}
async function handleFilterPress(id: string) {
if (id === "myLocation") {
handleLocateUser();
return;
}
const region = getRegionById(id);
if (!region) return;
setActiveFilter(id);
mapRef.current?.animateToRegion({
latitude: region.latitude,
longitude: region.longitude,
latitudeDelta: region.latitudeDelta,
longitudeDelta: region.longitudeDelta,
});
const geojson = await fetchRegionGeoJSON(region.geojsonUrl);
if (geojson && activeFilterRef.current === id) {
mapRef.current?.highlightGeoJSON(geojson);
} else if (!geojson) {
toast.error(t("map.regionLoadFailed"));
}
}
function handleRename(scanId: string, newName: string) {
renameScan(scanId, newName);
}
const isEmpty = locatedScans.length === 0;
return (
<View style={styles.root}>
<VineyardMapView
ref={mapRef}
scans={locatedScans}
initialRegion={initialRegion}
onScanPress={handleScanPress}
/>
<LinearGradient
colors={["rgba(255,255,255,0.95)", "rgba(255,255,255,0)"]}
style={[styles.topGradient, { height: insets.top + 140 }]}
pointerEvents="none"
/>
<View
style={[styles.searchSlot, { paddingTop: insets.top + 8 }]}
pointerEvents="box-none"
>
<FloatingSearch activeFilter={activeFilter} onFilterPress={handleFilterPress} />
</View>
<MapBottomSheet
ref={sheetRef}
scans={locatedScans}
onScanPress={handleScanPress}
onRename={handleRename}
onScanCta={() => navigation.navigate("Main", { screen: "Scanner" })}
defaultIndex={isEmpty ? 1 : 0}
/>
<View style={styles.actionsSlot} pointerEvents="box-none">
<FloatingActions
onLocate={handleLocateUser}
onLayers={handleComingSoon}
onSatellite={handleComingSoon}
activeAction={activeFilter === "myLocation" ? "locate" : undefined}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: "#F5F5F5",
},
topGradient: {
position: "absolute",
top: 0,
left: 0,
right: 0,
},
searchSlot: {
position: "absolute",
top: 0,
left: 0,
right: 0,
paddingHorizontal: 16,
},
actionsSlot: {
position: "absolute",
right: 16,
top: "30%",
zIndex: 10,
elevation: 10,
},
});