diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index a916367..8aaadf7 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,33 +1,70 @@ +import { PolygonWithLabel } from "@/components/map/PolygonWithLabel"; +import { PolylineWithLabel } from "@/components/map/PolylineWithLabel"; import { showToastError } from "@/config"; +import { + AUTO_REFRESH_INTERVAL, + ENTITY, + IOS_PLATFORM, + LIGHT_THEME, +} from "@/constants"; import { queryAlarm, + queryEntities, queryGpsData, queryTrackPoints, } from "@/controller/DeviceController"; +import { useColorScheme } from "@/hooks/use-color-scheme.web"; +import { usePlatform } from "@/hooks/use-platform"; 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 { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; import MapView, { Circle, Marker } from "react-native-maps"; 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() { const [gpsData, setGpsData] = useState(null); const [alarmData, setAlarmData] = useState(null); const [trackPoints, setTrackPoints] = useState( 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>(new Map()); + const intervalRef = useRef | 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 () => { try { const response = await queryGpsData(); - console.log("GpsData: ", response.data); - console.log( - "Heading value:", - response.data?.h, - "Type:", - typeof response.data?.h - ); + // console.log("GpsData: ", response.data); + // console.log( + // "Heading value:", + // response.data?.h, + // "Type:", + // typeof response.data?.h + // ); setGpsData(response.data); } catch (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 () => { try { const response = await queryAlarm(); - console.log("AlarmData: ", response.data); + // console.log("AlarmData: ", response.data); setAlarmData(response.data); } catch (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 () => { try { const response = await queryTrackPoints(); - console.log( - "TrackPoints Data: ", - response.data[response.data.length - 1] - ); + // console.log("TrackPoints Data Length: ", response.data.length); setTrackPoints(response.data); } catch (error) { console.error("Error fetching TrackPoints Data: ", error); @@ -60,75 +318,113 @@ export default function HomeScreen() { } }; - const handleMapReady = () => { - console.log("Map loaded successfully!"); - getGpsData(); - getAlarmData(); - getShipTrackPoints(); + const fetchAllData = async () => { + await Promise.all([ + getGpsData(), + getAlarmData(), + 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 = () => { - if (!gpsData && (!trackPoints || trackPoints.length === 0)) { + if (!isFirstLoad) { + // Sau lần đầu, return undefined để không force region + return undefined; + } + if (!gpsData) { return { latitude: 15.70581, longitude: 116.152685, - latitudeDelta: 2, - longitudeDelta: 2, + latitudeDelta: 0.05, + 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 { - latitude: (minLat + maxLat) / 2, - longitude: (minLon + maxLon) / 2, - latitudeDelta: latDelta, - longitudeDelta: lonDelta, + latitude: gpsData.lat, + longitude: gpsData.lon, + latitudeDelta: 0.05, + longitudeDelta: 0.05, }; }; return ( + {banzones.length > 0 && ( + Banzones loaded: {banzones.length} + )} { console.log("Poi clicked: ", point.nativeEvent); }} + onRegionChangeComplete={handleRegionChangeComplete} style={styles.map} - initialRegion={{ - latitude: 15.70581, - longitude: 116.152685, - latitudeDelta: 2, - longitudeDelta: 2, - }} + // initialRegion={getMapRegion()} region={getMapRegion()} - // userInterfaceStyle="dark" + userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"} showsBuildings={false} showsIndoors={false} loadingEnabled={true} @@ -146,46 +442,90 @@ export default function HomeScreen() { longitude: point.lon, }} zIndex={50} - radius={200} // Tăng từ 50 → 1000m - fillColor="rgba(241, 12, 65, 0.8)" // Tăng opacity từ 0.06 → 0.8 - strokeColor="rgba(221, 240, 15, 0.8)" + radius={circleRadius} + fillColor="rgba(16, 85, 201, 0.6)" + strokeColor="rgba(16, 85, 201, 0.8)" strokeWidth={2} /> ); })} + {polylineCoordinates && ( + ({ + 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 ( + ({ + 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 && ( - + )} - + Get GPS Data @@ -200,6 +540,7 @@ const styles = StyleSheet.create({ flex: 1, }, button: { + display: "none", position: "absolute", top: 50, right: 20, diff --git a/assets.d.ts b/assets.d.ts new file mode 100644 index 0000000..4441bd5 --- /dev/null +++ b/assets.d.ts @@ -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; +} diff --git a/components/map/PolygonWithLabel.tsx b/components/map/PolygonWithLabel.tsx new file mode 100644 index 0000000..d6ed13b --- /dev/null +++ b/components/map/PolygonWithLabel.tsx @@ -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 = ({ + 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 ( + <> + + {label && ( + + + + + {label} + + {content && ( + + {content} + + )} + + + + )} + + ); +}; + +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 + ); + } +); diff --git a/components/map/PolylineWithLabel.tsx b/components/map/PolylineWithLabel.tsx new file mode 100644 index 0000000..fc4a906 --- /dev/null +++ b/components/map/PolylineWithLabel.tsx @@ -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 = ({ + 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 ( + <> + + {displayText && ( + + + + + {displayText} + + + + + )} + + ); +}; + +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 + ); + } +); diff --git a/config/axios.ts b/config/axios.ts index 3bc6d31..883434b 100644 --- a/config/axios.ts +++ b/config/axios.ts @@ -24,7 +24,7 @@ const codeMessage = { // Tạo instance axios với cấu hình cơ bản const api: AxiosInstance = axios.create({ 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: { "Content-Type": "application/json", }, diff --git a/constants/index.ts b/constants/index.ts index e4b757c..ac31c0b 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -5,12 +5,23 @@ export const MAP_POLYLINE_BAN = "ban-polyline"; export const MAP_POLYGON_BAN = "ban-polygon"; // 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 export const ROUTE_LOGIN = "/login"; export const ROUTE_HOME = "/map"; export const ROUTE_TRIP = "/trip"; +// Entity Contants +export const ENTITY = { + ZONE_ALARM_LIST: "50:2", + GPS: "50:1", +}; + // API Path Constants export const API_PATH_LOGIN = "/api/agent/login"; export const API_PATH_ENTITIES = "/api/io/entities"; diff --git a/controller/DeviceController.ts b/controller/DeviceController.ts index 0a1f030..d188da9 100644 --- a/controller/DeviceController.ts +++ b/controller/DeviceController.ts @@ -2,8 +2,10 @@ import { api } from "@/config"; import { API_GET_ALARMS, API_GET_GPS, + API_PATH_ENTITIES, API_PATH_SHIP_TRACK_POINTS, } from "@/constants"; +import { transformEntityResponse } from "@/utils/tranform"; export async function queryGpsData() { return api.get(API_GET_GPS); @@ -16,3 +18,8 @@ export async function queryAlarm() { export async function queryTrackPoints() { return api.get(API_PATH_SHIP_TRACK_POINTS); } + +export async function queryEntities(): Promise { + const response = await api.get(API_PATH_ENTITIES); + return response.data.map(transformEntityResponse); +} diff --git a/controller/MapController.ts b/controller/MapController.ts new file mode 100644 index 0000000..17f1a27 --- /dev/null +++ b/controller/MapController.ts @@ -0,0 +1,6 @@ +import { api } from "@/config"; +import { API_GET_ALL_BANZONES } from "@/constants"; + +export async function queryBanzones() { + return api.get(API_GET_ALL_BANZONES); +} diff --git a/controller/index.ts b/controller/index.ts index 1b281ff..86fbfb9 100644 --- a/controller/index.ts +++ b/controller/index.ts @@ -1,3 +1,4 @@ import * as AuthController from "./AuthController"; - -export { AuthController }; +import * as DeviceController from "./DeviceController"; +import * as MapController from "./MapController"; +export { AuthController, DeviceController, MapController }; diff --git a/controller/typings.d.ts b/controller/typings.d.ts index a44fafa..299965c 100644 --- a/controller/typings.d.ts +++ b/controller/typings.d.ts @@ -33,4 +33,51 @@ declare namespace Model { s: 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; + } } diff --git a/hooks/use-fixed-circle-radius.ts b/hooks/use-fixed-circle-radius.ts new file mode 100644 index 0000000..0b8138a --- /dev/null +++ b/hooks/use-fixed-circle-radius.ts @@ -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; +}; diff --git a/hooks/use-platform.ts b/hooks/use-platform.ts new file mode 100644 index 0000000..2d48844 --- /dev/null +++ b/hooks/use-platform.ts @@ -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; +}; diff --git a/package-lock.json b/package-lock.json index d8ffe1d..301258b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,8 @@ "react-native-screens": "~4.16.0", "react-native-toast-message": "^2.3.3", "react-native-web": "~0.21.0", - "react-native-worklets": "0.5.1" + "react-native-worklets": "0.5.1", + "zustand": "^5.0.8" }, "devDependencies": { "@types/react": "~19.1.0", @@ -14240,6 +14241,35 @@ "peerDependencies": { "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 + } + } } } } diff --git a/package.json b/package.json index ad53667..5d341c4 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "react-native-screens": "~4.16.0", "react-native-toast-message": "^2.3.3", "react-native-web": "~0.21.0", - "react-native-worklets": "0.5.1" + "react-native-worklets": "0.5.1", + "zustand": "^5.0.8" }, "devDependencies": { "@types/react": "~19.1.0", diff --git a/services/map_service.ts b/services/map_service.ts index c986926..d210cf1 100644 --- a/services/map_service.ts +++ b/services/map_service.ts @@ -8,8 +8,6 @@ import shipWarningFishingIcon from "../assets/icons/ship_warning_fishing.png"; import shipSosIcon from "../assets/icons/sos_icon.png"; export const getShipIcon = (type: number, isFishing: boolean) => { - console.log("type, isFishing", type, isFishing); - if (type === 1 && !isFishing) { return shipWarningIcon; } else if (type === 2 && !isFishing) { diff --git a/state/use-banzones.ts b/state/use-banzones.ts new file mode 100644 index 0000000..ed42158 --- /dev/null +++ b/state/use-banzones.ts @@ -0,0 +1,27 @@ +import { queryBanzones } from "@/controller/MapController"; +import { create } from "zustand"; + +type Banzone = { + banzones: Model.Zone[]; + getBanzone: () => Promise; + error: string | null; + loading?: boolean; +}; + +export const useBanzones = create()((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, +})); diff --git a/utils/geom.ts b/utils/geom.ts new file mode 100644 index 0000000..a27b720 --- /dev/null +++ b/utils/geom.ts @@ -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ó"; + } +}; diff --git a/utils/polyline.ts b/utils/polyline.ts new file mode 100644 index 0000000..4db0490 --- /dev/null +++ b/utils/polyline.ts @@ -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, + }; +}; diff --git a/utils/tranform.ts b/utils/tranform.ts new file mode 100644 index 0000000..f806762 --- /dev/null +++ b/utils/tranform.ts @@ -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, + }; +}