thêm zustand để cấu hình global state, hook để lấy platform, thêm polyline và polygon b vào map

This commit is contained in:
Tran Anh Tuan
2025-10-31 19:54:16 +07:00
parent 2fac0b8093
commit 5801992eae
19 changed files with 1202 additions and 89 deletions

View File

@@ -1,33 +1,70 @@
import { PolygonWithLabel } from "@/components/map/PolygonWithLabel";
import { PolylineWithLabel } from "@/components/map/PolylineWithLabel";
import { showToastError } from "@/config"; import { showToastError } from "@/config";
import {
AUTO_REFRESH_INTERVAL,
ENTITY,
IOS_PLATFORM,
LIGHT_THEME,
} from "@/constants";
import { import {
queryAlarm, queryAlarm,
queryEntities,
queryGpsData, queryGpsData,
queryTrackPoints, queryTrackPoints,
} from "@/controller/DeviceController"; } from "@/controller/DeviceController";
import { useColorScheme } from "@/hooks/use-color-scheme.web";
import { usePlatform } from "@/hooks/use-platform";
import { getShipIcon } from "@/services/map_service"; import { getShipIcon } from "@/services/map_service";
import { useBanzones } from "@/state/use-banzones";
import {
convertWKTLineStringToLatLngArray,
convertWKTtoLatLngString,
} from "@/utils/geom";
import { Image as ExpoImage } from "expo-image"; import { Image as ExpoImage } from "expo-image";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import MapView, { Circle, Marker } from "react-native-maps"; import MapView, { Circle, Marker } from "react-native-maps";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
const testPolyline =
"MULTIPOLYGON(((108.7976074 17.5392966,110.390625 14.2217886,109.4677734 10.8548863,112.9227161 10.6933337,116.4383411 12.565622,116.8997669 17.0466095,109.8685169 17.8013229,108.7973446 17.5393669,108.7976074 17.5392966)))";
export default function HomeScreen() { export default function HomeScreen() {
const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null); const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null);
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null); const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
const [trackPoints, setTrackPoints] = useState<Model.ShipTrackPoint[] | null>( const [trackPoints, setTrackPoints] = useState<Model.ShipTrackPoint[] | null>(
null null
); );
const [circleRadius, setCircleRadius] = useState(100);
const [zoomLevel, setZoomLevel] = useState(10);
const [isFirstLoad, setIsFirstLoad] = useState(true);
const [polylineCoordinates, setPolylineCoordinates] = useState<
number[][] | null
>(null);
const [polygonCoordinates, setPolygonCoordinates] = useState<
number[][][] | null
>(null);
const [, setZoneGeometries] = useState<Map<string, any>>(new Map());
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const platform = usePlatform();
const theme = useColorScheme();
const { banzones, getBanzone } = useBanzones();
const banzonesRef = useRef(banzones);
// console.log("Platform: ", platform);
// console.log("Theme: ", theme);
const getGpsData = async () => { const getGpsData = async () => {
try { try {
const response = await queryGpsData(); const response = await queryGpsData();
console.log("GpsData: ", response.data); // console.log("GpsData: ", response.data);
console.log( // console.log(
"Heading value:", // "Heading value:",
response.data?.h, // response.data?.h,
"Type:", // "Type:",
typeof response.data?.h // typeof response.data?.h
); // );
setGpsData(response.data); setGpsData(response.data);
} catch (error) { } catch (error) {
console.error("Error fetching GPS data:", error); console.error("Error fetching GPS data:", error);
@@ -35,10 +72,84 @@ export default function HomeScreen() {
} }
}; };
const drawPolyline = () => {
const data = convertWKTtoLatLngString(testPolyline);
console.log("Data: ", data);
// setPolygonCoordinates(data[0]);
// ;
// console.log("Banzones: ", banzones.length);
};
useEffect(() => {
banzonesRef.current = banzones;
}, [banzones]);
const areGeometriesEqual = (
left?: {
geom_type: number;
geom_lines?: string | null;
geom_poly?: string | null;
},
right?: {
geom_type: number;
geom_lines?: string | null;
geom_poly?: string | null;
}
) => {
if (!left && !right) {
return true;
}
if (!left || !right) {
return false;
}
return (
left.geom_type === right.geom_type &&
(left.geom_lines || "") === (right.geom_lines || "") &&
(left.geom_poly || "") === (right.geom_poly || "")
);
};
const areCoordinatesEqual = (
current: number[][] | null,
next: number[][] | null
) => {
if (!current || !next || current.length !== next.length) {
return false;
}
return current.every(
(coord, index) =>
coord[0] === next[index][0] && coord[1] === next[index][1]
);
};
const areMultiPolygonCoordinatesEqual = (
current: number[][][] | null,
next: number[][][] | null
) => {
if (!current || !next || current.length !== next.length) {
return false;
}
return current.every((polygon, polyIndex) => {
const nextPolygon = next[polyIndex];
if (!nextPolygon || polygon.length !== nextPolygon.length) {
return false;
}
return polygon.every(
(coord, coordIndex) =>
coord[0] === nextPolygon[coordIndex][0] &&
coord[1] === nextPolygon[coordIndex][1]
);
});
};
const getAlarmData = async () => { const getAlarmData = async () => {
try { try {
const response = await queryAlarm(); const response = await queryAlarm();
console.log("AlarmData: ", response.data); // console.log("AlarmData: ", response.data);
setAlarmData(response.data); setAlarmData(response.data);
} catch (error) { } catch (error) {
console.error("Error fetching Alarm Data: ", error); console.error("Error fetching Alarm Data: ", error);
@@ -46,13 +157,160 @@ export default function HomeScreen() {
} }
}; };
const getEntities = async () => {
try {
const entities = await queryEntities();
if (!entities) {
// Clear tất cả khu vực khi không có dữ liệu
setPolylineCoordinates(null);
setPolygonCoordinates(null);
setZoneGeometries(new Map());
return;
}
const currentBanzones = banzonesRef.current || [];
let nextPolyline: number[][] | null = null;
let nextMultiPolygon: number[][][] | null = null;
let foundPolyline = false;
let foundPolygon = false;
// Process zones để tìm geometries
for (const entity of entities) {
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
continue;
}
let zones: any[] = [];
try {
zones = entity.valueString ? JSON.parse(entity.valueString) : [];
} catch (parseError) {
console.error("Error parsing zone list:", parseError);
continue;
}
// Nếu danh sách zone rỗng, clear tất cả
if (zones.length === 0) {
setPolylineCoordinates(null);
setPolygonCoordinates(null);
setZoneGeometries(new Map());
return;
}
for (const zone of zones) {
const geom = currentBanzones.find((b) => b.id === zone.zone_id);
if (!geom) {
continue;
}
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
if (typeof geom_type !== "number") {
continue;
}
if (geom_type === 2) {
foundPolyline = true;
const coordinates = convertWKTLineStringToLatLngArray(
geom_lines || ""
);
if (coordinates.length > 0) {
nextPolyline = coordinates;
}
} else if (geom_type === 1) {
foundPolygon = true;
const coordinates = convertWKTtoLatLngString(geom_poly || "");
if (coordinates.length > 0) {
console.log("Polygon Coordinate: ", coordinates);
nextMultiPolygon = coordinates;
}
}
}
}
// Update state sau khi đã process xong
setZoneGeometries((prevGeometries) => {
const updated = new Map(prevGeometries);
let hasChanges = false;
for (const entity of entities) {
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
continue;
}
let zones: any[] = [];
try {
zones = entity.valueString ? JSON.parse(entity.valueString) : [];
} catch {
continue;
}
if (zones.length === 0) {
if (updated.size > 0) {
hasChanges = true;
updated.clear();
}
break;
}
for (const zone of zones) {
const geom = currentBanzones.find((b) => b.id === zone.zone_id);
if (!geom) {
continue;
}
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
if (typeof geom_type !== "number") {
continue;
}
const key = `${zone.zone_id}_${geom_type}`;
const newGeomData = { geom_type, geom_lines, geom_poly };
const oldGeom = updated.get(key);
if (!areGeometriesEqual(oldGeom, newGeomData)) {
hasChanges = true;
updated.set(key, newGeomData);
console.log("Geometry changed", { key, oldGeom, newGeomData });
}
}
}
return hasChanges ? updated : prevGeometries;
});
// Cập nhật hoặc clear polyline
if (foundPolyline && nextPolyline) {
setPolylineCoordinates((prev) =>
areCoordinatesEqual(prev, nextPolyline) ? prev : nextPolyline
);
} else if (!foundPolyline) {
console.log("Hết cảnh báo qua polyline");
setPolylineCoordinates(null);
}
// Cập nhật hoặc clear polygon
if (foundPolygon && nextMultiPolygon) {
setPolygonCoordinates((prev) =>
areMultiPolygonCoordinatesEqual(prev, nextMultiPolygon)
? prev
: nextMultiPolygon
);
} else if (!foundPolygon) {
console.log("Hết cảnh báo qua polygon");
setPolygonCoordinates(null);
}
} catch (error) {
console.error("Error fetching Entities: ", error);
// Clear tất cả khi có lỗi
setPolylineCoordinates(null);
setPolygonCoordinates(null);
setZoneGeometries(new Map());
}
};
const getShipTrackPoints = async () => { const getShipTrackPoints = async () => {
try { try {
const response = await queryTrackPoints(); const response = await queryTrackPoints();
console.log( // console.log("TrackPoints Data Length: ", response.data.length);
"TrackPoints Data: ",
response.data[response.data.length - 1]
);
setTrackPoints(response.data); setTrackPoints(response.data);
} catch (error) { } catch (error) {
console.error("Error fetching TrackPoints Data: ", error); console.error("Error fetching TrackPoints Data: ", error);
@@ -60,75 +318,113 @@ export default function HomeScreen() {
} }
}; };
const handleMapReady = () => { const fetchAllData = async () => {
console.log("Map loaded successfully!"); await Promise.all([
getGpsData(); getGpsData(),
getAlarmData(); getAlarmData(),
getShipTrackPoints(); getShipTrackPoints(),
getEntities(),
]);
}; };
// Tính toán region để bao phủ cả GPS và track points const setupAutoRefresh = () => {
// Clear existing interval if any
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
// Set new interval to refresh data every 5 seconds
// Không fetch banzones vì dữ liệu không thay đổi
intervalRef.current = setInterval(async () => {
// console.log("Auto-refreshing data...");
await fetchAllData();
}, AUTO_REFRESH_INTERVAL);
};
const handleMapReady = () => {
// console.log("Map loaded successfully!");
// Gọi fetchAllData ngay lập tức (không cần đợi banzones)
fetchAllData();
setupAutoRefresh();
// Set isFirstLoad to false sau khi map ready để chỉ zoom lần đầu tiên
setTimeout(() => {
setIsFirstLoad(false);
}, 2000);
};
// Cleanup interval on component unmount
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
useEffect(() => {
getBanzone();
}, [getBanzone]);
// Hàm tính radius cố định khi zoom change
const calculateRadiusFromZoom = (zoom: number) => {
const baseZoom = 10;
const baseRadius = 100;
const zoomDifference = baseZoom - zoom;
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
// console.log("Caculate Radius: ", calculatedRadius);
return Math.max(calculatedRadius, 50);
};
// Xử lý khi region (zoom) thay đổi
const handleRegionChangeComplete = (newRegion: any) => {
// Tính zoom level từ latitudeDelta
// zoom = log2(360 / (latitudeDelta * 2)) + 8
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
const newRadius = calculateRadiusFromZoom(zoom);
setCircleRadius(newRadius);
setZoomLevel(zoom);
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
};
// Tính toán region để focus vào marker của tàu (chỉ lần đầu tiên)
const getMapRegion = () => { const getMapRegion = () => {
if (!gpsData && (!trackPoints || trackPoints.length === 0)) { if (!isFirstLoad) {
// Sau lần đầu, return undefined để không force region
return undefined;
}
if (!gpsData) {
return { return {
latitude: 15.70581, latitude: 15.70581,
longitude: 116.152685, longitude: 116.152685,
latitudeDelta: 2, latitudeDelta: 0.05,
longitudeDelta: 2, longitudeDelta: 0.05,
}; };
} }
let minLat = gpsData?.lat ?? 90;
let maxLat = gpsData?.lat ?? -90;
let minLon = gpsData?.lon ?? 180;
let maxLon = gpsData?.lon ?? -180;
// Bao gồm track points
if (trackPoints) {
trackPoints.forEach((point) => {
minLat = Math.min(minLat, point.lat);
maxLat = Math.max(maxLat, point.lat);
minLon = Math.min(minLon, point.lon);
maxLon = Math.max(maxLon, point.lon);
});
}
const latDelta = Math.max(maxLat - minLat, 0.01) * 1.2; // Padding 20%
const lonDelta = Math.max(maxLon - minLon, 0.01) * 1.2;
console.log("Map region:", {
minLat,
maxLat,
minLon,
maxLon,
latDelta,
lonDelta,
});
return { return {
latitude: (minLat + maxLat) / 2, latitude: gpsData.lat,
longitude: (minLon + maxLon) / 2, longitude: gpsData.lon,
latitudeDelta: latDelta, latitudeDelta: 0.05,
longitudeDelta: lonDelta, longitudeDelta: 0.05,
}; };
}; };
return ( return (
<SafeAreaProvider style={styles.container}> <SafeAreaProvider style={styles.container}>
{banzones.length > 0 && (
<Text className="hidden">Banzones loaded: {banzones.length}</Text>
)}
<MapView <MapView
onMapReady={handleMapReady} onMapReady={handleMapReady}
onPoiClick={(point) => { onPoiClick={(point) => {
console.log("Poi clicked: ", point.nativeEvent); console.log("Poi clicked: ", point.nativeEvent);
}} }}
onRegionChangeComplete={handleRegionChangeComplete}
style={styles.map} style={styles.map}
initialRegion={{ // initialRegion={getMapRegion()}
latitude: 15.70581,
longitude: 116.152685,
latitudeDelta: 2,
longitudeDelta: 2,
}}
region={getMapRegion()} region={getMapRegion()}
// userInterfaceStyle="dark" userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
showsBuildings={false} showsBuildings={false}
showsIndoors={false} showsIndoors={false}
loadingEnabled={true} loadingEnabled={true}
@@ -146,46 +442,90 @@ export default function HomeScreen() {
longitude: point.lon, longitude: point.lon,
}} }}
zIndex={50} zIndex={50}
radius={200} // Tăng từ 50 → 1000m radius={circleRadius}
fillColor="rgba(241, 12, 65, 0.8)" // Tăng opacity từ 0.06 → 0.8 fillColor="rgba(16, 85, 201, 0.6)"
strokeColor="rgba(221, 240, 15, 0.8)" strokeColor="rgba(16, 85, 201, 0.8)"
strokeWidth={2} strokeWidth={2}
/> />
); );
})} })}
{polylineCoordinates && (
<PolylineWithLabel
coordinates={polylineCoordinates.map((coord) => ({
latitude: coord[0],
longitude: coord[1],
}))}
label="Tuyến bờ"
strokeColor="#FF5733"
strokeWidth={4}
showDistance={false}
zIndex={50}
/>
)}
{polygonCoordinates && polygonCoordinates.length > 0 && (
<>
{polygonCoordinates.map((polygon, index) => {
// Tạo key ổn định từ tọa độ đầu tiên của polygon
const polygonKey =
polygon.length > 0
? `polygon-${polygon[0][0]}-${polygon[0][1]}-${index}`
: `polygon-${index}`;
return (
<PolygonWithLabel
key={polygonKey}
coordinates={polygon.map((coords) => ({
latitude: coords[0],
longitude: coords[1],
}))}
label="Test khu đánh bắt"
content="Thời gian cấm (từ tháng 1 đến tháng 12)"
fillColor="rgba(16, 85, 201, 0.6)"
strokeColor="rgba(16, 85, 201, 0.8)"
strokeWidth={2}
zIndex={50}
zoomLevel={zoomLevel}
/>
);
})}
</>
)}
{gpsData && ( {gpsData && (
<Marker <Marker
coordinate={{ coordinate={{
latitude: gpsData.lat, latitude: gpsData.lat,
longitude: gpsData.lon, longitude: gpsData.lon,
}} }}
title="Tàu của mình" title={
platform === IOS_PLATFORM
? "Tàu của mình - iOS"
: "Tàu của mình - Android"
}
zIndex={100} zIndex={100}
anchor={{ x: 0.5, y: 0.5 }}
> >
<View <View className="w-8 h-8 items-center justify-center">
style={{
transform: [
{
rotate: `${
typeof gpsData.h === "number" && !isNaN(gpsData.h)
? gpsData.h
: 0
}deg`,
},
],
alignItems: "center",
justifyContent: "center",
}}
>
<ExpoImage <ExpoImage
source={getShipIcon(alarmData?.level || 0, gpsData.fishing)} source={getShipIcon(alarmData?.level || 0, gpsData.fishing)}
style={{ width: 32, height: 32 }} style={{
width: 32,
height: 32,
transform: [
{
rotate: `${
typeof gpsData.h === "number" && !isNaN(gpsData.h)
? gpsData.h
: 0
}deg`,
},
],
}}
/> />
</View> </View>
</Marker> </Marker>
)} )}
</MapView> </MapView>
<TouchableOpacity style={styles.button} onPress={handleMapReady}> <TouchableOpacity style={styles.button} onPress={drawPolyline}>
<Text style={styles.buttonText}>Get GPS Data</Text> <Text style={styles.buttonText}>Get GPS Data</Text>
</TouchableOpacity> </TouchableOpacity>
</SafeAreaProvider> </SafeAreaProvider>
@@ -200,6 +540,7 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
button: { button: {
display: "none",
position: "absolute", position: "absolute",
top: 50, top: 50,
right: 20, right: 20,

14
assets.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
declare module "*.png" {
const content: string;
export default content;
}
declare module "*.jpg" {
const content: string;
export default content;
}
declare module "*.svg" {
const content: string;
export default content;
}

View File

@@ -0,0 +1,165 @@
import { getPolygonCenter } from "@/utils/polyline";
import React, { memo } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Marker, Polygon } from "react-native-maps";
export interface PolygonWithLabelProps {
coordinates: {
latitude: number;
longitude: number;
}[];
label?: string;
content?: string;
fillColor?: string;
strokeColor?: string;
strokeWidth?: number;
zIndex?: number;
zoomLevel?: number;
}
/**
* Component render Polygon kèm Label/Text ở giữa
*/
const PolygonWithLabelComponent: React.FC<PolygonWithLabelProps> = ({
coordinates,
label,
content,
fillColor = "rgba(16, 85, 201, 0.6)",
strokeColor = "rgba(16, 85, 201, 0.8)",
strokeWidth = 2,
zIndex = 50,
zoomLevel = 10,
}) => {
if (!coordinates || coordinates.length < 3) {
return null;
}
const centerPoint = getPolygonCenter(coordinates);
// Tính font size dựa trên zoom level
// Zoom càng thấp (xa ra) thì font size càng nhỏ
const calculateFontSize = (baseSize: number) => {
const baseZoom = 10;
// Giảm scale factor để text không quá to khi zoom out
const scaleFactor = Math.pow(2, (zoomLevel - baseZoom) * 0.3);
return Math.max(baseSize * scaleFactor, 5); // Tối thiểu 5px
};
const labelFontSize = calculateFontSize(12);
const contentFontSize = calculateFontSize(10);
console.log("zoom level: ", zoomLevel);
const paddingScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.2), 0.5);
const minWidthScale = Math.max(Math.pow(2, (zoomLevel - 10) * 0.25), 0.9);
console.log("Min Width Scale: ", minWidthScale);
return (
<>
<Polygon
coordinates={coordinates}
fillColor={fillColor}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
zIndex={zIndex}
/>
{label && (
<Marker
coordinate={centerPoint}
zIndex={200}
tracksViewChanges={false}
anchor={{ x: 0.5, y: 0.5 }}
>
<View style={styles.markerContainer}>
<View
style={[
styles.labelContainer,
{
paddingHorizontal: 5 * paddingScale,
paddingVertical: 5 * paddingScale,
minWidth: 80,
maxWidth: 150 * minWidthScale,
},
]}
>
<Text
style={[styles.labelText, { fontSize: labelFontSize }]}
numberOfLines={2}
>
{label}
</Text>
{content && (
<Text
style={[
styles.contentText,
{ fontSize: contentFontSize, marginTop: 2 * paddingScale },
]}
numberOfLines={2}
>
{content}
</Text>
)}
</View>
</View>
</Marker>
)}
</>
);
};
const styles = StyleSheet.create({
markerContainer: {
alignItems: "center",
justifyContent: "center",
},
labelContainer: {
backgroundColor: "rgba(16, 85, 201, 0.95)",
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 18,
borderWidth: 2,
borderColor: "#fff",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 5,
elevation: 8,
minWidth: 80,
maxWidth: 250,
},
labelText: {
color: "#fff",
fontSize: 14,
fontWeight: "bold",
letterSpacing: 0.3,
textAlign: "center",
},
contentText: {
color: "#fff",
fontSize: 11,
fontWeight: "600",
letterSpacing: 0.2,
textAlign: "center",
opacity: 0.95,
},
});
// Export memoized component để tránh re-render không cần thiết
export const PolygonWithLabel = memo(
PolygonWithLabelComponent,
(prev, next) => {
// Custom comparison: chỉ re-render khi coordinates, label, content hoặc zoomLevel thay đổi
return (
prev.coordinates.length === next.coordinates.length &&
prev.coordinates.every(
(coord, index) =>
coord.latitude === next.coordinates[index]?.latitude &&
coord.longitude === next.coordinates[index]?.longitude
) &&
prev.label === next.label &&
prev.content === next.content &&
prev.zoomLevel === next.zoomLevel &&
prev.fillColor === next.fillColor &&
prev.strokeColor === next.strokeColor
);
}
);

View File

@@ -0,0 +1,124 @@
import {
calculateTotalDistance,
getMiddlePointOfPolyline,
} from "@/utils/polyline";
import React, { memo } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Marker, Polyline } from "react-native-maps";
export interface PolylineWithLabelProps {
coordinates: {
latitude: number;
longitude: number;
}[];
label?: string;
strokeColor?: string;
strokeWidth?: number;
showDistance?: boolean;
zIndex?: number;
}
/**
* Component render Polyline kèm Label/Text ở giữa
*/
const PolylineWithLabelComponent: React.FC<PolylineWithLabelProps> = ({
coordinates,
label,
strokeColor = "#FF5733",
strokeWidth = 4,
showDistance = false,
zIndex = 50,
}) => {
if (!coordinates || coordinates.length < 2) {
return null;
}
const middlePoint = getMiddlePointOfPolyline(coordinates);
const distance = calculateTotalDistance(coordinates);
let displayText = label || "";
if (showDistance) {
displayText += displayText
? ` (${distance.toFixed(2)}km)`
: `${distance.toFixed(2)}km`;
}
return (
<>
<Polyline
coordinates={coordinates}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
zIndex={zIndex}
/>
{displayText && (
<Marker
coordinate={middlePoint}
zIndex={zIndex + 10}
tracksViewChanges={false}
anchor={{ x: 0.5, y: 0.5 }}
>
<View style={styles.markerContainer}>
<View style={styles.labelContainer}>
<Text
style={styles.labelText}
numberOfLines={2}
adjustsFontSizeToFit
>
{displayText}
</Text>
</View>
</View>
</Marker>
)}
</>
);
};
const styles = StyleSheet.create({
markerContainer: {
alignItems: "center",
justifyContent: "center",
},
labelContainer: {
backgroundColor: "rgba(255, 87, 51, 0.95)",
paddingHorizontal: 5,
paddingVertical: 5,
borderRadius: 18,
borderWidth: 1,
borderColor: "#fff",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 5,
elevation: 8,
minWidth: 80,
maxWidth: 180,
},
labelText: {
color: "#fff",
fontSize: 14,
fontWeight: "bold",
letterSpacing: 0.3,
textAlign: "center",
},
});
// Export memoized component để tránh re-render không cần thiết
export const PolylineWithLabel = memo(
PolylineWithLabelComponent,
(prev, next) => {
// Custom comparison: chỉ re-render khi coordinates, label hoặc showDistance thay đổi
return (
prev.coordinates.length === next.coordinates.length &&
prev.coordinates.every(
(coord, index) =>
coord.latitude === next.coordinates[index]?.latitude &&
coord.longitude === next.coordinates[index]?.longitude
) &&
prev.label === next.label &&
prev.showDistance === next.showDistance &&
prev.strokeColor === next.strokeColor
);
}
);

View File

@@ -24,7 +24,7 @@ const codeMessage = {
// Tạo instance axios với cấu hình cơ bản // Tạo instance axios với cấu hình cơ bản
const api: AxiosInstance = axios.create({ const api: AxiosInstance = axios.create({
baseURL: "http://192.168.30.102:81", // Thay bằng API thật của bạn baseURL: "http://192.168.30.102:81", // Thay bằng API thật của bạn
timeout: 10000, // Timeout 10 giây timeout: 20000, // Timeout 20 giây
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },

View File

@@ -5,12 +5,23 @@ export const MAP_POLYLINE_BAN = "ban-polyline";
export const MAP_POLYGON_BAN = "ban-polygon"; export const MAP_POLYGON_BAN = "ban-polygon";
// Global Constants // Global Constants
export const IOS_PLATFORM = "ios";
export const ANDROID_PLATFORM = "android";
export const WEB_PLATFORM = "web";
export const AUTO_REFRESH_INTERVAL = 5000; // in milliseconds
export const LIGHT_THEME = "light";
export const DARK_THEME = "dark";
// Route Constants // Route Constants
export const ROUTE_LOGIN = "/login"; export const ROUTE_LOGIN = "/login";
export const ROUTE_HOME = "/map"; export const ROUTE_HOME = "/map";
export const ROUTE_TRIP = "/trip"; export const ROUTE_TRIP = "/trip";
// Entity Contants
export const ENTITY = {
ZONE_ALARM_LIST: "50:2",
GPS: "50:1",
};
// API Path Constants // API Path Constants
export const API_PATH_LOGIN = "/api/agent/login"; export const API_PATH_LOGIN = "/api/agent/login";
export const API_PATH_ENTITIES = "/api/io/entities"; export const API_PATH_ENTITIES = "/api/io/entities";

View File

@@ -2,8 +2,10 @@ import { api } from "@/config";
import { import {
API_GET_ALARMS, API_GET_ALARMS,
API_GET_GPS, API_GET_GPS,
API_PATH_ENTITIES,
API_PATH_SHIP_TRACK_POINTS, API_PATH_SHIP_TRACK_POINTS,
} from "@/constants"; } from "@/constants";
import { transformEntityResponse } from "@/utils/tranform";
export async function queryGpsData() { export async function queryGpsData() {
return api.get<Model.GPSResonse>(API_GET_GPS); return api.get<Model.GPSResonse>(API_GET_GPS);
@@ -16,3 +18,8 @@ export async function queryAlarm() {
export async function queryTrackPoints() { export async function queryTrackPoints() {
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS); return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
} }
export async function queryEntities(): Promise<Model.TransformedEntity[]> {
const response = await api.get<Model.EntityResponse[]>(API_PATH_ENTITIES);
return response.data.map(transformEntityResponse);
}

View File

@@ -0,0 +1,6 @@
import { api } from "@/config";
import { API_GET_ALL_BANZONES } from "@/constants";
export async function queryBanzones() {
return api.get<Model.Zone[]>(API_GET_ALL_BANZONES);
}

View File

@@ -1,3 +1,4 @@
import * as AuthController from "./AuthController"; import * as AuthController from "./AuthController";
import * as DeviceController from "./DeviceController";
export { AuthController }; import * as MapController from "./MapController";
export { AuthController, DeviceController, MapController };

View File

@@ -33,4 +33,51 @@ declare namespace Model {
s: number; s: number;
h: number; h: number;
} }
interface EntityResponse {
id: string;
v: number;
vs: string;
t: number;
type: string;
}
interface TransformedEntity {
id: string;
value: number;
valueString: string;
time: number;
type: string;
}
// Banzones
// Banzone
export interface Zone {
id?: string;
name?: string;
type?: number;
conditions?: Condition[];
enabled?: boolean;
updated_at?: Date;
geom?: Geom;
}
export interface Condition {
max?: number;
min?: number;
type?: Type;
to?: number;
from?: number;
}
export enum Type {
LengthLimit = "length_limit",
MonthRange = "month_range",
}
export interface Geom {
geom_type?: number;
geom_poly?: string;
geom_lines?: string;
geom_point?: string;
geom_radius?: number;
}
} }

View File

@@ -0,0 +1,62 @@
import { useCallback, useState } from "react";
/**
* Hook để tính radius cố định cho Circle trên MapView
* Radius sẽ được điều chỉnh dựa trên zoom level để giữ kích thước pixel cố định
*/
export const useFixedCircleRadius = (pixelRadius: number = 30) => {
const [radius, setRadius] = useState(100); // Giá trị default
const calculateRadiusFromZoom = useCallback((zoomLevel: number) => {
// Công thức: radius (meters) = pixelRadius * 156543.04 * cos(latitude) / 2^(zoomLevel + 8)
// Đơn giản hơn: radius tỉ lệ với 2^(maxZoom - currentZoom)
// Khi zoom = 14, dùng radius = 100 làm reference
const baseZoom = 14;
const baseRadius = 100;
// Mỗi level zoom tương ứng với 2x sự khác biệt
const zoomDifference = baseZoom - zoomLevel;
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
return Math.max(calculatedRadius, 10); // Minimum 10 meters
}, []);
const handleZoomChange = useCallback(
(zoomLevel: number) => {
const newRadius = calculateRadiusFromZoom(zoomLevel);
setRadius(newRadius);
},
[calculateRadiusFromZoom]
);
return {
radius,
handleZoomChange,
};
};
/**
* Alternative: Sử dụng Polygon thay vì Circle để có kích thước cố định theo pixel
* Tạo một hình tròn bằng Polygon với điểm tâm là coordinate
*/
export const createCircleCoordinates = (
center: { latitude: number; longitude: number },
radiusInMeters: number,
points: number = 36
) => {
const coordinates = [];
const latDelta = radiusInMeters / 111000; // 1 degree ~ 111km
for (let i = 0; i < points; i++) {
const angle = (i / points) * (2 * Math.PI);
const longitude =
center.longitude +
(latDelta * Math.cos(angle)) /
Math.cos((center.latitude * Math.PI) / 180);
const latitude = center.latitude + latDelta * Math.sin(angle);
coordinates.push({ latitude, longitude });
}
return coordinates;
};

23
hooks/use-platform.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Platform } from "react-native";
export type PlatformType = "ios" | "android" | "web";
export const usePlatform = (): PlatformType => {
return Platform.OS as PlatformType;
};
export const useIsIOS = (): boolean => {
return Platform.OS === "ios";
};
export const useIsAndroid = (): boolean => {
return Platform.OS === "android";
};
export const useIsWeb = (): boolean => {
return Platform.OS === "web";
};
export const getPlatform = (): PlatformType => {
return Platform.OS as PlatformType;
};

32
package-lock.json generated
View File

@@ -37,7 +37,8 @@
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-toast-message": "^2.3.3", "react-native-toast-message": "^2.3.3",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
@@ -14240,6 +14241,35 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.24.1" "zod": "^3.24.1"
} }
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }

View File

@@ -40,7 +40,8 @@
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-toast-message": "^2.3.3", "react-native-toast-message": "^2.3.3",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",

View File

@@ -8,8 +8,6 @@ import shipWarningFishingIcon from "../assets/icons/ship_warning_fishing.png";
import shipSosIcon from "../assets/icons/sos_icon.png"; import shipSosIcon from "../assets/icons/sos_icon.png";
export const getShipIcon = (type: number, isFishing: boolean) => { export const getShipIcon = (type: number, isFishing: boolean) => {
console.log("type, isFishing", type, isFishing);
if (type === 1 && !isFishing) { if (type === 1 && !isFishing) {
return shipWarningIcon; return shipWarningIcon;
} else if (type === 2 && !isFishing) { } else if (type === 2 && !isFishing) {

27
state/use-banzones.ts Normal file
View File

@@ -0,0 +1,27 @@
import { queryBanzones } from "@/controller/MapController";
import { create } from "zustand";
type Banzone = {
banzones: Model.Zone[];
getBanzone: () => Promise<void>;
error: string | null;
loading?: boolean;
};
export const useBanzones = create<Banzone>()((set) => ({
banzones: [],
getBanzone: async () => {
set({ loading: true });
try {
const response = await queryBanzones();
console.log("Banzone fetching: ", response.data.length);
set({ banzones: response.data, loading: false });
} catch (error) {
console.error("Error when fetch Banzones: ", error);
set({ error: "Failed to fetch banzone data", loading: false });
set({ banzones: [] });
}
},
error: null,
}));

88
utils/geom.ts Normal file
View File

@@ -0,0 +1,88 @@
export const convertWKTPointToLatLng = (wktString: string) => {
if (
!wktString ||
typeof wktString !== "string" ||
!wktString.startsWith("POINT")
) {
return null;
}
const matched = wktString.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
if (!matched) return null;
const lng = parseFloat(matched[1]);
const lat = parseFloat(matched[2]);
return [lng, lat]; // [longitude, latitude]
};
export const convertWKTLineStringToLatLngArray = (wktString: string) => {
if (
!wktString ||
typeof wktString !== "string" ||
!wktString.startsWith("LINESTRING")
) {
return [];
}
const matched = wktString.match(/LINESTRING\s*\((.*)\)/);
if (!matched) return [];
const coordinates = matched[1].split(",").map((coordStr) => {
const [x, y] = coordStr.trim().split(" ").map(Number);
return [y, x]; // [lat, lng]
});
return coordinates;
};
export const convertWKTtoLatLngString = (wktString: string) => {
if (!wktString || typeof wktString !== "string") return [];
const clean = wktString.trim();
// MULTIPOLYGON
if (clean.startsWith("MULTIPOLYGON")) {
const matched = clean.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
if (!matched) return [];
const polygons = matched[1].split(")),((").map((polygonStr) =>
polygonStr
.trim()
.split(",")
.map((coordStr) => {
const [lng, lat] = coordStr.trim().split(/\s+/).map(Number);
return [lat, lng]; // Đảo ngược: [latitude, longitude]
})
);
return polygons; // Mỗi phần tử là 1 polygon (mảng các [lat, lng])
}
// POLYGON
if (clean.startsWith("POLYGON")) {
const matched = clean.match(/POLYGON\s*\(\((.*)\)\)/);
if (!matched) return [];
const polygon = matched[1].split(",").map((coordStr) => {
const [lng, lat] = coordStr.trim().split(/\s+/).map(Number);
return [lat, lng];
});
return [polygon];
}
return [];
};
export const getBanzoneNameByType = (type: number) => {
switch (type) {
case 1:
return "Cấm đánh bắt";
case 2:
return "Cấm di chuyển";
case 3:
return "Vùng an toàn";
default:
return "Chưa có";
}
};

157
utils/polyline.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* Utility functions for Polyline
*/
export interface LatLng {
latitude: number;
longitude: number;
}
/**
* Tìm điểm ở giữa của polyline
*/
export const getMiddlePointOfPolyline = (coordinates: LatLng[]): LatLng => {
if (coordinates.length === 0) {
return { latitude: 0, longitude: 0 };
}
if (coordinates.length === 1) {
return coordinates[0];
}
const middleIndex = Math.floor(coordinates.length / 2);
return coordinates[middleIndex];
};
/**
* Tính toán điểm ở giữa của 2 điểm
*/
export const getMidpoint = (point1: LatLng, point2: LatLng): LatLng => {
return {
latitude: (point1.latitude + point2.latitude) / 2,
longitude: (point1.longitude + point2.longitude) / 2,
};
};
/**
* Tính khoảng cách giữa 2 điểm (Haversine formula)
* Trả về khoảng cách theo km
*/
export const calculateDistance = (point1: LatLng, point2: LatLng): number => {
const R = 6371; // Bán kính trái đất (km)
const dLat = (point2.latitude - point1.latitude) * (Math.PI / 180);
const dLon = (point2.longitude - point1.longitude) * (Math.PI / 180);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(point1.latitude * (Math.PI / 180)) *
Math.cos(point2.latitude * (Math.PI / 180)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
/**
* Tính tổng khoảng cách của polyline
*/
export const calculateTotalDistance = (coordinates: LatLng[]): number => {
if (coordinates.length < 2) return 0;
let totalDistance = 0;
for (let i = 0; i < coordinates.length - 1; i++) {
totalDistance += calculateDistance(coordinates[i], coordinates[i + 1]);
}
return totalDistance;
};
/**
* Tính heading (hướng) giữa 2 điểm
* Trả về góc độ (0-360)
*/
export const calculateHeading = (point1: LatLng, point2: LatLng): number => {
const dLon = point2.longitude - point1.longitude;
const lat1 = point1.latitude * (Math.PI / 180);
const lat2 = point2.latitude * (Math.PI / 180);
const dLonRad = dLon * (Math.PI / 180);
const y = Math.sin(dLonRad) * Math.cos(lat2);
const x =
Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLonRad);
const bearing = Math.atan2(y, x) * (180 / Math.PI);
return (bearing + 360) % 360;
};
/**
* Tính điểm trung tâm (centroid) của polygon
* Sử dụng thuật toán Shoelace formula để tính centroid chính xác
* Thuật toán này tính centroid dựa trên diện tích, phù hợp với polygon bất kỳ
*/
export const getPolygonCenter = (coordinates: LatLng[]): LatLng => {
if (coordinates.length === 0) {
return { latitude: 0, longitude: 0 };
}
if (coordinates.length === 1) {
return coordinates[0];
}
if (coordinates.length === 2) {
return {
latitude: (coordinates[0].latitude + coordinates[1].latitude) / 2,
longitude: (coordinates[0].longitude + coordinates[1].longitude) / 2,
};
}
let area = 0;
let centroidLat = 0;
let centroidLon = 0;
// Đảm bảo polygon đóng (điểm đầu = điểm cuối)
const coords = [...coordinates];
if (
coords[0].latitude !== coords[coords.length - 1].latitude ||
coords[0].longitude !== coords[coords.length - 1].longitude
) {
coords.push(coords[0]);
}
// Tính diện tích và centroid sử dụng Shoelace formula
for (let i = 0; i < coords.length - 1; i++) {
const lat1 = coords[i].latitude;
const lon1 = coords[i].longitude;
const lat2 = coords[i + 1].latitude;
const lon2 = coords[i + 1].longitude;
const cross = lat1 * lon2 - lon1 * lat2;
area += cross;
centroidLat += (lat1 + lat2) * cross;
centroidLon += (lon1 + lon2) * cross;
}
area = area / 2;
// Nếu diện tích quá nhỏ (polygon suy biến), dùng trung bình đơn giản
if (Math.abs(area) < 0.0000001) {
let latSum = 0;
let lonSum = 0;
for (const coord of coordinates) {
latSum += coord.latitude;
lonSum += coord.longitude;
}
return {
latitude: latSum / coordinates.length,
longitude: lonSum / coordinates.length,
};
}
centroidLat = centroidLat / (6 * area);
centroidLon = centroidLon / (6 * area);
return {
latitude: centroidLat,
longitude: centroidLon,
};
};

11
utils/tranform.ts Normal file
View File

@@ -0,0 +1,11 @@
export function transformEntityResponse(
raw: Model.EntityResponse
): Model.TransformedEntity {
return {
id: raw.id,
value: raw.v,
valueString: raw.vs,
time: raw.t,
type: raw.type,
};
}