Compare commits

...

9 Commits

36 changed files with 2262 additions and 272 deletions

View File

@@ -1,5 +1,4 @@
import { Tabs } from "expo-router";
import React from "react";
import { HapticTab } from "@/components/haptic-tab";
import { IconSymbol } from "@/components/ui/icon-symbol";
@@ -26,21 +25,35 @@ export default function TabLayout() {
),
}}
/>
<Tabs.Screen
name="explore"
name="tripInfo"
options={{
title: "Explore",
title: "Chuyến Đi",
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="paperplane.fill" color={color} />
<IconSymbol size={28} name="ferry.fill" color={color} />
),
}}
/>
<Tabs.Screen
name="tripInfo"
name="diary"
options={{
title: "Thông Tin Chuyến Đi",
title: "Nhật Ký",
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="ferry.fill" color={color} />
<IconSymbol size={28} name="book.closed.fill" color={color} />
),
}}
/>
<Tabs.Screen
name="sensor"
options={{
title: "Cảm biến",
tabBarIcon: ({ color }) => (
<IconSymbol
size={28}
name="dot.radiowaves.left.and.right"
color={color}
/>
),
}}
/>

35
app/(tabs)/diary.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export default function Warning() {
return (
<SafeAreaView style={{ flex: 1 }}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.container}>
<Text style={styles.titleText}>Nhật Chuyến Đi</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
scrollContent: {
flexGrow: 1,
},
container: {
alignItems: "center",
padding: 15,
},
titleText: {
fontSize: 32,
fontWeight: "700",
lineHeight: 40,
marginBottom: 10,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -1,126 +0,0 @@
import { Image } from "expo-image";
import { Platform, StyleSheet } from "react-native";
import { ExternalLink } from "@/components/external-link";
import ParallaxScrollView from "@/components/parallax-scroll-view";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { Collapsible } from "@/components/ui/collapsible";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { Fonts } from "@/constants/theme";
export default function TabTwoScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: "#D0D0D0", dark: "#353636" }}
headerImage={
<IconSymbol
size={310}
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}
>
<ThemedView style={styles.titleContainer}>
<ThemedText
type="title"
style={{
fontFamily: Fonts.rounded,
}}
>
Explore
</ThemedText>
</ThemedView>
<ThemedText>
This app includes example code to help you get started.
</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{" "}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText>{" "}
and{" "}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in{" "}
<ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{" "}
sets up the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the
web version, press <ThemedText type="defaultSemiBold">w</ThemedText>{" "}
in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the{" "}
<ThemedText type="defaultSemiBold">@2x</ThemedText> and{" "}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to
provide files for different screen densities
</ThemedText>
<Image
source={require("@/assets/images/react-logo.png")}
style={{ width: 100, height: 100, alignSelf: "center" }}
/>
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{" "}
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook
lets you inspect what the user&apos;s current color scheme is, and so
you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{" "}
<ThemedText type="defaultSemiBold">
components/HelloWave.tsx
</ThemedText>{" "}
component uses the powerful{" "}
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
react-native-reanimated
</ThemedText>{" "}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The{" "}
<ThemedText type="defaultSemiBold">
components/ParallaxScrollView.tsx
</ThemedText>{" "}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
);
}
const styles = StyleSheet.create({
headerImage: {
color: "#808080",
bottom: -90,
left: -35,
position: "absolute",
},
titleContainer: {
flexDirection: "row",
gap: 8,
},
});

View File

@@ -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={20} // 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,

35
app/(tabs)/sensor.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export default function Sensor() {
return (
<SafeAreaView style={{ flex: 1 }}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.container}>
<Text style={styles.titleText}>Cảm biến trên tàu</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
scrollContent: {
flexGrow: 1,
},
container: {
alignItems: "center",
padding: 15,
},
titleText: {
fontSize: 32,
fontWeight: "700",
lineHeight: 40,
marginBottom: 10,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

View File

@@ -1,22 +1,72 @@
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import ButtonCancelTrip from "@/components/ButtonCancelTrip";
import ButtonCreateNewHaulOrTrip from "@/components/ButtonCreateNewHaulOrTrip";
import ButtonEndTrip from "@/components/ButtonEndTrip";
import CrewListTable from "@/components/tripInfo/CrewListTable";
import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
import NetListTable from "@/components/tripInfo/NetListTable";
import TripCostTable from "@/components/tripInfo/TripCostTable";
import { StyleSheet } from "react-native";
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export default function TripInfoScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">Thông Tin Chuyến Đi</ThemedText>
<TripCostTable />
</ThemedView>
<SafeAreaView style={{ flex: 1 }}>
<View style={styles.header}>
<Text style={styles.titleText}>Thông Tin Chuyến Đi</Text>
<View style={styles.buttonWrapper}>
<ButtonCreateNewHaulOrTrip />
</View>
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.container}>
<TripCostTable />
<FishingToolsTable />
<CrewListTable />
<NetListTable />
<View style={styles.buttonRow}>
<ButtonCancelTrip />
<ButtonEndTrip />
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
scrollContent: {
flexGrow: 1,
},
header: {
width: "100%",
paddingHorizontal: 15,
paddingTop: 15,
paddingBottom: 10,
alignItems: "center",
justifyContent: "center",
padding: 20,
},
buttonWrapper: {
width: "100%",
flexDirection: "row",
justifyContent: "flex-end",
},
container: {
alignItems: "center",
paddingHorizontal: 15,
},
buttonRow: {
flexDirection: "row",
gap: 10,
marginTop: 15,
},
titleText: {
fontSize: 32,
fontWeight: "700",
lineHeight: 40,
paddingBottom: 10,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

14
assets.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,45 @@
import React from "react";
import { StyleSheet, Text, TouchableOpacity } from "react-native";
interface ButtonCancelTripProps {
title?: string;
onPress?: () => void;
}
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
title = "Hủy chuyến đi",
onPress,
}) => {
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
activeOpacity={0.8}
>
<Text style={styles.text}>{title}</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#f45b57", // đỏ nhẹ giống ảnh
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 20,
alignSelf: "flex-start",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 2, // cho Android
},
text: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
textAlign: "center",
},
});
export default ButtonCancelTrip;

View File

@@ -0,0 +1,107 @@
import { AntDesign } from "@expo/vector-icons";
import React, { useState } from "react";
import { Alert, StyleSheet, Text, TouchableOpacity, View } from "react-native";
interface StartButtonProps {
title?: string;
onPress?: () => void;
}
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
title = "Bắt đầu mẻ lưới",
onPress,
}) => {
const [isStarted, setIsStarted] = useState(false);
const handlePress = () => {
if (isStarted) {
Alert.alert(
"Kết thúc mẻ lưới",
"Bạn có chắc chắn muốn kết thúc mẻ lưới này?",
[
{
text: "Hủy",
style: "cancel",
},
{
text: "Kết thúc",
onPress: () => {
setIsStarted(false);
Alert.alert("Thành công", "Đã kết thúc mẻ lưới!");
},
},
]
);
} else {
Alert.alert("Bắt đầu mẻ lưới", "Bạn có muốn bắt đầu mẻ lưới mới?", [
{
text: "Hủy",
style: "cancel",
},
{
text: "Bắt đầu",
onPress: () => {
setIsStarted(true);
Alert.alert("Thành công", "Đã bắt đầu mẻ lưới mới!");
},
},
]);
}
if (onPress) {
onPress();
}
};
return (
<TouchableOpacity
style={[styles.button, isStarted && styles.buttonActive]}
onPress={handlePress}
activeOpacity={0.8}
>
<View style={styles.content}>
<AntDesign
name={isStarted ? "close" : "plus"}
size={18}
color="#fff"
style={styles.icon}
/>
<Text style={styles.text}>
{isStarted ? "Kết thúc mẻ lưới" : title}
</Text>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#4ecdc4", // màu ngọc lam
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 16,
alignSelf: "flex-start",
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 3,
shadowOffset: { width: 0, height: 2 },
elevation: 3, // hiệu ứng nổi trên Android
},
buttonActive: {
backgroundColor: "#e74c3c", // màu đỏ khi đang hoạt động
},
content: {
flexDirection: "row",
alignItems: "center",
},
icon: {
marginRight: 6,
},
text: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
});
export default ButtonCreateNewHaulOrTrip;

View File

@@ -0,0 +1,45 @@
import React from "react";
import { StyleSheet, Text, TouchableOpacity } from "react-native";
interface ButtonEndTripProps {
title?: string;
onPress?: () => void;
}
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({
title = "Kết thúc",
onPress,
}) => {
return (
<TouchableOpacity
style={styles.button}
onPress={onPress}
activeOpacity={0.85}
>
<Text style={styles.text}>{title}</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#ed9434", // màu cam sáng
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 28,
alignSelf: "flex-start",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 3,
shadowOffset: { width: 0, height: 1 },
elevation: 2, // hiệu ứng nổi trên Android
},
text: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
textAlign: "center",
},
});
export default ButtonEndTrip;

View File

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

View File

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

View File

@@ -0,0 +1,140 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native";
import styles from "./style/CrewListTable.styles";
// ---------------------------
// 🧩 Interface
// ---------------------------
interface CrewMember {
id: string;
maDinhDanh: string;
ten: string;
chucVu: string;
}
// ---------------------------
// ⚓ Dữ liệu mẫu
// ---------------------------
const data: CrewMember[] = [
{
id: "1",
maDinhDanh: "TV001",
ten: "Nguyễn Văn A",
chucVu: "Thuyền trưởng",
},
{ id: "2", maDinhDanh: "TV002", ten: "Trần Văn B", chucVu: "Máy trưởng" },
{ id: "3", maDinhDanh: "TV003", ten: "Lê Văn C", chucVu: "Thủy thủ" },
];
const CrewListTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true);
const [contentHeight, setContentHeight] = useState<number>(0);
const animatedHeight = useRef(new Animated.Value(0)).current;
const tongThanhVien = data.length;
const handleToggle = () => {
const toValue = collapsed ? contentHeight : 0;
Animated.timing(animatedHeight, {
toValue,
duration: 300,
useNativeDriver: false,
}).start();
setCollapsed((prev) => !prev);
};
return (
<View style={styles.container}>
{/* Header toggle */}
<TouchableOpacity
activeOpacity={0.7}
onPress={handleToggle}
style={styles.headerRow}
>
<Text style={styles.title}>Danh sách thuyền viên</Text>
{collapsed && (
<Text style={styles.totalCollapsed}>{tongThanhVien}</Text>
)}
<IconSymbol
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
size={16}
color="#000"
/>
</TouchableOpacity>
{/* Nội dung ẩn để đo chiều cao */}
<View
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
onLayout={(event) => {
const height = event.nativeEvent.layout.height;
if (height > 0 && contentHeight === 0) {
setContentHeight(height);
}
}}
>
{/* Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>
đnh danh
</Text>
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
<Text style={[styles.cell, styles.right, styles.headerText]}>
Chức vụ
</Text>
</View>
{/* Body */}
{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>
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
</View>
))}
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
</Text>
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
<Text style={[styles.cell, styles.right]}></Text>
</View>
</View>
{/* Bảng hiển thị với animation */}
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
{/* Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>
đnh danh
</Text>
<Text style={[styles.cell, styles.headerText]}>Tên</Text>
<Text style={[styles.cell, styles.right, styles.headerText]}>
Chức vụ
</Text>
</View>
{/* Body */}
{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>
<Text style={[styles.cell, styles.right]}>{item.chucVu}</Text>
</View>
))}
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
</Text>
<Text style={[styles.cell, styles.footerTotal]}>{tongThanhVien}</Text>
<Text style={[styles.cell, styles.right]}></Text>
</View>
</Animated.View>
</View>
);
};
export default CrewListTable;

View File

@@ -0,0 +1,126 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native";
import styles from "./style/FishingToolsTable.styles";
// ---------------------------
// 🧩 Interface
// ---------------------------
interface ToolItem {
id: string;
ten: string;
soLuong: number;
}
// ---------------------------
// 🎣 Dữ liệu mẫu
// ---------------------------
const data: ToolItem[] = [
{ id: "1", ten: "Lưới kéo", soLuong: 1 },
{ id: "2", ten: "Cần câu", soLuong: 1 },
{ id: "3", ten: "Mồi câu", soLuong: 5 },
];
const FishingToolsTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true);
const [contentHeight, setContentHeight] = useState<number>(0);
const animatedHeight = useRef(new Animated.Value(0)).current;
const tongSoLuong = data.reduce((sum, item) => sum + item.soLuong, 0);
const handleToggle = () => {
const toValue = collapsed ? contentHeight : 0;
Animated.timing(animatedHeight, {
toValue,
duration: 300,
useNativeDriver: false,
}).start();
setCollapsed((prev) => !prev);
};
return (
<View style={styles.container}>
{/* Header / Toggle */}
<TouchableOpacity
activeOpacity={0.7}
onPress={handleToggle}
style={styles.headerRow}
>
<Text style={styles.title}>Danh sách ngư cụ</Text>
{collapsed && <Text style={styles.totalCollapsed}>{tongSoLuong}</Text>}
<IconSymbol
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
size={16}
color="#000"
/>
</TouchableOpacity>
{/* Nội dung ẩn để đo chiều cao */}
<View
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
onLayout={(event) => {
const height = event.nativeEvent.layout.height;
if (height > 0 && contentHeight === 0) {
setContentHeight(height);
}
}}
>
{/* Table Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>Tên</Text>
<Text style={[styles.cell, styles.right, styles.headerText]}>
Số lượng
</Text>
</View>
{/* Body */}
{data.map((item) => (
<View key={item.id} style={styles.row}>
<Text style={[styles.cell, styles.left]}>{item.ten}</Text>
<Text style={[styles.cell, styles.right]}>{item.soLuong}</Text>
</View>
))}
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
</Text>
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
{tongSoLuong}
</Text>
</View>
</View>
{/* Nội dung mở/đóng */}
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
{/* Table Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>Tên</Text>
<Text style={[styles.cell, styles.right, styles.headerText]}>
Số lượng
</Text>
</View>
{/* Body */}
{data.map((item) => (
<View key={item.id} style={styles.row}>
<Text style={[styles.cell, styles.left]}>{item.ten}</Text>
<Text style={[styles.cell, styles.right]}>{item.soLuong}</Text>
</View>
))}
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
</Text>
<Text style={[styles.cell, styles.right, styles.footerTotal]}>
{tongSoLuong}
</Text>
</View>
</Animated.View>
</View>
);
};
export default FishingToolsTable;

View File

@@ -0,0 +1,114 @@
import { IconSymbol } from "@/components/ui/icon-symbol";
import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native";
import styles from "./style/NetListTable.styles";
// ---------------------------
// 🧩 Interface
// ---------------------------
interface NetItem {
id: string;
stt: string;
trangThai: string;
}
// ---------------------------
// 🧵 Dữ liệu mẫu
// ---------------------------
const data: NetItem[] = [
{ id: "1", stt: "Mẻ 3", trangThai: "Đã hoàn thành" },
{ id: "2", stt: "Mẻ 2", trangThai: "Đã hoàn thành" },
{ id: "3", stt: "Mẻ 1", trangThai: "Đã hoàn thành" },
];
const NetListTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true);
const [contentHeight, setContentHeight] = useState<number>(0);
const animatedHeight = useRef(new Animated.Value(0)).current;
const tongSoMe = data.length;
const handleToggle = () => {
const toValue = collapsed ? contentHeight : 0;
Animated.timing(animatedHeight, {
toValue,
duration: 300,
useNativeDriver: false,
}).start();
setCollapsed((prev) => !prev);
};
return (
<View style={styles.container}>
{/* Header toggle */}
<TouchableOpacity
activeOpacity={0.7}
onPress={handleToggle}
style={styles.headerRow}
>
<Text style={styles.title}>Danh sách mẻ lưới</Text>
{collapsed && <Text style={styles.totalCollapsed}>{tongSoMe}</Text>}
<IconSymbol
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
size={16}
color="#000"
/>
</TouchableOpacity>
{/* Nội dung ẩn để đo chiều cao */}
<View
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
onLayout={(event) => {
const height = event.nativeEvent.layout.height;
if (height > 0 && contentHeight === 0) {
setContentHeight(height);
}
}}
>
{/* Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.sttCell, styles.headerText]}>STT</Text>
<Text style={[styles.cell, styles.headerText]}>Trạng thái</Text>
</View>
{/* Body */}
{data.map((item) => (
<View key={item.id} style={styles.row}>
{/* Cột STT */}
<Text style={styles.sttCell}>{item.stt}</Text>
{/* Cột Trạng thái */}
<View style={[styles.cell, styles.statusContainer]}>
<View style={styles.statusDot} />
<Text style={styles.statusText}>{item.trangThai}</Text>
</View>
</View>
))}
</View>
{/* Bảng hiển thị với animation */}
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
{/* Header */}
<View style={[styles.row, styles.tableHeader]}>
<Text style={[styles.sttCell, styles.headerText]}>STT</Text>
<Text style={[styles.cell, styles.headerText]}>Trạng thái</Text>
</View>
{/* Body */}
{data.map((item) => (
<View key={item.id} style={styles.row}>
{/* Cột STT */}
<Text style={styles.sttCell}>{item.stt}</Text>
{/* Cột Trạng thái */}
<View style={[styles.cell, styles.statusContainer]}>
<View style={styles.statusDot} />
<Text style={styles.statusText}>{item.trangThai}</Text>
</View>
</View>
))}
</Animated.View>
</View>
);
};
export default NetListTable;

View File

@@ -1,6 +1,7 @@
import React from "react";
import { FlatList, ListRenderItem, Text, View } from "react-native";
import styles from "./TripCostTable.styles";
import { IconSymbol } from "@/components/ui/icon-symbol";
import React, { useRef, useState } from "react";
import { Animated, Text, TouchableOpacity, View } from "react-native";
import styles from "./style/TripCostTable.styles";
// ---------------------------
// 🧩 Interface
@@ -55,44 +56,121 @@ const data: CostItem[] = [
// ---------------------------
// 💰 Component chính
// ---------------------------
const TripCostTable: React.FC = () => {
const [collapsed, setCollapsed] = useState(true);
const [contentHeight, setContentHeight] = useState<number>(0);
const animatedHeight = useRef(new Animated.Value(0)).current;
const tongCong = data.reduce((sum, item) => sum + item.tongChiPhi, 0);
const renderItem: ListRenderItem<CostItem> = ({ item }) => (
<View style={styles.row}>
<Text style={[styles.cell, styles.left]}>{item.loai}</Text>
<Text style={[styles.cell, styles.highlight]}>
{item.tongChiPhi.toLocaleString()}
</Text>
</View>
);
const handleToggle = () => {
const toValue = collapsed ? contentHeight : 0;
Animated.timing(animatedHeight, {
toValue,
duration: 300,
useNativeDriver: false,
}).start();
setCollapsed((prev) => !prev);
};
return (
<View style={styles.container}>
<Text style={styles.title}>Chi phí chuyến đi</Text>
<TouchableOpacity
activeOpacity={0.7}
onPress={handleToggle}
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
// marginBottom: 12,
}}
>
<Text style={styles.title}>Chi phí chuyến đi</Text>
{collapsed && (
<Text
style={[
styles.title,
{ color: "#ff6600", fontWeight: "bold", marginLeft: 8 },
]}
>
{tongCong.toLocaleString()}
</Text>
)}
<IconSymbol
name={collapsed ? "arrowshape.down.fill" : "arrowshape.up.fill"}
size={15}
color="#000000"
/>
</TouchableOpacity>
{/* Header */}
<View style={[styles.row, styles.header]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>Loại</Text>
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
{/* Nội dung ẩn để đo chiều cao */}
<View
style={{ position: "absolute", opacity: 0, zIndex: -1000 }}
onLayout={(event) => {
const height = event.nativeEvent.layout.height;
if (height > 0 && contentHeight === 0) {
setContentHeight(height);
}
}}
>
{/* Header */}
<View style={[styles.row, styles.header]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>
Loại
</Text>
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
</View>
{/* Body */}
{data.map((item) => (
<View key={item.id} style={styles.row}>
<Text style={[styles.cell, styles.left]}>{item.loai}</Text>
<Text style={[styles.cell, styles.right]}>
{item.tongChiPhi.toLocaleString()}
</Text>
</View>
))}
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
</Text>
<Text style={[styles.cell, styles.total]}>
{tongCong.toLocaleString()}
</Text>
</View>
</View>
{/* Body */}
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item) => item.id}
/>
<Animated.View style={{ height: animatedHeight, overflow: "hidden" }}>
{/* Header */}
<View style={[styles.row, styles.header]}>
<Text style={[styles.cell, styles.left, styles.headerText]}>
Loại
</Text>
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
</View>
{/* Footer */}
<View style={[styles.row, styles.footer]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
</Text>
<Text style={[styles.cell, styles.total]}>
{tongCong.toLocaleString()}
</Text>
</View>
{/* Body */}
{data.map((item) => (
<View key={item.id} style={styles.row}>
<Text style={[styles.cell, styles.left]}>{item.loai}</Text>
<Text style={[styles.cell, styles.right]}>
{item.tongChiPhi.toLocaleString()}
</Text>
</View>
))}
{/* Footer */}
<View style={[styles.row]}>
<Text style={[styles.cell, styles.left, styles.footerText]}>
Tổng cộng
</Text>
<Text style={[styles.cell, styles.total]}>
{tongCong.toLocaleString()}
</Text>
</View>
</Animated.View>
</View>
);
};

View File

@@ -0,0 +1,67 @@
import { StyleSheet } from "react-native";
export default StyleSheet.create({
container: {
width: "100%",
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginVertical: 10,
borderWidth: 1,
borderColor: "#fff",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
title: {
fontSize: 18,
fontWeight: "700",
},
totalCollapsed: {
color: "#ff6600",
fontSize: 18,
fontWeight: "700",
textAlign: "center",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 8,
borderBottomWidth: 0.5,
borderBottomColor: "#eee",
},
tableHeader: {
backgroundColor: "#fafafa",
borderRadius: 6,
marginTop: 10,
},
cell: {
flex: 1,
fontSize: 15,
color: "#111",
textAlign: "center",
},
left: {
textAlign: "center",
},
right: {
textAlign: "center",
},
headerText: {
fontWeight: "600",
},
footerText: {
color: "#007bff",
fontWeight: "600",
},
footerTotal: {
color: "#ff6600",
fontWeight: "800",
},
});

View File

@@ -0,0 +1,66 @@
import { StyleSheet } from "react-native";
export default StyleSheet.create({
container: {
width: "100%",
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginVertical: 10,
borderWidth: 1,
borderColor: "#fff",
shadowColor: "#000",
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
title: {
fontSize: 18,
fontWeight: "700",
},
totalCollapsed: {
color: "#ff6600",
fontSize: 18,
fontWeight: "700",
textAlign: "center",
},
row: {
flexDirection: "row",
paddingVertical: 8,
borderBottomWidth: 0.5,
borderBottomColor: "#eee",
paddingLeft: 15,
},
cell: {
flex: 1,
fontSize: 15,
color: "#111",
},
left: {
textAlign: "left",
},
right: {
textAlign: "center",
},
tableHeader: {
backgroundColor: "#fafafa",
borderRadius: 6,
marginTop: 10,
},
headerText: {
fontWeight: "600",
},
footerText: {
color: "#007bff",
fontWeight: "600",
},
footerTotal: {
color: "#ff6600",
fontWeight: "800",
},
});

View File

@@ -0,0 +1,77 @@
import { StyleSheet } from "react-native";
export default StyleSheet.create({
container: {
width: "100%",
backgroundColor: "#fff",
borderRadius: 10,
padding: 12,
marginVertical: 10,
borderWidth: 1,
borderColor: "#eee",
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 1,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
title: {
fontSize: 18,
fontWeight: "700",
},
totalCollapsed: {
color: "#ff6600",
fontSize: 18,
fontWeight: "700",
textAlign: "center",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 8,
borderBottomWidth: 0.6,
borderBottomColor: "#eee",
},
tableHeader: {
backgroundColor: "#fafafa",
borderRadius: 6,
marginTop: 10,
},
cell: {
flex: 1,
fontSize: 15,
color: "#111",
textAlign: "center",
},
sttCell: {
flex: 0.3,
fontSize: 15,
color: "#111",
textAlign: "left",
paddingLeft: 10,
},
headerText: {
fontWeight: "600",
},
statusContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: "#2ecc71",
marginRight: 6,
},
statusText: {
fontSize: 15,
color: "#111",
},
});

View File

@@ -16,34 +16,35 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: "700",
textAlign: "center",
marginBottom: 12,
},
row: {
flexDirection: "row",
borderBottomWidth: 0.5,
borderColor: "#ddd",
paddingVertical: 8,
paddingLeft: 15,
},
cell: {
flex: 1,
textAlign: "center",
fontSize: 14,
fontSize: 15,
},
left: {
textAlign: "left",
},
right: {
color: "#ff6600",
fontWeight: "600",
},
header: {
backgroundColor: "#f8f8f8",
borderTopWidth: 1,
borderBottomWidth: 1,
marginTop: 10,
},
headerText: {
fontWeight: "600",
},
highlight: {
color: "#ff6600",
fontWeight: "600",
},
footer: {
marginTop: 6,
},
@@ -52,7 +53,7 @@ const styles = StyleSheet.create({
color: "#007bff",
},
total: {
color: "#007bff",
color: "#ff6600",
fontWeight: "700",
},
});

View File

@@ -23,6 +23,11 @@ const MAPPING = {
"chevron.right": "chevron-right",
"ferry.fill": "directions-boat",
"map.fill": "map",
"arrowshape.down.fill": "arrow-drop-down",
"arrowshape.up.fill": "arrow-drop-up",
"exclamationmark.triangle.fill": "warning",
"book.closed.fill": "book",
"dot.radiowaves.left.and.right": "sensors",
} as IconMapping;
/**

View File

@@ -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",
},
@@ -53,7 +53,6 @@ api.interceptors.response.use(
return response;
},
(error) => {
if (!error.response) {
const networkErrorMsg =
error.message || "Network error - please check connection";

View File

@@ -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";

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -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;
}
}

View File

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

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

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

32
package-lock.json generated
View File

@@ -37,7 +37,8 @@
"react-native-screens": "~4.16.0",
"react-native-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
}
}
}
}
}

View File

@@ -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",

View File

@@ -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
View File

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

88
utils/geom.ts Normal file
View File

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

157
utils/polyline.ts Normal file
View File

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

11
utils/tranform.ts Normal file
View File

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