Compare commits
2 Commits
2fac0b8093
...
44fc6848a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 44fc6848a8 | |||
|
|
5801992eae |
@@ -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<Model.GPSResonse | null>(null);
|
||||
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
||||
const [trackPoints, setTrackPoints] = useState<Model.ShipTrackPoint[] | 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 () => {
|
||||
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 (
|
||||
<SafeAreaProvider style={styles.container}>
|
||||
{banzones.length > 0 && (
|
||||
<Text className="hidden">Banzones loaded: {banzones.length}</Text>
|
||||
)}
|
||||
<MapView
|
||||
onMapReady={handleMapReady}
|
||||
onPoiClick={(point) => {
|
||||
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 && (
|
||||
<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 && (
|
||||
<Marker
|
||||
coordinate={{
|
||||
latitude: gpsData.lat,
|
||||
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}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
transform: [
|
||||
{
|
||||
rotate: `${
|
||||
typeof gpsData.h === "number" && !isNaN(gpsData.h)
|
||||
? gpsData.h
|
||||
: 0
|
||||
}deg`,
|
||||
},
|
||||
],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<View className="w-8 h-8 items-center justify-center">
|
||||
<ExpoImage
|
||||
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>
|
||||
</Marker>
|
||||
)}
|
||||
</MapView>
|
||||
<TouchableOpacity style={styles.button} onPress={handleMapReady}>
|
||||
<TouchableOpacity style={styles.button} onPress={drawPolyline}>
|
||||
<Text style={styles.buttonText}>Get GPS Data</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaProvider>
|
||||
@@ -200,6 +540,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
button: {
|
||||
display: "none",
|
||||
position: "absolute",
|
||||
top: 50,
|
||||
right: 20,
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
} from "@/utils/storage";
|
||||
import { parseJwtToken } from "@/utils/token";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
@@ -98,9 +99,23 @@ export default function LoginScreen() {
|
||||
<ThemedView style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.headerContainer}>
|
||||
{/* Logo */}
|
||||
<Image
|
||||
source={require("@/assets/images/logo.png")}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
SGW App
|
||||
Hệ thống giám sát tàu cá
|
||||
</ThemedText>
|
||||
{/* Owner Logo */}
|
||||
<View style={styles.ownerContainer}>
|
||||
<Image
|
||||
source={require("@/assets/images/owner.png")}
|
||||
style={styles.ownerLogo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
<ThemedText style={styles.subtitle}>
|
||||
Đăng nhập để tiếp tục
|
||||
</ThemedText>
|
||||
@@ -160,6 +175,13 @@ export default function LoginScreen() {
|
||||
<Text style={styles.linkText}>Đăng ký ngay</Text>
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Copyright */}
|
||||
<View style={styles.copyrightContainer}>
|
||||
<ThemedText style={styles.copyrightText}>
|
||||
© {new Date().getFullYear()} - Sản phẩm của Mobifone
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
@@ -181,8 +203,13 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
logo: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
},
|
||||
@@ -236,4 +263,20 @@ const styles = StyleSheet.create({
|
||||
color: "#007AFF",
|
||||
fontWeight: "600",
|
||||
},
|
||||
ownerContainer: {
|
||||
alignItems: "center",
|
||||
},
|
||||
ownerLogo: {
|
||||
width: 150,
|
||||
height: 50,
|
||||
},
|
||||
copyrightContainer: {
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
copyrightText: {
|
||||
fontSize: 12,
|
||||
opacity: 0.6,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
BIN
assets/images/logo.png
Normal file
BIN
assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/owner.png
Normal file
BIN
assets/images/owner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
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
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -17,6 +17,12 @@ interface CrewMember {
|
||||
// ⚓ Dữ liệu mẫu
|
||||
// ---------------------------
|
||||
const data: CrewMember[] = [
|
||||
{
|
||||
id: "10",
|
||||
maDinhDanh: "ChuTau",
|
||||
ten: "Nguyễn Nhật Minh",
|
||||
chucVu: "Chủ tàu",
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
maDinhDanh: "TV001",
|
||||
@@ -43,6 +49,10 @@ const CrewListTable: React.FC = () => {
|
||||
setCollapsed((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleCrewPress = (crewId: string) => {
|
||||
console.log("Crew ID:", crewId);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header toggle */}
|
||||
@@ -77,7 +87,9 @@ const CrewListTable: React.FC = () => {
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||
Mã định danh
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
|
||||
<View style={styles.cellWrapper}>
|
||||
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
|
||||
</View>
|
||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||
Chức vụ
|
||||
</Text>
|
||||
@@ -87,7 +99,12 @@ const CrewListTable: React.FC = () => {
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text>
|
||||
<Text style={[styles.cell]}>{item.ten}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.cellWrapper}
|
||||
onPress={() => handleCrewPress(item.id)}
|
||||
>
|
||||
<Text style={[styles.cell, styles.linkText]}>{item.ten}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -109,7 +126,9 @@ const CrewListTable: React.FC = () => {
|
||||
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||
Mã định danh
|
||||
</Text>
|
||||
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
|
||||
<View style={styles.cellWrapper}>
|
||||
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
|
||||
</View>
|
||||
<Text style={[styles.cell, styles.right, styles.headerText]}>
|
||||
Chức vụ
|
||||
</Text>
|
||||
@@ -119,7 +138,12 @@ const CrewListTable: React.FC = () => {
|
||||
{data.map((item) => (
|
||||
<View key={item.id} style={styles.row}>
|
||||
<Text style={[styles.cell, styles.left]}>{item.maDinhDanh}</Text>
|
||||
<Text style={[styles.cell]}>{item.ten}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.cellWrapper}
|
||||
onPress={() => handleCrewPress(item.id)}
|
||||
>
|
||||
<Text style={[styles.cell, styles.linkText]}>{item.ten}</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -37,6 +37,10 @@ const NetListTable: React.FC = () => {
|
||||
setCollapsed((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleStatusPress = (id: string) => {
|
||||
console.log(`ID mẻ lưới: ${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header toggle */}
|
||||
@@ -79,7 +83,9 @@ const NetListTable: React.FC = () => {
|
||||
{/* Cột Trạng thái */}
|
||||
<View style={[styles.cell, styles.statusContainer]}>
|
||||
<View style={styles.statusDot} />
|
||||
<Text style={styles.statusText}>{item.trangThai}</Text>
|
||||
<TouchableOpacity onPress={() => handleStatusPress(item.id)}>
|
||||
<Text style={styles.statusText}>{item.trangThai}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -102,7 +108,9 @@ const NetListTable: React.FC = () => {
|
||||
{/* Cột Trạng thái */}
|
||||
<View style={[styles.cell, styles.statusContainer]}>
|
||||
<View style={styles.statusDot} />
|
||||
<Text style={styles.statusText}>{item.trangThai}</Text>
|
||||
<TouchableOpacity onPress={() => handleStatusPress(item.id)}>
|
||||
<Text style={styles.statusText}>{item.trangThai}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
@@ -73,6 +73,10 @@ const TripCostTable: React.FC = () => {
|
||||
setCollapsed((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleViewDetail = () => {
|
||||
console.log("View trip cost details");
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
@@ -140,6 +144,14 @@ const TripCostTable: React.FC = () => {
|
||||
{tongCong.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* View Detail Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.viewDetailButton}
|
||||
onPress={handleViewDetail}
|
||||
>
|
||||
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
|
||||
@@ -170,6 +182,14 @@ const TripCostTable: React.FC = () => {
|
||||
{tongCong.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* View Detail Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.viewDetailButton}
|
||||
onPress={handleViewDetail}
|
||||
>
|
||||
<Text style={styles.viewDetailText}>Xem chi tiết</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,11 @@ export default StyleSheet.create({
|
||||
color: "#111",
|
||||
textAlign: "center",
|
||||
},
|
||||
cellWrapper: {
|
||||
flex: 1.5,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
left: {
|
||||
textAlign: "center",
|
||||
},
|
||||
@@ -64,4 +69,8 @@ export default StyleSheet.create({
|
||||
color: "#ff6600",
|
||||
fontWeight: "800",
|
||||
},
|
||||
linkText: {
|
||||
color: "#007AFF",
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,13 +4,13 @@ export default StyleSheet.create({
|
||||
container: {
|
||||
width: "100%",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "#eee",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.05,
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 1,
|
||||
},
|
||||
@@ -34,7 +34,7 @@ export default StyleSheet.create({
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 0.6,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: "#eee",
|
||||
},
|
||||
tableHeader: {
|
||||
@@ -52,7 +52,7 @@ export default StyleSheet.create({
|
||||
flex: 0.3,
|
||||
fontSize: 15,
|
||||
color: "#111",
|
||||
textAlign: "left",
|
||||
textAlign: "center",
|
||||
paddingLeft: 10,
|
||||
},
|
||||
headerText: {
|
||||
@@ -72,6 +72,7 @@ export default StyleSheet.create({
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 15,
|
||||
color: "#111",
|
||||
color: "#4a90e2",
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,6 +56,17 @@ const styles = StyleSheet.create({
|
||||
color: "#ff6600",
|
||||
fontWeight: "700",
|
||||
},
|
||||
viewDetailButton: {
|
||||
marginTop: 12,
|
||||
paddingVertical: 8,
|
||||
alignItems: "center",
|
||||
},
|
||||
viewDetailText: {
|
||||
color: "#007AFF",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
});
|
||||
|
||||
export default styles;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Model.GPSResonse>(API_GET_GPS);
|
||||
@@ -16,3 +18,8 @@ export async function queryAlarm() {
|
||||
export async function queryTrackPoints() {
|
||||
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";
|
||||
|
||||
export { AuthController };
|
||||
import * as DeviceController from "./DeviceController";
|
||||
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;
|
||||
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-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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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