545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
import DraggablePanel from "@/components/DraggablePanel";
|
|
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
|
|
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
|
|
import ShipInfo from "@/components/map/ShipInfo";
|
|
import { TagState, TagStateCallbackPayload } from "@/components/map/TagState";
|
|
import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants";
|
|
import { usePlatform } from "@/hooks/use-platform";
|
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
|
import { searchThingEventBus } from "@/services/device_events";
|
|
import { getShipIcon } from "@/services/map_service";
|
|
import eventBus from "@/utils/eventBus";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Animated,
|
|
Image,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
import MapView, { Marker } from "react-native-maps";
|
|
|
|
export default function HomeScreen() {
|
|
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
|
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
|
|
const [trackPointsData, setTrackPointsData] = useState<
|
|
Model.ShipTrackPoint[] | null
|
|
>(null);
|
|
const [circleRadius, setCircleRadius] = useState(100);
|
|
const [zoomLevel, setZoomLevel] = useState(10);
|
|
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
|
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
|
PolylineWithLabelProps[]
|
|
>([]);
|
|
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
|
PolygonWithLabelProps[]
|
|
>([]);
|
|
const [things, setThings] = useState<Model.ThingsResponse | null>(null);
|
|
const platform = usePlatform();
|
|
const theme = useThemeContext().colorScheme;
|
|
const scale = useRef(new Animated.Value(0)).current;
|
|
const opacity = useRef(new Animated.Value(1)).current;
|
|
|
|
// Thêm state để quản lý tracksViewChanges
|
|
const [tracksViewChanges, setTracksViewChanges] = useState(true);
|
|
|
|
const bodySearchThings: Model.SearchThingBody = {
|
|
offset: 0,
|
|
limit: 50,
|
|
order: "name",
|
|
sort: "asc",
|
|
metadata: {
|
|
not_empty: "ship_id",
|
|
},
|
|
};
|
|
|
|
useEffect(() => {
|
|
searchThingEventBus(bodySearchThings);
|
|
const querySearchThingsData = (thingsData: Model.ThingsResponse) => {
|
|
const sortedThings: Model.Thing[] = (thingsData.things ?? []).sort(
|
|
(a, b) => {
|
|
const stateLevelA = a.metadata?.state_level || 0;
|
|
const stateLevelB = b.metadata?.state_level || 0;
|
|
return stateLevelB - stateLevelA; // Giảm dần
|
|
}
|
|
);
|
|
const sortedThingsResponse: Model.ThingsResponse = {
|
|
...thingsData,
|
|
things: sortedThings,
|
|
};
|
|
console.log("Things Updated: ", sortedThingsResponse.things?.length);
|
|
|
|
setThings(sortedThingsResponse);
|
|
};
|
|
eventBus.on(EVENT_SEARCH_THINGS, querySearchThingsData);
|
|
|
|
return () => {
|
|
eventBus.off(EVENT_SEARCH_THINGS, querySearchThingsData);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (things) {
|
|
// console.log("Things Updated: ", things.things?.length);
|
|
// const gpsDatas: Model.GPSResponse[] = [];
|
|
// for (const thing of things.things || []) {
|
|
// if (thing.metadata?.gps) {
|
|
// const gps: Model.GPSResponse = JSON.parse(thing.metadata.gps);
|
|
// gpsDatas.push(gps);
|
|
// }
|
|
// }
|
|
// console.log("GPS Lenght: ", gpsDatas.length);
|
|
// setGpsData(gpsDatas);
|
|
}
|
|
}, [things]);
|
|
|
|
// useEffect(() => {
|
|
// setPolylineCoordinates([]);
|
|
// setPolygonCoordinates([]);
|
|
// if (!entityData) return;
|
|
// if (!banzoneData) return;
|
|
// for (const entity of entityData) {
|
|
// 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([]);
|
|
// setPolygonCoordinates([]);
|
|
// return;
|
|
// }
|
|
|
|
// let polylines: PolylineWithLabelProps[] = [];
|
|
// let polygons: PolygonWithLabelProps[] = [];
|
|
|
|
// for (const zone of zones) {
|
|
// // console.log("Zone Data: ", zone);
|
|
// const geom = banzoneData.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) {
|
|
// // if(oldEntityData.find(e => e.id === ))
|
|
// // foundPolyline = true;
|
|
// const coordinates = convertWKTLineStringToLatLngArray(
|
|
// geom_lines || ""
|
|
// );
|
|
// if (coordinates.length > 0) {
|
|
// polylines.push({
|
|
// coordinates: coordinates.map((coord) => ({
|
|
// latitude: coord[0],
|
|
// longitude: coord[1],
|
|
// })),
|
|
// label: zone?.zone_name ?? "",
|
|
// content: zone?.message ?? "",
|
|
// });
|
|
// } else {
|
|
// console.log("Không tìm thấy polyline trong alarm");
|
|
// }
|
|
// } else if (geom_type === 1) {
|
|
// // foundPolygon = true;
|
|
// const coordinates = convertWKTtoLatLngString(geom_poly || "");
|
|
// if (coordinates.length > 0) {
|
|
// // console.log("Polygon Coordinate: ", coordinates);
|
|
// const zonePolygons = coordinates.map((polygon) => ({
|
|
// coordinates: polygon.map((coord) => ({
|
|
// latitude: coord[0],
|
|
// longitude: coord[1],
|
|
// })),
|
|
// label: zone?.zone_name ?? "",
|
|
// content: zone?.message ?? "",
|
|
// }));
|
|
// polygons.push(...zonePolygons);
|
|
// } else {
|
|
// console.log("Không tìm thấy polygon trong alarm");
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// setPolylineCoordinates(polylines);
|
|
// setPolygonCoordinates(polygons);
|
|
// }
|
|
// }, [banzoneData, entityData]);
|
|
|
|
// Hàm tính radius cố định khi zoom change
|
|
const calculateRadiusFromZoom = (zoom: number) => {
|
|
const baseZoom = 10;
|
|
const baseRadius = 100;
|
|
const zoomDifference = baseZoom - zoom;
|
|
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
|
|
// console.log("Caculate Radius: ", calculatedRadius);
|
|
|
|
return Math.max(calculatedRadius, 50);
|
|
};
|
|
|
|
// Xử lý khi region (zoom) thay đổi
|
|
const handleRegionChangeComplete = (newRegion: any) => {
|
|
// Tính zoom level từ latitudeDelta
|
|
// zoom = log2(360 / (latitudeDelta * 2)) + 8
|
|
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
|
|
const newRadius = calculateRadiusFromZoom(zoom);
|
|
setCircleRadius(newRadius);
|
|
setZoomLevel(zoom);
|
|
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
|
|
};
|
|
|
|
// Tính toán region để focus vào marker của tàu (chỉ lần đầu tiên)
|
|
const getMapRegion = () => {
|
|
if (!isFirstLoad) {
|
|
// Sau lần đầu, return undefined để không force region
|
|
return undefined;
|
|
}
|
|
if (things?.things?.length === 0) {
|
|
return {
|
|
latitude: 15.70581,
|
|
longitude: 116.152685,
|
|
latitudeDelta: 0.05,
|
|
longitudeDelta: 0.05,
|
|
};
|
|
}
|
|
|
|
return {
|
|
latitude: things?.things?.[0]?.metadata?.gps
|
|
? JSON.parse(things.things[0].metadata.gps).lat
|
|
: 15.70581,
|
|
longitude: things?.things?.[0]?.metadata?.gps
|
|
? JSON.parse(things.things[0].metadata.gps).lon
|
|
: 116.152685,
|
|
latitudeDelta: 0.05,
|
|
longitudeDelta: 0.05,
|
|
};
|
|
};
|
|
|
|
const handleMapReady = () => {
|
|
setTimeout(() => {
|
|
setIsFirstLoad(false);
|
|
}, 2000);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (alarmData?.level === 3) {
|
|
const loop = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.parallel([
|
|
Animated.timing(scale, {
|
|
toValue: 3, // nở to 3 lần
|
|
duration: 1500,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(opacity, {
|
|
toValue: 0, // mờ dần
|
|
duration: 1500,
|
|
useNativeDriver: true,
|
|
}),
|
|
]),
|
|
Animated.parallel([
|
|
Animated.timing(scale, {
|
|
toValue: 0,
|
|
duration: 0,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(opacity, {
|
|
toValue: 1,
|
|
duration: 0,
|
|
useNativeDriver: true,
|
|
}),
|
|
]),
|
|
])
|
|
);
|
|
loop.start();
|
|
return () => loop.stop();
|
|
}
|
|
}, [things, scale, opacity]);
|
|
|
|
// Tắt tracksViewChanges sau khi map đã load xong
|
|
useEffect(() => {
|
|
if (!isFirstLoad) {
|
|
// Delay một chút để đảm bảo markers đã render xong
|
|
const timer = setTimeout(() => {
|
|
setTracksViewChanges(false);
|
|
}, 3000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isFirstLoad]);
|
|
|
|
const handleOnPressState = (state: TagStateCallbackPayload) => {
|
|
// Xây dựng query state dựa trên logic bạn cung cấp
|
|
const stateNormalQuery = state.isNormal ? "normal" : "";
|
|
const stateSosQuery = state.isSos ? "sos" : "";
|
|
const stateWarningQuery = state.isWarning
|
|
? stateNormalQuery + ",warning"
|
|
: stateNormalQuery;
|
|
const stateCriticalQuery = state.isDangerous
|
|
? stateWarningQuery + ",critical"
|
|
: stateWarningQuery;
|
|
|
|
// Nếu bật tất cả filter thì không cần truyền stateQuery
|
|
const stateQuery =
|
|
state.isNormal && state.isWarning && state.isDangerous && state.isSos
|
|
? ""
|
|
: [stateCriticalQuery, stateSosQuery].filter(Boolean).join(",");
|
|
let metaFormQuery = {};
|
|
if (state.isDisconected)
|
|
metaFormQuery = { ...metaFormQuery, connected: false };
|
|
|
|
// Tạo metadata query
|
|
const metaStateQuery =
|
|
stateQuery !== "" ? { state_level: stateQuery } : null;
|
|
|
|
// Tạo body search với filter
|
|
const searchParams: Model.SearchThingBody = {
|
|
offset: 0,
|
|
limit: 50,
|
|
order: "name",
|
|
sort: "asc",
|
|
metadata: {
|
|
...metaFormQuery,
|
|
...metaStateQuery,
|
|
not_empty: "ship_id",
|
|
},
|
|
};
|
|
|
|
// Gọi API tìm kiếm
|
|
searchThingEventBus(searchParams);
|
|
};
|
|
|
|
return (
|
|
<GestureHandlerRootView style={styles.container}>
|
|
<View style={styles.container}>
|
|
<MapView
|
|
onMapReady={handleMapReady}
|
|
onRegionChangeComplete={handleRegionChangeComplete}
|
|
style={styles.map}
|
|
region={getMapRegion()}
|
|
userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
|
|
showsBuildings={false}
|
|
showsIndoors={false}
|
|
loadingEnabled={true}
|
|
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
|
rotateEnabled={false}
|
|
>
|
|
{things?.things && things.things.length > 0 && (
|
|
<>
|
|
{things.things
|
|
.filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check
|
|
.map((thing, index) => {
|
|
const gpsData: Model.GPSResponse = JSON.parse(
|
|
thing.metadata!.gps!
|
|
);
|
|
|
|
// Tạo unique key dựa trên thing.id hoặc tọa độ
|
|
const uniqueKey = thing.id
|
|
? `marker-${thing.id}-${index}`
|
|
: `marker-${gpsData.lat.toFixed(6)}-${gpsData.lon.toFixed(
|
|
6
|
|
)}-${index}`;
|
|
|
|
return (
|
|
<Marker
|
|
key={uniqueKey}
|
|
coordinate={{
|
|
latitude: gpsData.lat,
|
|
longitude: gpsData.lon,
|
|
}}
|
|
zIndex={50}
|
|
anchor={{ x: 0.5, y: 0.5 }}
|
|
// Chỉ tracks changes khi cần thiết
|
|
tracksViewChanges={
|
|
platform === IOS_PLATFORM ? tracksViewChanges : true
|
|
}
|
|
// Thêm identifier để iOS optimize
|
|
identifier={uniqueKey}
|
|
>
|
|
<View className="w-8 h-8 items-center justify-center">
|
|
<View style={styles.pingContainer}>
|
|
{thing.metadata?.state_level === 3 && (
|
|
<Animated.View
|
|
style={[
|
|
styles.pingCircle,
|
|
{
|
|
transform: [{ scale }],
|
|
opacity,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
<Image
|
|
source={(() => {
|
|
const icon = getShipIcon(
|
|
thing.metadata?.state_level || 0,
|
|
gpsData.fishing
|
|
);
|
|
// console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing);
|
|
return typeof icon === "string"
|
|
? { uri: icon }
|
|
: icon;
|
|
})()}
|
|
style={{
|
|
width: 32,
|
|
height: 32,
|
|
transform: [
|
|
{
|
|
rotate: `${
|
|
typeof gpsData.h === "number" &&
|
|
!isNaN(gpsData.h)
|
|
? gpsData.h
|
|
: 0
|
|
}deg`,
|
|
},
|
|
],
|
|
}}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</Marker>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</MapView>
|
|
|
|
{/* <View className="absolute top-14 right-2 shadow-md">
|
|
<SosButton />
|
|
</View>
|
|
<GPSInfoPanel gpsData={gpsData!} /> */}
|
|
|
|
{/* Draggable Panel */}
|
|
<DraggablePanel
|
|
minHeightPct={0.1}
|
|
maxHeightPct={0.5}
|
|
initialState="min"
|
|
onExpandedChange={(expanded) => {
|
|
console.log("Panel expanded:", expanded);
|
|
}}
|
|
>
|
|
<>
|
|
<View className="flex flex-row gap-4 items-center justify-center">
|
|
<TagState
|
|
normalCount={things?.metadata?.total_state_level_0 || 0}
|
|
warningCount={things?.metadata?.total_state_level_1 || 0}
|
|
dangerousCount={things?.metadata?.total_state_level_2 || 0}
|
|
sosCount={things?.metadata?.total_sos || 0}
|
|
disconnectedCount={
|
|
(things?.metadata?.total_thing ?? 0) -
|
|
(things?.metadata?.total_connected ?? 0) || 0
|
|
}
|
|
onTagPress={handleOnPressState}
|
|
/>
|
|
</View>
|
|
<View style={{ width: "100%", paddingVertical: 8, flex: 1 }}>
|
|
<Text
|
|
style={{ fontSize: 20, textAlign: "center", fontWeight: "600" }}
|
|
>
|
|
Danh sách tàu thuyền
|
|
</Text>
|
|
|
|
<ScrollView
|
|
style={{ width: "100%", marginTop: 8, flex: 1 }}
|
|
contentContainerStyle={{
|
|
alignItems: "center",
|
|
paddingBottom: 24,
|
|
// backgroundColor: "green",
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{things && things.things && things.things.length > 0 && (
|
|
<>
|
|
{things.things.map((thing, index) => {
|
|
return (
|
|
<View
|
|
key={thing.id ?? index}
|
|
style={{ width: "100%", alignItems: "center" }}
|
|
>
|
|
<ShipInfo thingMetadata={thing.metadata} />
|
|
{index < (things.things?.length ?? 0) - 1 && (
|
|
<View
|
|
style={{
|
|
width: "50%",
|
|
height: 1,
|
|
backgroundColor: "#E5E7EB",
|
|
marginVertical: 8,
|
|
borderRadius: 1,
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
</>
|
|
</DraggablePanel>
|
|
</View>
|
|
</GestureHandlerRootView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
map: {
|
|
flex: 1,
|
|
},
|
|
button: {
|
|
// display: "none",
|
|
position: "absolute",
|
|
top: 50,
|
|
right: 20,
|
|
backgroundColor: "#007AFF",
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
borderRadius: 8,
|
|
elevation: 5,
|
|
shadowColor: "#000",
|
|
shadowOffset: {
|
|
width: 0,
|
|
height: 2,
|
|
},
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 3.84,
|
|
},
|
|
buttonText: {
|
|
color: "#fff",
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
},
|
|
|
|
pingContainer: {
|
|
width: 32,
|
|
height: 32,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
overflow: "visible",
|
|
},
|
|
pingCircle: {
|
|
position: "absolute",
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: "#ED3F27",
|
|
},
|
|
centerDot: {
|
|
width: 20,
|
|
height: 20,
|
|
borderRadius: 10,
|
|
backgroundColor: "#0096FF",
|
|
},
|
|
});
|