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:
@@ -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
14
assets.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
165
components/map/PolygonWithLabel.tsx
Normal file
165
components/map/PolygonWithLabel.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
124
components/map/PolylineWithLabel.tsx
Normal file
124
components/map/PolylineWithLabel.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
6
controller/MapController.ts
Normal file
6
controller/MapController.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
47
controller/typings.d.ts
vendored
47
controller/typings.d.ts
vendored
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
hooks/use-fixed-circle-radius.ts
Normal file
62
hooks/use-fixed-circle-radius.ts
Normal 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
23
hooks/use-platform.ts
Normal 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
32
package-lock.json
generated
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
27
state/use-banzones.ts
Normal 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
88
utils/geom.ts
Normal 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
157
utils/polyline.ts
Normal 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
11
utils/tranform.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user