Compare commits
9 Commits
f9ca9542c4
...
5801992eae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5801992eae | ||
| 2fac0b8093 | |||
| 5307f44a34 | |||
| 16149068d2 | |||
| 7610a48a6e | |||
| 4e5abc21e9 | |||
| c3787e49c1 | |||
| a236b80cdd | |||
| 5c6758d2c2 |
@@ -1,5 +1,4 @@
|
|||||||
import { Tabs } from "expo-router";
|
import { Tabs } from "expo-router";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { HapticTab } from "@/components/haptic-tab";
|
import { HapticTab } from "@/components/haptic-tab";
|
||||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
@@ -26,21 +25,35 @@ export default function TabLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="explore"
|
name="tripInfo"
|
||||||
options={{
|
options={{
|
||||||
title: "Explore",
|
title: "Chuyến Đi",
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => (
|
||||||
<IconSymbol size={28} name="paperplane.fill" color={color} />
|
<IconSymbol size={28} name="ferry.fill" color={color} />
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="tripInfo"
|
name="diary"
|
||||||
options={{
|
options={{
|
||||||
title: "Thông Tin Chuyến Đi",
|
title: "Nhật Ký",
|
||||||
tabBarIcon: ({ color }) => (
|
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
35
app/(tabs)/diary.tsx
Normal 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 Ký 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",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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'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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,33 +1,70 @@
|
|||||||
|
import { PolygonWithLabel } from "@/components/map/PolygonWithLabel";
|
||||||
|
import { PolylineWithLabel } from "@/components/map/PolylineWithLabel";
|
||||||
import { showToastError } from "@/config";
|
import { showToastError } from "@/config";
|
||||||
|
import {
|
||||||
|
AUTO_REFRESH_INTERVAL,
|
||||||
|
ENTITY,
|
||||||
|
IOS_PLATFORM,
|
||||||
|
LIGHT_THEME,
|
||||||
|
} from "@/constants";
|
||||||
import {
|
import {
|
||||||
queryAlarm,
|
queryAlarm,
|
||||||
|
queryEntities,
|
||||||
queryGpsData,
|
queryGpsData,
|
||||||
queryTrackPoints,
|
queryTrackPoints,
|
||||||
} from "@/controller/DeviceController";
|
} from "@/controller/DeviceController";
|
||||||
|
import { useColorScheme } from "@/hooks/use-color-scheme.web";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
import { getShipIcon } from "@/services/map_service";
|
import { getShipIcon } from "@/services/map_service";
|
||||||
|
import { useBanzones } from "@/state/use-banzones";
|
||||||
|
import {
|
||||||
|
convertWKTLineStringToLatLngArray,
|
||||||
|
convertWKTtoLatLngString,
|
||||||
|
} from "@/utils/geom";
|
||||||
import { Image as ExpoImage } from "expo-image";
|
import { Image as ExpoImage } from "expo-image";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
import MapView, { Circle, Marker } from "react-native-maps";
|
import MapView, { Circle, Marker } from "react-native-maps";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const testPolyline =
|
||||||
|
"MULTIPOLYGON(((108.7976074 17.5392966,110.390625 14.2217886,109.4677734 10.8548863,112.9227161 10.6933337,116.4383411 12.565622,116.8997669 17.0466095,109.8685169 17.8013229,108.7973446 17.5393669,108.7976074 17.5392966)))";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null);
|
const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null);
|
||||||
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
||||||
const [trackPoints, setTrackPoints] = useState<Model.ShipTrackPoint[] | null>(
|
const [trackPoints, setTrackPoints] = useState<Model.ShipTrackPoint[] | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [circleRadius, setCircleRadius] = useState(100);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(10);
|
||||||
|
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||||
|
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
||||||
|
number[][] | null
|
||||||
|
>(null);
|
||||||
|
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
||||||
|
number[][][] | null
|
||||||
|
>(null);
|
||||||
|
const [, setZoneGeometries] = useState<Map<string, any>>(new Map());
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const platform = usePlatform();
|
||||||
|
const theme = useColorScheme();
|
||||||
|
const { banzones, getBanzone } = useBanzones();
|
||||||
|
const banzonesRef = useRef(banzones);
|
||||||
|
// console.log("Platform: ", platform);
|
||||||
|
// console.log("Theme: ", theme);
|
||||||
|
|
||||||
const getGpsData = async () => {
|
const getGpsData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await queryGpsData();
|
const response = await queryGpsData();
|
||||||
console.log("GpsData: ", response.data);
|
// console.log("GpsData: ", response.data);
|
||||||
console.log(
|
// console.log(
|
||||||
"Heading value:",
|
// "Heading value:",
|
||||||
response.data?.h,
|
// response.data?.h,
|
||||||
"Type:",
|
// "Type:",
|
||||||
typeof response.data?.h
|
// typeof response.data?.h
|
||||||
);
|
// );
|
||||||
setGpsData(response.data);
|
setGpsData(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching GPS data:", error);
|
console.error("Error fetching GPS data:", error);
|
||||||
@@ -35,10 +72,84 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const drawPolyline = () => {
|
||||||
|
const data = convertWKTtoLatLngString(testPolyline);
|
||||||
|
console.log("Data: ", data);
|
||||||
|
// setPolygonCoordinates(data[0]);
|
||||||
|
// ;
|
||||||
|
// console.log("Banzones: ", banzones.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
banzonesRef.current = banzones;
|
||||||
|
}, [banzones]);
|
||||||
|
|
||||||
|
const areGeometriesEqual = (
|
||||||
|
left?: {
|
||||||
|
geom_type: number;
|
||||||
|
geom_lines?: string | null;
|
||||||
|
geom_poly?: string | null;
|
||||||
|
},
|
||||||
|
right?: {
|
||||||
|
geom_type: number;
|
||||||
|
geom_lines?: string | null;
|
||||||
|
geom_poly?: string | null;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
if (!left && !right) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!left || !right) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
left.geom_type === right.geom_type &&
|
||||||
|
(left.geom_lines || "") === (right.geom_lines || "") &&
|
||||||
|
(left.geom_poly || "") === (right.geom_poly || "")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const areCoordinatesEqual = (
|
||||||
|
current: number[][] | null,
|
||||||
|
next: number[][] | null
|
||||||
|
) => {
|
||||||
|
if (!current || !next || current.length !== next.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.every(
|
||||||
|
(coord, index) =>
|
||||||
|
coord[0] === next[index][0] && coord[1] === next[index][1]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const areMultiPolygonCoordinatesEqual = (
|
||||||
|
current: number[][][] | null,
|
||||||
|
next: number[][][] | null
|
||||||
|
) => {
|
||||||
|
if (!current || !next || current.length !== next.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.every((polygon, polyIndex) => {
|
||||||
|
const nextPolygon = next[polyIndex];
|
||||||
|
if (!nextPolygon || polygon.length !== nextPolygon.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return polygon.every(
|
||||||
|
(coord, coordIndex) =>
|
||||||
|
coord[0] === nextPolygon[coordIndex][0] &&
|
||||||
|
coord[1] === nextPolygon[coordIndex][1]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getAlarmData = async () => {
|
const getAlarmData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await queryAlarm();
|
const response = await queryAlarm();
|
||||||
console.log("AlarmData: ", response.data);
|
// console.log("AlarmData: ", response.data);
|
||||||
setAlarmData(response.data);
|
setAlarmData(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching Alarm Data: ", error);
|
console.error("Error fetching Alarm Data: ", error);
|
||||||
@@ -46,13 +157,160 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getEntities = async () => {
|
||||||
|
try {
|
||||||
|
const entities = await queryEntities();
|
||||||
|
if (!entities) {
|
||||||
|
// Clear tất cả khu vực khi không có dữ liệu
|
||||||
|
setPolylineCoordinates(null);
|
||||||
|
setPolygonCoordinates(null);
|
||||||
|
setZoneGeometries(new Map());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBanzones = banzonesRef.current || [];
|
||||||
|
let nextPolyline: number[][] | null = null;
|
||||||
|
let nextMultiPolygon: number[][][] | null = null;
|
||||||
|
let foundPolyline = false;
|
||||||
|
let foundPolygon = false;
|
||||||
|
|
||||||
|
// Process zones để tìm geometries
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let zones: any[] = [];
|
||||||
|
try {
|
||||||
|
zones = entity.valueString ? JSON.parse(entity.valueString) : [];
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error("Error parsing zone list:", parseError);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu danh sách zone rỗng, clear tất cả
|
||||||
|
if (zones.length === 0) {
|
||||||
|
setPolylineCoordinates(null);
|
||||||
|
setPolygonCoordinates(null);
|
||||||
|
setZoneGeometries(new Map());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const zone of zones) {
|
||||||
|
const geom = currentBanzones.find((b) => b.id === zone.zone_id);
|
||||||
|
if (!geom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
||||||
|
if (typeof geom_type !== "number") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geom_type === 2) {
|
||||||
|
foundPolyline = true;
|
||||||
|
const coordinates = convertWKTLineStringToLatLngArray(
|
||||||
|
geom_lines || ""
|
||||||
|
);
|
||||||
|
if (coordinates.length > 0) {
|
||||||
|
nextPolyline = coordinates;
|
||||||
|
}
|
||||||
|
} else if (geom_type === 1) {
|
||||||
|
foundPolygon = true;
|
||||||
|
const coordinates = convertWKTtoLatLngString(geom_poly || "");
|
||||||
|
if (coordinates.length > 0) {
|
||||||
|
console.log("Polygon Coordinate: ", coordinates);
|
||||||
|
nextMultiPolygon = coordinates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state sau khi đã process xong
|
||||||
|
setZoneGeometries((prevGeometries) => {
|
||||||
|
const updated = new Map(prevGeometries);
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let zones: any[] = [];
|
||||||
|
try {
|
||||||
|
zones = entity.valueString ? JSON.parse(entity.valueString) : [];
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zones.length === 0) {
|
||||||
|
if (updated.size > 0) {
|
||||||
|
hasChanges = true;
|
||||||
|
updated.clear();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const zone of zones) {
|
||||||
|
const geom = currentBanzones.find((b) => b.id === zone.zone_id);
|
||||||
|
if (!geom) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
||||||
|
if (typeof geom_type !== "number") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${zone.zone_id}_${geom_type}`;
|
||||||
|
const newGeomData = { geom_type, geom_lines, geom_poly };
|
||||||
|
const oldGeom = updated.get(key);
|
||||||
|
|
||||||
|
if (!areGeometriesEqual(oldGeom, newGeomData)) {
|
||||||
|
hasChanges = true;
|
||||||
|
updated.set(key, newGeomData);
|
||||||
|
console.log("Geometry changed", { key, oldGeom, newGeomData });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasChanges ? updated : prevGeometries;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cập nhật hoặc clear polyline
|
||||||
|
if (foundPolyline && nextPolyline) {
|
||||||
|
setPolylineCoordinates((prev) =>
|
||||||
|
areCoordinatesEqual(prev, nextPolyline) ? prev : nextPolyline
|
||||||
|
);
|
||||||
|
} else if (!foundPolyline) {
|
||||||
|
console.log("Hết cảnh báo qua polyline");
|
||||||
|
setPolylineCoordinates(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật hoặc clear polygon
|
||||||
|
if (foundPolygon && nextMultiPolygon) {
|
||||||
|
setPolygonCoordinates((prev) =>
|
||||||
|
areMultiPolygonCoordinatesEqual(prev, nextMultiPolygon)
|
||||||
|
? prev
|
||||||
|
: nextMultiPolygon
|
||||||
|
);
|
||||||
|
} else if (!foundPolygon) {
|
||||||
|
console.log("Hết cảnh báo qua polygon");
|
||||||
|
setPolygonCoordinates(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Entities: ", error);
|
||||||
|
// Clear tất cả khi có lỗi
|
||||||
|
setPolylineCoordinates(null);
|
||||||
|
setPolygonCoordinates(null);
|
||||||
|
setZoneGeometries(new Map());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getShipTrackPoints = async () => {
|
const getShipTrackPoints = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await queryTrackPoints();
|
const response = await queryTrackPoints();
|
||||||
console.log(
|
// console.log("TrackPoints Data Length: ", response.data.length);
|
||||||
"TrackPoints Data: ",
|
|
||||||
response.data[response.data.length - 1]
|
|
||||||
);
|
|
||||||
setTrackPoints(response.data);
|
setTrackPoints(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching TrackPoints Data: ", error);
|
console.error("Error fetching TrackPoints Data: ", error);
|
||||||
@@ -60,75 +318,113 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMapReady = () => {
|
const fetchAllData = async () => {
|
||||||
console.log("Map loaded successfully!");
|
await Promise.all([
|
||||||
getGpsData();
|
getGpsData(),
|
||||||
getAlarmData();
|
getAlarmData(),
|
||||||
getShipTrackPoints();
|
getShipTrackPoints(),
|
||||||
|
getEntities(),
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tính toán region để bao phủ cả GPS và track points
|
const setupAutoRefresh = () => {
|
||||||
|
// Clear existing interval if any
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new interval to refresh data every 5 seconds
|
||||||
|
// Không fetch banzones vì dữ liệu không thay đổi
|
||||||
|
intervalRef.current = setInterval(async () => {
|
||||||
|
// console.log("Auto-refreshing data...");
|
||||||
|
await fetchAllData();
|
||||||
|
}, AUTO_REFRESH_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapReady = () => {
|
||||||
|
// console.log("Map loaded successfully!");
|
||||||
|
// Gọi fetchAllData ngay lập tức (không cần đợi banzones)
|
||||||
|
fetchAllData();
|
||||||
|
setupAutoRefresh();
|
||||||
|
// Set isFirstLoad to false sau khi map ready để chỉ zoom lần đầu tiên
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsFirstLoad(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup interval on component unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBanzone();
|
||||||
|
}, [getBanzone]);
|
||||||
|
|
||||||
|
// Hàm tính radius cố định khi zoom change
|
||||||
|
const calculateRadiusFromZoom = (zoom: number) => {
|
||||||
|
const baseZoom = 10;
|
||||||
|
const baseRadius = 100;
|
||||||
|
const zoomDifference = baseZoom - zoom;
|
||||||
|
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
|
||||||
|
// console.log("Caculate Radius: ", calculatedRadius);
|
||||||
|
|
||||||
|
return Math.max(calculatedRadius, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý khi region (zoom) thay đổi
|
||||||
|
const handleRegionChangeComplete = (newRegion: any) => {
|
||||||
|
// Tính zoom level từ latitudeDelta
|
||||||
|
// zoom = log2(360 / (latitudeDelta * 2)) + 8
|
||||||
|
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
|
||||||
|
const newRadius = calculateRadiusFromZoom(zoom);
|
||||||
|
setCircleRadius(newRadius);
|
||||||
|
setZoomLevel(zoom);
|
||||||
|
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tính toán region để focus vào marker của tàu (chỉ lần đầu tiên)
|
||||||
const getMapRegion = () => {
|
const getMapRegion = () => {
|
||||||
if (!gpsData && (!trackPoints || trackPoints.length === 0)) {
|
if (!isFirstLoad) {
|
||||||
|
// Sau lần đầu, return undefined để không force region
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!gpsData) {
|
||||||
return {
|
return {
|
||||||
latitude: 15.70581,
|
latitude: 15.70581,
|
||||||
longitude: 116.152685,
|
longitude: 116.152685,
|
||||||
latitudeDelta: 2,
|
latitudeDelta: 0.05,
|
||||||
longitudeDelta: 2,
|
longitudeDelta: 0.05,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let minLat = gpsData?.lat ?? 90;
|
|
||||||
let maxLat = gpsData?.lat ?? -90;
|
|
||||||
let minLon = gpsData?.lon ?? 180;
|
|
||||||
let maxLon = gpsData?.lon ?? -180;
|
|
||||||
|
|
||||||
// Bao gồm track points
|
|
||||||
if (trackPoints) {
|
|
||||||
trackPoints.forEach((point) => {
|
|
||||||
minLat = Math.min(minLat, point.lat);
|
|
||||||
maxLat = Math.max(maxLat, point.lat);
|
|
||||||
minLon = Math.min(minLon, point.lon);
|
|
||||||
maxLon = Math.max(maxLon, point.lon);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const latDelta = Math.max(maxLat - minLat, 0.01) * 1.2; // Padding 20%
|
|
||||||
const lonDelta = Math.max(maxLon - minLon, 0.01) * 1.2;
|
|
||||||
|
|
||||||
console.log("Map region:", {
|
|
||||||
minLat,
|
|
||||||
maxLat,
|
|
||||||
minLon,
|
|
||||||
maxLon,
|
|
||||||
latDelta,
|
|
||||||
lonDelta,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
latitude: (minLat + maxLat) / 2,
|
latitude: gpsData.lat,
|
||||||
longitude: (minLon + maxLon) / 2,
|
longitude: gpsData.lon,
|
||||||
latitudeDelta: latDelta,
|
latitudeDelta: 0.05,
|
||||||
longitudeDelta: lonDelta,
|
longitudeDelta: 0.05,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider style={styles.container}>
|
<SafeAreaProvider style={styles.container}>
|
||||||
|
{banzones.length > 0 && (
|
||||||
|
<Text className="hidden">Banzones loaded: {banzones.length}</Text>
|
||||||
|
)}
|
||||||
<MapView
|
<MapView
|
||||||
onMapReady={handleMapReady}
|
onMapReady={handleMapReady}
|
||||||
onPoiClick={(point) => {
|
onPoiClick={(point) => {
|
||||||
console.log("Poi clicked: ", point.nativeEvent);
|
console.log("Poi clicked: ", point.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
onRegionChangeComplete={handleRegionChangeComplete}
|
||||||
style={styles.map}
|
style={styles.map}
|
||||||
initialRegion={{
|
// initialRegion={getMapRegion()}
|
||||||
latitude: 15.70581,
|
|
||||||
longitude: 116.152685,
|
|
||||||
latitudeDelta: 2,
|
|
||||||
longitudeDelta: 2,
|
|
||||||
}}
|
|
||||||
region={getMapRegion()}
|
region={getMapRegion()}
|
||||||
// userInterfaceStyle="dark"
|
userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
|
||||||
showsBuildings={false}
|
showsBuildings={false}
|
||||||
showsIndoors={false}
|
showsIndoors={false}
|
||||||
loadingEnabled={true}
|
loadingEnabled={true}
|
||||||
@@ -146,24 +442,74 @@ export default function HomeScreen() {
|
|||||||
longitude: point.lon,
|
longitude: point.lon,
|
||||||
}}
|
}}
|
||||||
zIndex={50}
|
zIndex={50}
|
||||||
radius={20} // Tăng từ 50 → 1000m
|
radius={circleRadius}
|
||||||
fillColor="rgba(241, 12, 65, 0.8)" // Tăng opacity từ 0.06 → 0.8
|
fillColor="rgba(16, 85, 201, 0.6)"
|
||||||
strokeColor="rgba(221, 240, 15, 0.8)"
|
strokeColor="rgba(16, 85, 201, 0.8)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{polylineCoordinates && (
|
||||||
|
<PolylineWithLabel
|
||||||
|
coordinates={polylineCoordinates.map((coord) => ({
|
||||||
|
latitude: coord[0],
|
||||||
|
longitude: coord[1],
|
||||||
|
}))}
|
||||||
|
label="Tuyến bờ"
|
||||||
|
strokeColor="#FF5733"
|
||||||
|
strokeWidth={4}
|
||||||
|
showDistance={false}
|
||||||
|
zIndex={50}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{polygonCoordinates && polygonCoordinates.length > 0 && (
|
||||||
|
<>
|
||||||
|
{polygonCoordinates.map((polygon, index) => {
|
||||||
|
// Tạo key ổn định từ tọa độ đầu tiên của polygon
|
||||||
|
const polygonKey =
|
||||||
|
polygon.length > 0
|
||||||
|
? `polygon-${polygon[0][0]}-${polygon[0][1]}-${index}`
|
||||||
|
: `polygon-${index}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PolygonWithLabel
|
||||||
|
key={polygonKey}
|
||||||
|
coordinates={polygon.map((coords) => ({
|
||||||
|
latitude: coords[0],
|
||||||
|
longitude: coords[1],
|
||||||
|
}))}
|
||||||
|
label="Test khu đánh bắt"
|
||||||
|
content="Thời gian cấm (từ tháng 1 đến tháng 12)"
|
||||||
|
fillColor="rgba(16, 85, 201, 0.6)"
|
||||||
|
strokeColor="rgba(16, 85, 201, 0.8)"
|
||||||
|
strokeWidth={2}
|
||||||
|
zIndex={50}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{gpsData && (
|
{gpsData && (
|
||||||
<Marker
|
<Marker
|
||||||
coordinate={{
|
coordinate={{
|
||||||
latitude: gpsData.lat,
|
latitude: gpsData.lat,
|
||||||
longitude: gpsData.lon,
|
longitude: gpsData.lon,
|
||||||
}}
|
}}
|
||||||
title="Tàu của mình"
|
title={
|
||||||
|
platform === IOS_PLATFORM
|
||||||
|
? "Tàu của mình - iOS"
|
||||||
|
: "Tàu của mình - Android"
|
||||||
|
}
|
||||||
zIndex={100}
|
zIndex={100}
|
||||||
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
>
|
>
|
||||||
<View
|
<View className="w-8 h-8 items-center justify-center">
|
||||||
|
<ExpoImage
|
||||||
|
source={getShipIcon(alarmData?.level || 0, gpsData.fishing)}
|
||||||
style={{
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
transform: [
|
transform: [
|
||||||
{
|
{
|
||||||
rotate: `${
|
rotate: `${
|
||||||
@@ -173,19 +519,13 @@ export default function HomeScreen() {
|
|||||||
}deg`,
|
}deg`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<ExpoImage
|
|
||||||
source={getShipIcon(alarmData?.level || 0, gpsData.fishing)}
|
|
||||||
style={{ width: 32, height: 32 }}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</Marker>
|
</Marker>
|
||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
<TouchableOpacity style={styles.button} onPress={handleMapReady}>
|
<TouchableOpacity style={styles.button} onPress={drawPolyline}>
|
||||||
<Text style={styles.buttonText}>Get GPS Data</Text>
|
<Text style={styles.buttonText}>Get GPS Data</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
@@ -200,6 +540,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
|
display: "none",
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 50,
|
top: 50,
|
||||||
right: 20,
|
right: 20,
|
||||||
|
|||||||
35
app/(tabs)/sensor.tsx
Normal file
35
app/(tabs)/sensor.tsx
Normal 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",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,22 +1,72 @@
|
|||||||
import { ThemedText } from "@/components/themed-text";
|
import ButtonCancelTrip from "@/components/ButtonCancelTrip";
|
||||||
import { ThemedView } from "@/components/themed-view";
|
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 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() {
|
export default function TripInfoScreen() {
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
<ThemedText type="title">Thông Tin Chuyến Đi</ThemedText>
|
<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 />
|
<TripCostTable />
|
||||||
</ThemedView>
|
<FishingToolsTable />
|
||||||
|
<CrewListTable />
|
||||||
|
<NetListTable />
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<ButtonCancelTrip />
|
||||||
|
<ButtonEndTrip />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
scrollContent: {
|
||||||
flex: 1,
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
width: "100%",
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingTop: 15,
|
||||||
|
paddingBottom: 10,
|
||||||
alignItems: "center",
|
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
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;
|
||||||
|
}
|
||||||
45
components/ButtonCancelTrip.tsx
Normal file
45
components/ButtonCancelTrip.tsx
Normal 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;
|
||||||
107
components/ButtonCreateNewHaulOrTrip.tsx
Normal file
107
components/ButtonCreateNewHaulOrTrip.tsx
Normal 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;
|
||||||
45
components/ButtonEndTrip.tsx
Normal file
45
components/ButtonEndTrip.tsx
Normal 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;
|
||||||
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
140
components/tripInfo/CrewListTable.tsx
Normal file
140
components/tripInfo/CrewListTable.tsx
Normal 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]}>
|
||||||
|
Mã đị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]}>
|
||||||
|
Mã đị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;
|
||||||
126
components/tripInfo/FishingToolsList.tsx
Normal file
126
components/tripInfo/FishingToolsList.tsx
Normal 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;
|
||||||
114
components/tripInfo/NetListTable.tsx
Normal file
114
components/tripInfo/NetListTable.tsx
Normal 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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||||
import { FlatList, ListRenderItem, Text, View } from "react-native";
|
import React, { useRef, useState } from "react";
|
||||||
import styles from "./TripCostTable.styles";
|
import { Animated, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import styles from "./style/TripCostTable.styles";
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// 🧩 Interface
|
// 🧩 Interface
|
||||||
@@ -55,37 +56,83 @@ const data: CostItem[] = [
|
|||||||
// ---------------------------
|
// ---------------------------
|
||||||
// 💰 Component chính
|
// 💰 Component chính
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
||||||
const TripCostTable: React.FC = () => {
|
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 tongCong = data.reduce((sum, item) => sum + item.tongChiPhi, 0);
|
||||||
|
|
||||||
const renderItem: ListRenderItem<CostItem> = ({ item }) => (
|
const handleToggle = () => {
|
||||||
<View style={styles.row}>
|
const toValue = collapsed ? contentHeight : 0;
|
||||||
<Text style={[styles.cell, styles.left]}>{item.loai}</Text>
|
Animated.timing(animatedHeight, {
|
||||||
<Text style={[styles.cell, styles.highlight]}>
|
toValue,
|
||||||
{item.tongChiPhi.toLocaleString()}
|
duration: 300,
|
||||||
</Text>
|
useNativeDriver: false,
|
||||||
</View>
|
}).start();
|
||||||
);
|
setCollapsed((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
<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>
|
<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>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Header */}
|
||||||
<View style={[styles.row, styles.header]}>
|
<View style={[styles.row, styles.header]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.headerText]}>Loại</Text>
|
<Text style={[styles.cell, styles.left, styles.headerText]}>
|
||||||
|
Loại
|
||||||
|
</Text>
|
||||||
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
|
<Text style={[styles.cell, styles.headerText]}>Tổng chi phí</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<FlatList
|
{data.map((item) => (
|
||||||
data={data}
|
<View key={item.id} style={styles.row}>
|
||||||
renderItem={renderItem}
|
<Text style={[styles.cell, styles.left]}>{item.loai}</Text>
|
||||||
keyExtractor={(item) => item.id}
|
<Text style={[styles.cell, styles.right]}>
|
||||||
/>
|
{item.tongChiPhi.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={[styles.row, styles.footer]}>
|
<View style={[styles.row]}>
|
||||||
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
<Text style={[styles.cell, styles.left, styles.footerText]}>
|
||||||
Tổng cộng
|
Tổng cộng
|
||||||
</Text>
|
</Text>
|
||||||
@@ -94,6 +141,37 @@ const TripCostTable: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
67
components/tripInfo/style/CrewListTable.styles.ts
Normal file
67
components/tripInfo/style/CrewListTable.styles.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
66
components/tripInfo/style/FishingToolsTable.styles.ts
Normal file
66
components/tripInfo/style/FishingToolsTable.styles.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
77
components/tripInfo/style/NetListTable.styles.ts
Normal file
77
components/tripInfo/style/NetListTable.styles.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -16,34 +16,35 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: 12,
|
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
borderBottomWidth: 0.5,
|
borderBottomWidth: 0.5,
|
||||||
borderColor: "#ddd",
|
borderColor: "#ddd",
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
|
paddingLeft: 15,
|
||||||
},
|
},
|
||||||
cell: {
|
cell: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontSize: 14,
|
fontSize: 15,
|
||||||
},
|
},
|
||||||
left: {
|
left: {
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
},
|
},
|
||||||
|
right: {
|
||||||
|
color: "#ff6600",
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
backgroundColor: "#f8f8f8",
|
backgroundColor: "#f8f8f8",
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
|
marginTop: 10,
|
||||||
},
|
},
|
||||||
headerText: {
|
headerText: {
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
},
|
},
|
||||||
highlight: {
|
|
||||||
color: "#ff6600",
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
footer: {
|
footer: {
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
},
|
},
|
||||||
@@ -52,7 +53,7 @@ const styles = StyleSheet.create({
|
|||||||
color: "#007bff",
|
color: "#007bff",
|
||||||
},
|
},
|
||||||
total: {
|
total: {
|
||||||
color: "#007bff",
|
color: "#ff6600",
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -23,6 +23,11 @@ const MAPPING = {
|
|||||||
"chevron.right": "chevron-right",
|
"chevron.right": "chevron-right",
|
||||||
"ferry.fill": "directions-boat",
|
"ferry.fill": "directions-boat",
|
||||||
"map.fill": "map",
|
"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;
|
} as IconMapping;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const codeMessage = {
|
|||||||
// Tạo instance axios với cấu hình cơ bản
|
// Tạo instance axios với cấu hình cơ bản
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: "http://192.168.30.102:81", // Thay bằng API thật của bạn
|
baseURL: "http://192.168.30.102:81", // Thay bằng API thật của bạn
|
||||||
timeout: 10000, // Timeout 10 giây
|
timeout: 20000, // Timeout 20 giây
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
@@ -53,7 +53,6 @@ api.interceptors.response.use(
|
|||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
const networkErrorMsg =
|
const networkErrorMsg =
|
||||||
error.message || "Network error - please check connection";
|
error.message || "Network error - please check connection";
|
||||||
|
|||||||
@@ -5,12 +5,23 @@ export const MAP_POLYLINE_BAN = "ban-polyline";
|
|||||||
export const MAP_POLYGON_BAN = "ban-polygon";
|
export const MAP_POLYGON_BAN = "ban-polygon";
|
||||||
|
|
||||||
// Global Constants
|
// Global Constants
|
||||||
|
export const IOS_PLATFORM = "ios";
|
||||||
|
export const ANDROID_PLATFORM = "android";
|
||||||
|
export const WEB_PLATFORM = "web";
|
||||||
|
export const AUTO_REFRESH_INTERVAL = 5000; // in milliseconds
|
||||||
|
export const LIGHT_THEME = "light";
|
||||||
|
export const DARK_THEME = "dark";
|
||||||
// Route Constants
|
// Route Constants
|
||||||
export const ROUTE_LOGIN = "/login";
|
export const ROUTE_LOGIN = "/login";
|
||||||
export const ROUTE_HOME = "/map";
|
export const ROUTE_HOME = "/map";
|
||||||
export const ROUTE_TRIP = "/trip";
|
export const ROUTE_TRIP = "/trip";
|
||||||
|
|
||||||
|
// Entity Contants
|
||||||
|
export const ENTITY = {
|
||||||
|
ZONE_ALARM_LIST: "50:2",
|
||||||
|
GPS: "50:1",
|
||||||
|
};
|
||||||
|
|
||||||
// API Path Constants
|
// API Path Constants
|
||||||
export const API_PATH_LOGIN = "/api/agent/login";
|
export const API_PATH_LOGIN = "/api/agent/login";
|
||||||
export const API_PATH_ENTITIES = "/api/io/entities";
|
export const API_PATH_ENTITIES = "/api/io/entities";
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { api } from "@/config";
|
|||||||
import {
|
import {
|
||||||
API_GET_ALARMS,
|
API_GET_ALARMS,
|
||||||
API_GET_GPS,
|
API_GET_GPS,
|
||||||
|
API_PATH_ENTITIES,
|
||||||
API_PATH_SHIP_TRACK_POINTS,
|
API_PATH_SHIP_TRACK_POINTS,
|
||||||
} from "@/constants";
|
} from "@/constants";
|
||||||
|
import { transformEntityResponse } from "@/utils/tranform";
|
||||||
|
|
||||||
export async function queryGpsData() {
|
export async function queryGpsData() {
|
||||||
return api.get<Model.GPSResonse>(API_GET_GPS);
|
return api.get<Model.GPSResonse>(API_GET_GPS);
|
||||||
@@ -16,3 +18,8 @@ export async function queryAlarm() {
|
|||||||
export async function queryTrackPoints() {
|
export async function queryTrackPoints() {
|
||||||
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
|
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function queryEntities(): Promise<Model.TransformedEntity[]> {
|
||||||
|
const response = await api.get<Model.EntityResponse[]>(API_PATH_ENTITIES);
|
||||||
|
return response.data.map(transformEntityResponse);
|
||||||
|
}
|
||||||
|
|||||||
6
controller/MapController.ts
Normal file
6
controller/MapController.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { api } from "@/config";
|
||||||
|
import { API_GET_ALL_BANZONES } from "@/constants";
|
||||||
|
|
||||||
|
export async function queryBanzones() {
|
||||||
|
return api.get<Model.Zone[]>(API_GET_ALL_BANZONES);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
import * as AuthController from "./AuthController";
|
import * as AuthController from "./AuthController";
|
||||||
|
import * as DeviceController from "./DeviceController";
|
||||||
export { AuthController };
|
import * as MapController from "./MapController";
|
||||||
|
export { AuthController, DeviceController, MapController };
|
||||||
|
|||||||
47
controller/typings.d.ts
vendored
47
controller/typings.d.ts
vendored
@@ -33,4 +33,51 @@ declare namespace Model {
|
|||||||
s: number;
|
s: number;
|
||||||
h: number;
|
h: number;
|
||||||
}
|
}
|
||||||
|
interface EntityResponse {
|
||||||
|
id: string;
|
||||||
|
v: number;
|
||||||
|
vs: string;
|
||||||
|
t: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
interface TransformedEntity {
|
||||||
|
id: string;
|
||||||
|
value: number;
|
||||||
|
valueString: string;
|
||||||
|
time: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banzones
|
||||||
|
// Banzone
|
||||||
|
export interface Zone {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: number;
|
||||||
|
conditions?: Condition[];
|
||||||
|
enabled?: boolean;
|
||||||
|
updated_at?: Date;
|
||||||
|
geom?: Geom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Condition {
|
||||||
|
max?: number;
|
||||||
|
min?: number;
|
||||||
|
type?: Type;
|
||||||
|
to?: number;
|
||||||
|
from?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Type {
|
||||||
|
LengthLimit = "length_limit",
|
||||||
|
MonthRange = "month_range",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Geom {
|
||||||
|
geom_type?: number;
|
||||||
|
geom_poly?: string;
|
||||||
|
geom_lines?: string;
|
||||||
|
geom_point?: string;
|
||||||
|
geom_radius?: number;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
hooks/use-fixed-circle-radius.ts
Normal file
62
hooks/use-fixed-circle-radius.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook để tính radius cố định cho Circle trên MapView
|
||||||
|
* Radius sẽ được điều chỉnh dựa trên zoom level để giữ kích thước pixel cố định
|
||||||
|
*/
|
||||||
|
export const useFixedCircleRadius = (pixelRadius: number = 30) => {
|
||||||
|
const [radius, setRadius] = useState(100); // Giá trị default
|
||||||
|
|
||||||
|
const calculateRadiusFromZoom = useCallback((zoomLevel: number) => {
|
||||||
|
// Công thức: radius (meters) = pixelRadius * 156543.04 * cos(latitude) / 2^(zoomLevel + 8)
|
||||||
|
// Đơn giản hơn: radius tỉ lệ với 2^(maxZoom - currentZoom)
|
||||||
|
// Khi zoom = 14, dùng radius = 100 làm reference
|
||||||
|
const baseZoom = 14;
|
||||||
|
const baseRadius = 100;
|
||||||
|
|
||||||
|
// Mỗi level zoom tương ứng với 2x sự khác biệt
|
||||||
|
const zoomDifference = baseZoom - zoomLevel;
|
||||||
|
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
|
||||||
|
|
||||||
|
return Math.max(calculatedRadius, 10); // Minimum 10 meters
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleZoomChange = useCallback(
|
||||||
|
(zoomLevel: number) => {
|
||||||
|
const newRadius = calculateRadiusFromZoom(zoomLevel);
|
||||||
|
setRadius(newRadius);
|
||||||
|
},
|
||||||
|
[calculateRadiusFromZoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
radius,
|
||||||
|
handleZoomChange,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alternative: Sử dụng Polygon thay vì Circle để có kích thước cố định theo pixel
|
||||||
|
* Tạo một hình tròn bằng Polygon với điểm tâm là coordinate
|
||||||
|
*/
|
||||||
|
export const createCircleCoordinates = (
|
||||||
|
center: { latitude: number; longitude: number },
|
||||||
|
radiusInMeters: number,
|
||||||
|
points: number = 36
|
||||||
|
) => {
|
||||||
|
const coordinates = [];
|
||||||
|
const latDelta = radiusInMeters / 111000; // 1 degree ~ 111km
|
||||||
|
|
||||||
|
for (let i = 0; i < points; i++) {
|
||||||
|
const angle = (i / points) * (2 * Math.PI);
|
||||||
|
const longitude =
|
||||||
|
center.longitude +
|
||||||
|
(latDelta * Math.cos(angle)) /
|
||||||
|
Math.cos((center.latitude * Math.PI) / 180);
|
||||||
|
const latitude = center.latitude + latDelta * Math.sin(angle);
|
||||||
|
|
||||||
|
coordinates.push({ latitude, longitude });
|
||||||
|
}
|
||||||
|
|
||||||
|
return coordinates;
|
||||||
|
};
|
||||||
23
hooks/use-platform.ts
Normal file
23
hooks/use-platform.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export type PlatformType = "ios" | "android" | "web";
|
||||||
|
|
||||||
|
export const usePlatform = (): PlatformType => {
|
||||||
|
return Platform.OS as PlatformType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIsIOS = (): boolean => {
|
||||||
|
return Platform.OS === "ios";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIsAndroid = (): boolean => {
|
||||||
|
return Platform.OS === "android";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIsWeb = (): boolean => {
|
||||||
|
return Platform.OS === "web";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlatform = (): PlatformType => {
|
||||||
|
return Platform.OS as PlatformType;
|
||||||
|
};
|
||||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -37,7 +37,8 @@
|
|||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-toast-message": "^2.3.3",
|
"react-native-toast-message": "^2.3.3",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.5.1",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
@@ -14240,6 +14241,35 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-toast-message": "^2.3.3",
|
"react-native-toast-message": "^2.3.3",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.5.1",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import shipWarningFishingIcon from "../assets/icons/ship_warning_fishing.png";
|
|||||||
import shipSosIcon from "../assets/icons/sos_icon.png";
|
import shipSosIcon from "../assets/icons/sos_icon.png";
|
||||||
|
|
||||||
export const getShipIcon = (type: number, isFishing: boolean) => {
|
export const getShipIcon = (type: number, isFishing: boolean) => {
|
||||||
console.log("type, isFishing", type, isFishing);
|
|
||||||
|
|
||||||
if (type === 1 && !isFishing) {
|
if (type === 1 && !isFishing) {
|
||||||
return shipWarningIcon;
|
return shipWarningIcon;
|
||||||
} else if (type === 2 && !isFishing) {
|
} else if (type === 2 && !isFishing) {
|
||||||
|
|||||||
27
state/use-banzones.ts
Normal file
27
state/use-banzones.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { queryBanzones } from "@/controller/MapController";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type Banzone = {
|
||||||
|
banzones: Model.Zone[];
|
||||||
|
getBanzone: () => Promise<void>;
|
||||||
|
error: string | null;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBanzones = create<Banzone>()((set) => ({
|
||||||
|
banzones: [],
|
||||||
|
getBanzone: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const response = await queryBanzones();
|
||||||
|
console.log("Banzone fetching: ", response.data.length);
|
||||||
|
|
||||||
|
set({ banzones: response.data, loading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error when fetch Banzones: ", error);
|
||||||
|
set({ error: "Failed to fetch banzone data", loading: false });
|
||||||
|
set({ banzones: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
88
utils/geom.ts
Normal file
88
utils/geom.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export const convertWKTPointToLatLng = (wktString: string) => {
|
||||||
|
if (
|
||||||
|
!wktString ||
|
||||||
|
typeof wktString !== "string" ||
|
||||||
|
!wktString.startsWith("POINT")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = wktString.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
|
||||||
|
if (!matched) return null;
|
||||||
|
|
||||||
|
const lng = parseFloat(matched[1]);
|
||||||
|
const lat = parseFloat(matched[2]);
|
||||||
|
|
||||||
|
return [lng, lat]; // [longitude, latitude]
|
||||||
|
};
|
||||||
|
export const convertWKTLineStringToLatLngArray = (wktString: string) => {
|
||||||
|
if (
|
||||||
|
!wktString ||
|
||||||
|
typeof wktString !== "string" ||
|
||||||
|
!wktString.startsWith("LINESTRING")
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = wktString.match(/LINESTRING\s*\((.*)\)/);
|
||||||
|
if (!matched) return [];
|
||||||
|
|
||||||
|
const coordinates = matched[1].split(",").map((coordStr) => {
|
||||||
|
const [x, y] = coordStr.trim().split(" ").map(Number);
|
||||||
|
return [y, x]; // [lat, lng]
|
||||||
|
});
|
||||||
|
|
||||||
|
return coordinates;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertWKTtoLatLngString = (wktString: string) => {
|
||||||
|
if (!wktString || typeof wktString !== "string") return [];
|
||||||
|
|
||||||
|
const clean = wktString.trim();
|
||||||
|
|
||||||
|
// MULTIPOLYGON
|
||||||
|
if (clean.startsWith("MULTIPOLYGON")) {
|
||||||
|
const matched = clean.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
|
||||||
|
if (!matched) return [];
|
||||||
|
|
||||||
|
const polygons = matched[1].split(")),((").map((polygonStr) =>
|
||||||
|
polygonStr
|
||||||
|
.trim()
|
||||||
|
.split(",")
|
||||||
|
.map((coordStr) => {
|
||||||
|
const [lng, lat] = coordStr.trim().split(/\s+/).map(Number);
|
||||||
|
return [lat, lng]; // Đảo ngược: [latitude, longitude]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return polygons; // Mỗi phần tử là 1 polygon (mảng các [lat, lng])
|
||||||
|
}
|
||||||
|
|
||||||
|
// POLYGON
|
||||||
|
if (clean.startsWith("POLYGON")) {
|
||||||
|
const matched = clean.match(/POLYGON\s*\(\((.*)\)\)/);
|
||||||
|
if (!matched) return [];
|
||||||
|
|
||||||
|
const polygon = matched[1].split(",").map((coordStr) => {
|
||||||
|
const [lng, lat] = coordStr.trim().split(/\s+/).map(Number);
|
||||||
|
return [lat, lng];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [polygon];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBanzoneNameByType = (type: number) => {
|
||||||
|
switch (type) {
|
||||||
|
case 1:
|
||||||
|
return "Cấm đánh bắt";
|
||||||
|
case 2:
|
||||||
|
return "Cấm di chuyển";
|
||||||
|
case 3:
|
||||||
|
return "Vùng an toàn";
|
||||||
|
default:
|
||||||
|
return "Chưa có";
|
||||||
|
}
|
||||||
|
};
|
||||||
157
utils/polyline.ts
Normal file
157
utils/polyline.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for Polyline
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LatLng {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tìm điểm ở giữa của polyline
|
||||||
|
*/
|
||||||
|
export const getMiddlePointOfPolyline = (coordinates: LatLng[]): LatLng => {
|
||||||
|
if (coordinates.length === 0) {
|
||||||
|
return { latitude: 0, longitude: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coordinates.length === 1) {
|
||||||
|
return coordinates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const middleIndex = Math.floor(coordinates.length / 2);
|
||||||
|
return coordinates[middleIndex];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tính toán điểm ở giữa của 2 điểm
|
||||||
|
*/
|
||||||
|
export const getMidpoint = (point1: LatLng, point2: LatLng): LatLng => {
|
||||||
|
return {
|
||||||
|
latitude: (point1.latitude + point2.latitude) / 2,
|
||||||
|
longitude: (point1.longitude + point2.longitude) / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tính khoảng cách giữa 2 điểm (Haversine formula)
|
||||||
|
* Trả về khoảng cách theo km
|
||||||
|
*/
|
||||||
|
export const calculateDistance = (point1: LatLng, point2: LatLng): number => {
|
||||||
|
const R = 6371; // Bán kính trái đất (km)
|
||||||
|
const dLat = (point2.latitude - point1.latitude) * (Math.PI / 180);
|
||||||
|
const dLon = (point2.longitude - point1.longitude) * (Math.PI / 180);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(point1.latitude * (Math.PI / 180)) *
|
||||||
|
Math.cos(point2.latitude * (Math.PI / 180)) *
|
||||||
|
Math.sin(dLon / 2) *
|
||||||
|
Math.sin(dLon / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tính tổng khoảng cách của polyline
|
||||||
|
*/
|
||||||
|
export const calculateTotalDistance = (coordinates: LatLng[]): number => {
|
||||||
|
if (coordinates.length < 2) return 0;
|
||||||
|
|
||||||
|
let totalDistance = 0;
|
||||||
|
for (let i = 0; i < coordinates.length - 1; i++) {
|
||||||
|
totalDistance += calculateDistance(coordinates[i], coordinates[i + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tính heading (hướng) giữa 2 điểm
|
||||||
|
* Trả về góc độ (0-360)
|
||||||
|
*/
|
||||||
|
export const calculateHeading = (point1: LatLng, point2: LatLng): number => {
|
||||||
|
const dLon = point2.longitude - point1.longitude;
|
||||||
|
const lat1 = point1.latitude * (Math.PI / 180);
|
||||||
|
const lat2 = point2.latitude * (Math.PI / 180);
|
||||||
|
const dLonRad = dLon * (Math.PI / 180);
|
||||||
|
|
||||||
|
const y = Math.sin(dLonRad) * Math.cos(lat2);
|
||||||
|
const x =
|
||||||
|
Math.cos(lat1) * Math.sin(lat2) -
|
||||||
|
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLonRad);
|
||||||
|
|
||||||
|
const bearing = Math.atan2(y, x) * (180 / Math.PI);
|
||||||
|
return (bearing + 360) % 360;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tính điểm trung tâm (centroid) của polygon
|
||||||
|
* Sử dụng thuật toán Shoelace formula để tính centroid chính xác
|
||||||
|
* Thuật toán này tính centroid dựa trên diện tích, phù hợp với polygon bất kỳ
|
||||||
|
*/
|
||||||
|
export const getPolygonCenter = (coordinates: LatLng[]): LatLng => {
|
||||||
|
if (coordinates.length === 0) {
|
||||||
|
return { latitude: 0, longitude: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coordinates.length === 1) {
|
||||||
|
return coordinates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coordinates.length === 2) {
|
||||||
|
return {
|
||||||
|
latitude: (coordinates[0].latitude + coordinates[1].latitude) / 2,
|
||||||
|
longitude: (coordinates[0].longitude + coordinates[1].longitude) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let area = 0;
|
||||||
|
let centroidLat = 0;
|
||||||
|
let centroidLon = 0;
|
||||||
|
|
||||||
|
// Đảm bảo polygon đóng (điểm đầu = điểm cuối)
|
||||||
|
const coords = [...coordinates];
|
||||||
|
if (
|
||||||
|
coords[0].latitude !== coords[coords.length - 1].latitude ||
|
||||||
|
coords[0].longitude !== coords[coords.length - 1].longitude
|
||||||
|
) {
|
||||||
|
coords.push(coords[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tính diện tích và centroid sử dụng Shoelace formula
|
||||||
|
for (let i = 0; i < coords.length - 1; i++) {
|
||||||
|
const lat1 = coords[i].latitude;
|
||||||
|
const lon1 = coords[i].longitude;
|
||||||
|
const lat2 = coords[i + 1].latitude;
|
||||||
|
const lon2 = coords[i + 1].longitude;
|
||||||
|
|
||||||
|
const cross = lat1 * lon2 - lon1 * lat2;
|
||||||
|
area += cross;
|
||||||
|
centroidLat += (lat1 + lat2) * cross;
|
||||||
|
centroidLon += (lon1 + lon2) * cross;
|
||||||
|
}
|
||||||
|
|
||||||
|
area = area / 2;
|
||||||
|
|
||||||
|
// Nếu diện tích quá nhỏ (polygon suy biến), dùng trung bình đơn giản
|
||||||
|
if (Math.abs(area) < 0.0000001) {
|
||||||
|
let latSum = 0;
|
||||||
|
let lonSum = 0;
|
||||||
|
for (const coord of coordinates) {
|
||||||
|
latSum += coord.latitude;
|
||||||
|
lonSum += coord.longitude;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
latitude: latSum / coordinates.length,
|
||||||
|
longitude: lonSum / coordinates.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
centroidLat = centroidLat / (6 * area);
|
||||||
|
centroidLon = centroidLon / (6 * area);
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: centroidLat,
|
||||||
|
longitude: centroidLon,
|
||||||
|
};
|
||||||
|
};
|
||||||
11
utils/tranform.ts
Normal file
11
utils/tranform.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function transformEntityResponse(
|
||||||
|
raw: Model.EntityResponse
|
||||||
|
): Model.TransformedEntity {
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
value: raw.v,
|
||||||
|
valueString: raw.vs,
|
||||||
|
time: raw.t,
|
||||||
|
type: raw.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user