Hiển thị tọa độ và khu vực khi tàu vi phạm
This commit is contained in:
@@ -1,46 +1,69 @@
|
|||||||
import DraggablePanel from "@/components/DraggablePanel";
|
import DraggablePanel from "@/components/DraggablePanel";
|
||||||
import IconButton from "@/components/IconButton";
|
import IconButton from "@/components/IconButton";
|
||||||
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
|
import AlarmList from "@/components/map/AlarmList";
|
||||||
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
|
|
||||||
import ShipInfo from "@/components/map/ShipInfo";
|
import ShipInfo from "@/components/map/ShipInfo";
|
||||||
import { TagState, TagStateCallbackPayload } from "@/components/map/TagState";
|
import { TagState, TagStateCallbackPayload } from "@/components/map/TagState";
|
||||||
|
import ZoneInMap from "@/components/map/ZoneInMap";
|
||||||
import ShipSearchForm, {
|
import ShipSearchForm, {
|
||||||
SearchShipResponse,
|
SearchShipResponse,
|
||||||
} from "@/components/ShipSearchForm";
|
} from "@/components/ShipSearchForm";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants";
|
import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants";
|
||||||
|
import { queryBanzoneById } from "@/controller/MapController";
|
||||||
import { usePlatform } from "@/hooks/use-platform";
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||||
import { searchThingEventBus } from "@/services/device_events";
|
import { searchThingEventBus } from "@/services/device_events";
|
||||||
import { getShipIcon } from "@/services/map_service";
|
import { getShipIcon } from "@/services/map_service";
|
||||||
import eventBus from "@/utils/eventBus";
|
import eventBus from "@/utils/eventBus";
|
||||||
import { AntDesign } from "@expo/vector-icons";
|
import { AntDesign, Ionicons } from "@expo/vector-icons";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Animated,
|
Animated,
|
||||||
|
Dimensions,
|
||||||
Image,
|
Image,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import MapView, { Marker } from "react-native-maps";
|
import MapView, { Marker } from "react-native-maps";
|
||||||
|
|
||||||
|
interface ZoneDataParsed {
|
||||||
|
zone_type?: number;
|
||||||
|
zone_name?: string;
|
||||||
|
zone_id?: string;
|
||||||
|
message?: string;
|
||||||
|
alarm_type?: number;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
s?: number;
|
||||||
|
h?: number;
|
||||||
|
fishing?: boolean;
|
||||||
|
gps_time?: number;
|
||||||
|
}
|
||||||
|
export interface AlarmData {
|
||||||
|
thing_id: string;
|
||||||
|
ship_name?: string;
|
||||||
|
zone: ZoneDataParsed;
|
||||||
|
type: "approaching" | "entered" | "fishing";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BanzoneWithAlarm {
|
||||||
|
alarms: AlarmData;
|
||||||
|
zone?: Model.Zone;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
|
const mapRef = useRef<MapView>(null);
|
||||||
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
|
const [alarms, setAlarms] = useState<AlarmData[]>([]);
|
||||||
const [trackPointsData, setTrackPointsData] = useState<
|
const [banzoneWithAlarm, setBanzoneWithAlarm] =
|
||||||
Model.ShipTrackPoint[] | null
|
useState<BanzoneWithAlarm | null>(null);
|
||||||
>(null);
|
const [allBanZones, setAllBanZones] = useState<BanzoneWithAlarm[]>([]);
|
||||||
const [circleRadius, setCircleRadius] = useState(100);
|
const [showAllAlarmsOnMap, setShowAllAlarmsOnMap] = useState(false);
|
||||||
const [zoomLevel, setZoomLevel] = useState(10);
|
|
||||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||||
const [polylineCoordinates, setPolylineCoordinates] = useState<
|
|
||||||
PolylineWithLabelProps[]
|
|
||||||
>([]);
|
|
||||||
const [polygonCoordinates, setPolygonCoordinates] = useState<
|
|
||||||
PolygonWithLabelProps[]
|
|
||||||
>([]);
|
|
||||||
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(false);
|
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(false);
|
||||||
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
|
const [isPanelExpanded, setIsPanelExpanded] = useState(false);
|
||||||
const [things, setThings] = useState<Model.ThingsResponse | null>(null);
|
const [things, setThings] = useState<Model.ThingsResponse | null>(null);
|
||||||
@@ -54,15 +77,36 @@ export default function HomeScreen() {
|
|||||||
const [tagStatePayload, setTagStatePayload] =
|
const [tagStatePayload, setTagStatePayload] =
|
||||||
useState<TagStateCallbackPayload | null>(null);
|
useState<TagStateCallbackPayload | null>(null);
|
||||||
|
|
||||||
|
const [isShowAlarmList, setIsShowAlarmList] = useState(false);
|
||||||
|
// Control mount so we can animate close before unmounting
|
||||||
|
const [isAlarmListMounted, setIsAlarmListMounted] = useState(false);
|
||||||
// Thêm state để quản lý tracksViewChanges
|
// Thêm state để quản lý tracksViewChanges
|
||||||
const [tracksViewChanges, setTracksViewChanges] = useState(true);
|
const [tracksViewChanges, setTracksViewChanges] = useState(true);
|
||||||
|
|
||||||
|
// Alarm list animation
|
||||||
|
const screenHeight = Dimensions.get("window").height;
|
||||||
|
const alarmListHeight = Math.round(screenHeight * 0.3);
|
||||||
|
const alarmTranslateY = useRef(new Animated.Value(alarmListHeight)).current;
|
||||||
|
const alarmOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const uiAnim = useRef(new Animated.Value(1)).current; // 1 visible, 0 hidden
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tagStatePayload) {
|
if (tagStatePayload) {
|
||||||
searchThings();
|
searchThings();
|
||||||
}
|
}
|
||||||
}, [tagStatePayload]);
|
}, [tagStatePayload]);
|
||||||
|
|
||||||
|
const openAlarmList = () => {
|
||||||
|
setIsAlarmListMounted(true);
|
||||||
|
setIsShowAlarmList(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAlarmList = () => {
|
||||||
|
// Trigger the animation; the effect will unmount when complete
|
||||||
|
setIsShowAlarmList(false);
|
||||||
|
setBanzoneWithAlarm(null);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shipSearchFormData) {
|
if (shipSearchFormData) {
|
||||||
searchThings();
|
searchThings();
|
||||||
@@ -93,7 +137,7 @@ export default function HomeScreen() {
|
|||||||
...thingsData,
|
...thingsData,
|
||||||
things: sortedThings,
|
things: sortedThings,
|
||||||
};
|
};
|
||||||
console.log("Things Updated: ", sortedThingsResponse.things?.length);
|
// console.log("Things Updated: ", sortedThingsResponse.things?.length);
|
||||||
|
|
||||||
setThings(sortedThingsResponse);
|
setThings(sortedThingsResponse);
|
||||||
};
|
};
|
||||||
@@ -105,101 +149,85 @@ export default function HomeScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (things) {
|
if (things?.things) {
|
||||||
// console.log("Things Updated: ", things.things?.length);
|
const alarmTypes = [
|
||||||
// const gpsDatas: Model.GPSResponse[] = [];
|
{
|
||||||
// for (const thing of things.things || []) {
|
key: "zone_approaching_alarm_list",
|
||||||
// if (thing.metadata?.gps) {
|
type: "approaching" as const,
|
||||||
// const gps: Model.GPSResponse = JSON.parse(thing.metadata.gps);
|
},
|
||||||
// gpsDatas.push(gps);
|
{ key: "zone_entered_alarm_list", type: "entered" as const },
|
||||||
// }
|
{ key: "zone_fishing_alarm_list", type: "fishing" as const },
|
||||||
// }
|
];
|
||||||
// console.log("GPS Lenght: ", gpsDatas.length);
|
|
||||||
// setGpsData(gpsDatas);
|
const newAlarms: AlarmData[] = [];
|
||||||
|
|
||||||
|
for (const thing of things.things) {
|
||||||
|
for (const { key, type } of alarmTypes) {
|
||||||
|
if ((thing.metadata as any)?.[key] != "[]") {
|
||||||
|
const zoneList: ZoneDataParsed[] = JSON.parse(
|
||||||
|
(thing?.metadata as any)?.[key] || "[]"
|
||||||
|
);
|
||||||
|
for (const zone of zoneList) {
|
||||||
|
const alarmData: AlarmData = {
|
||||||
|
thing_id: thing.id || "",
|
||||||
|
ship_name: thing.metadata?.ship_name,
|
||||||
|
zone: zone,
|
||||||
|
type: type,
|
||||||
|
};
|
||||||
|
newAlarms.push(alarmData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update alarms, removing old ones not in newAlarms
|
||||||
|
setAlarms((prev) => {
|
||||||
|
const toKeep = prev.filter((a) =>
|
||||||
|
newAlarms.some(
|
||||||
|
(na) =>
|
||||||
|
na.thing_id === a.thing_id && na.zone.zone_id === a.zone.zone_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const toAdd = newAlarms.filter(
|
||||||
|
(na) =>
|
||||||
|
!prev.some(
|
||||||
|
(a) =>
|
||||||
|
a.thing_id === na.thing_id && a.zone.zone_id === a.zone.zone_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return [...toKeep, ...toAdd];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [things]);
|
}, [things]);
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// setPolylineCoordinates([]);
|
const approaching = alarms.filter((a) => a.type === "approaching");
|
||||||
// setPolygonCoordinates([]);
|
const entered = alarms.filter((a) => a.type === "entered");
|
||||||
// if (!entityData) return;
|
const fishing = alarms.filter((a) => a.type === "fishing");
|
||||||
// if (!banzoneData) return;
|
|
||||||
// for (const entity of entityData) {
|
|
||||||
// if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// let zones: any[] = [];
|
if (approaching.length > 0) {
|
||||||
// try {
|
console.log("ZoneApproachingAlarm: ", approaching);
|
||||||
// zones = entity.valueString ? JSON.parse(entity.valueString) : [];
|
} else {
|
||||||
// } catch (parseError) {
|
// console.log("No ZoneApproachingAlarm");
|
||||||
// console.error("Error parsing zone list:", parseError);
|
}
|
||||||
// continue;
|
if (entered.length > 0) {
|
||||||
// }
|
console.log("ZoneEnteredAlarm: ", entered);
|
||||||
// // Nếu danh sách zone rỗng, clear tất cả
|
} else {
|
||||||
// if (zones.length === 0) {
|
// console.log("No ZoneEnteredAlarm");
|
||||||
// setPolylineCoordinates([]);
|
}
|
||||||
// setPolygonCoordinates([]);
|
if (fishing.length > 0) {
|
||||||
// return;
|
console.log("ZoneFishingAlarm: ", fishing);
|
||||||
// }
|
} else {
|
||||||
|
// console.log("No ZoneFishingAlarm");
|
||||||
|
}
|
||||||
|
}, [alarms]);
|
||||||
|
|
||||||
// let polylines: PolylineWithLabelProps[] = [];
|
// Load all banzones when alarms change (for showing all alarms on map)
|
||||||
// let polygons: PolygonWithLabelProps[] = [];
|
useEffect(() => {
|
||||||
|
if (alarms.length > 0 && showAllAlarmsOnMap) {
|
||||||
// for (const zone of zones) {
|
loadAllBanZones();
|
||||||
// // console.log("Zone Data: ", zone);
|
}
|
||||||
// const geom = banzoneData.find((b) => b.id === zone.zone_id);
|
}, [alarms, showAllAlarmsOnMap]);
|
||||||
// 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 calculateRadiusFromZoom = (zoom: number) => {
|
||||||
const baseZoom = 10;
|
const baseZoom = 10;
|
||||||
@@ -217,8 +245,8 @@ export default function HomeScreen() {
|
|||||||
// zoom = log2(360 / (latitudeDelta * 2)) + 8
|
// zoom = log2(360 / (latitudeDelta * 2)) + 8
|
||||||
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
|
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
|
||||||
const newRadius = calculateRadiusFromZoom(zoom);
|
const newRadius = calculateRadiusFromZoom(zoom);
|
||||||
setCircleRadius(newRadius);
|
// setCircleRadius(newRadius);
|
||||||
setZoomLevel(zoom);
|
// setZoomLevel(zoom);
|
||||||
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
|
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -303,8 +331,53 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
}, [isFirstLoad]);
|
}, [isFirstLoad]);
|
||||||
|
|
||||||
|
// Animate alarm panel when isShowAlarmList changes
|
||||||
|
// Keep the overlay mounted while animating it in/out.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isShowAlarmList) {
|
||||||
|
// Ensure mounted then animate to visible
|
||||||
|
setIsAlarmListMounted(true);
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(alarmTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(alarmOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
} else {
|
||||||
|
// Animate out, then unmount
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(alarmTranslateY, {
|
||||||
|
toValue: alarmListHeight,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(alarmOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
setIsAlarmListMounted(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [alarmTranslateY, alarmListHeight, alarmOpacity, isShowAlarmList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(uiAnim, {
|
||||||
|
toValue: isAlarmListMounted ? 0 : 1,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, [uiAnim, isAlarmListMounted]);
|
||||||
|
|
||||||
const searchThings = async () => {
|
const searchThings = async () => {
|
||||||
console.log("FormSearch Playload in Search Thing: ", shipSearchFormData);
|
// console.log("FormSearch Playload in Search Thing: ", shipSearchFormData);
|
||||||
|
|
||||||
// Xây dựng query state dựa trên logic bạn cung cấp
|
// Xây dựng query state dựa trên logic bạn cung cấp
|
||||||
const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : "";
|
const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : "";
|
||||||
@@ -391,7 +464,7 @@ export default function HomeScreen() {
|
|||||||
not_empty: "ship_id",
|
not_empty: "ship_id",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
console.log("Search Params: ", searchParams);
|
// console.log("Search Params: ", searchParams);
|
||||||
|
|
||||||
// Gọi API tìm kiếm
|
// Gọi API tìm kiếm
|
||||||
searchThingEventBus(searchParams);
|
searchThingEventBus(searchParams);
|
||||||
@@ -401,6 +474,50 @@ export default function HomeScreen() {
|
|||||||
setShipSearchFormOpen(false);
|
setShipSearchFormOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadAllBanZones = async () => {
|
||||||
|
try {
|
||||||
|
const banzonePromises = alarms.map(async (alarm) => {
|
||||||
|
try {
|
||||||
|
const banzone = await queryBanzoneById(alarm.zone.zone_id || "");
|
||||||
|
return {
|
||||||
|
alarms: alarm,
|
||||||
|
zone: banzone.data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Cannot get banzone for zone_id: ${alarm.zone.zone_id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const banzoneResults = await Promise.all(banzonePromises);
|
||||||
|
const validBanZones = banzoneResults.filter(
|
||||||
|
(banzone): banzone is NonNullable<typeof banzone> => banzone !== null
|
||||||
|
);
|
||||||
|
setAllBanZones(validBanZones);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading all banzones:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlarmPress = async (alarm: AlarmData) => {
|
||||||
|
console.log("Alarm pressed from list:", alarm);
|
||||||
|
try {
|
||||||
|
const banzone = await queryBanzoneById(alarm.zone.zone_id || "");
|
||||||
|
// console.log("Banzone API response:", banzone);
|
||||||
|
// console.log("Banzone data:", banzone.data);
|
||||||
|
// console.log("Zone geometry:", banzone.data?.geometry);
|
||||||
|
const banzoneWithAlarm: BanzoneWithAlarm = {
|
||||||
|
alarms: alarm,
|
||||||
|
zone: banzone.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
setBanzoneWithAlarm(banzoneWithAlarm);
|
||||||
|
setShowAllAlarmsOnMap(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Cannot get Banzone:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const hasActiveFilters = shipSearchFormData
|
const hasActiveFilters = shipSearchFormData
|
||||||
? shipSearchFormData.ship_name !== "" ||
|
? shipSearchFormData.ship_name !== "" ||
|
||||||
shipSearchFormData.reg_number !== "" ||
|
shipSearchFormData.reg_number !== "" ||
|
||||||
@@ -418,6 +535,7 @@ export default function HomeScreen() {
|
|||||||
<GestureHandlerRootView style={styles.container}>
|
<GestureHandlerRootView style={styles.container}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<MapView
|
<MapView
|
||||||
|
ref={mapRef}
|
||||||
onMapReady={handleMapReady}
|
onMapReady={handleMapReady}
|
||||||
onRegionChangeComplete={handleRegionChangeComplete}
|
onRegionChangeComplete={handleRegionChangeComplete}
|
||||||
style={styles.map}
|
style={styles.map}
|
||||||
@@ -428,8 +546,9 @@ export default function HomeScreen() {
|
|||||||
loadingEnabled={true}
|
loadingEnabled={true}
|
||||||
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
|
||||||
rotateEnabled={false}
|
rotateEnabled={false}
|
||||||
|
// onMarkerPress={onMarkerPress}
|
||||||
>
|
>
|
||||||
{things?.things && things.things.length > 0 && (
|
{!banzoneWithAlarm && things?.things && things.things.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{things.things
|
{things.things
|
||||||
.filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check
|
.filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check
|
||||||
@@ -454,6 +573,10 @@ export default function HomeScreen() {
|
|||||||
}}
|
}}
|
||||||
zIndex={50}
|
zIndex={50}
|
||||||
anchor={{ x: 0.5, y: 0.5 }}
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
title={thing.metadata?.ship_name}
|
||||||
|
description={`Trạng thái: ${
|
||||||
|
gpsData.fishing ? "Đang đánh bắt" : "Không đánh bắt"
|
||||||
|
}`}
|
||||||
// Chỉ tracks changes khi cần thiết
|
// Chỉ tracks changes khi cần thiết
|
||||||
tracksViewChanges={
|
tracksViewChanges={
|
||||||
platform === IOS_PLATFORM ? tracksViewChanges : true
|
platform === IOS_PLATFORM ? tracksViewChanges : true
|
||||||
@@ -461,6 +584,7 @@ export default function HomeScreen() {
|
|||||||
// Thêm identifier để iOS optimize
|
// Thêm identifier để iOS optimize
|
||||||
identifier={uniqueKey}
|
identifier={uniqueKey}
|
||||||
>
|
>
|
||||||
|
{/* <Callout tooltip></Callout> */}
|
||||||
<View className="w-8 h-8 items-center justify-center">
|
<View className="w-8 h-8 items-center justify-center">
|
||||||
<View style={styles.pingContainer}>
|
<View style={styles.pingContainer}>
|
||||||
{thing.metadata?.state_level === 3 && (
|
{thing.metadata?.state_level === 3 && (
|
||||||
@@ -507,9 +631,16 @@ export default function HomeScreen() {
|
|||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{(banzoneWithAlarm ||
|
||||||
|
(showAllAlarmsOnMap && allBanZones.length > 0)) && (
|
||||||
|
<ZoneInMap
|
||||||
|
banzones={banzoneWithAlarm ? [banzoneWithAlarm] : allBanZones}
|
||||||
|
mapRef={mapRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
<View className="absolute top-20 left-5">
|
<View className="absolute top-12 left-5">
|
||||||
{!isPanelExpanded && (
|
{!isAlarmListMounted && !isPanelExpanded && (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<AntDesign name="filter" size={16} />}
|
icon={<AntDesign name="filter" size={16} />}
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -528,12 +659,13 @@ export default function HomeScreen() {
|
|||||||
<GPSInfoPanel gpsData={gpsData!} /> */}
|
<GPSInfoPanel gpsData={gpsData!} /> */}
|
||||||
|
|
||||||
{/* Draggable Panel */}
|
{/* Draggable Panel */}
|
||||||
|
{!isAlarmListMounted && (
|
||||||
<DraggablePanel
|
<DraggablePanel
|
||||||
minHeightPct={0.1}
|
minHeightPct={0.1}
|
||||||
maxHeightPct={0.6}
|
maxHeightPct={0.6}
|
||||||
initialState="min"
|
initialState="min"
|
||||||
onExpandedChange={(expanded) => {
|
onExpandedChange={(expanded) => {
|
||||||
console.log("Panel expanded:", expanded);
|
// console.log("Panel expanded:", expanded);
|
||||||
setIsPanelExpanded(expanded);
|
setIsPanelExpanded(expanded);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -618,12 +750,67 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
</DraggablePanel>
|
</DraggablePanel>
|
||||||
|
)}
|
||||||
|
{/* Alarm list overlay */}
|
||||||
|
{isAlarmListMounted && (
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: alarmListHeight,
|
||||||
|
transform: [{ translateY: alarmTranslateY }],
|
||||||
|
opacity: alarmOpacity,
|
||||||
|
elevation: 10,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="bg-white rounded-t-3xl shadow-md overflow-hidden h-full z-50">
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-2">
|
||||||
|
<Text className="text-lg font-semibold">
|
||||||
|
Danh sách cảnh báo
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => closeAlarmList()}
|
||||||
|
style={{ padding: 6 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={20} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#F9FAFB",
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* <ThemedText className="text-lg font-semibold">Body</ThemedText> */}
|
||||||
|
<AlarmList data={alarms} onPress={handleAlarmPress} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
<ShipSearchForm
|
<ShipSearchForm
|
||||||
initialValues={shipSearchFormData}
|
initialValues={shipSearchFormData}
|
||||||
isOpen={shipSearchFormOpen}
|
isOpen={shipSearchFormOpen}
|
||||||
onClose={() => setShipSearchFormOpen(false)}
|
onClose={() => setShipSearchFormOpen(false)}
|
||||||
onSubmit={handleOnSubmitSearchForm}
|
onSubmit={handleOnSubmitSearchForm}
|
||||||
/>
|
/>
|
||||||
|
{!isAlarmListMounted && alarms.length > 0 && (
|
||||||
|
<View className="absolute top-12 right-5 space-y-2">
|
||||||
|
<IconButton
|
||||||
|
icon={<Ionicons name="warning" size={16} color="#fff" />}
|
||||||
|
type="danger"
|
||||||
|
size="middle"
|
||||||
|
onPress={() => openAlarmList()}
|
||||||
|
>
|
||||||
|
<ThemedText className="text-sm font-semibold">
|
||||||
|
{alarms.length}
|
||||||
|
</ThemedText>
|
||||||
|
</IconButton>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +1,323 @@
|
|||||||
import ShipSearchForm from "@/components/ShipSearchForm";
|
import { ThemedText } from "@/components/themed-text";
|
||||||
import { useState } from "react";
|
import { ThemedView } from "@/components/themed-view";
|
||||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import { FlatList, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { AlarmData } from ".";
|
||||||
|
|
||||||
|
// ============ Types ============
|
||||||
|
type AlarmType = "approaching" | "entered" | "fishing";
|
||||||
|
|
||||||
|
interface AlarmCardProps {
|
||||||
|
alarm: AlarmData;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Config ============
|
||||||
|
const ALARM_CONFIG: Record<
|
||||||
|
AlarmType,
|
||||||
|
{
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label: string;
|
||||||
|
bgColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
iconBgColor: string;
|
||||||
|
iconColor: string;
|
||||||
|
labelColor: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
entered: {
|
||||||
|
icon: "warning",
|
||||||
|
label: "Xâm nhập",
|
||||||
|
bgColor: "bg-red-50",
|
||||||
|
borderColor: "border-red-200",
|
||||||
|
iconBgColor: "bg-red-100",
|
||||||
|
iconColor: "#DC2626",
|
||||||
|
labelColor: "text-red-600",
|
||||||
|
},
|
||||||
|
approaching: {
|
||||||
|
icon: "alert-circle",
|
||||||
|
label: "Tiếp cận",
|
||||||
|
bgColor: "bg-amber-50",
|
||||||
|
borderColor: "border-amber-200",
|
||||||
|
iconBgColor: "bg-amber-100",
|
||||||
|
iconColor: "#D97706",
|
||||||
|
labelColor: "text-amber-600",
|
||||||
|
},
|
||||||
|
fishing: {
|
||||||
|
icon: "fish",
|
||||||
|
label: "Đánh bắt",
|
||||||
|
bgColor: "bg-orange-50",
|
||||||
|
borderColor: "border-orange-200",
|
||||||
|
iconBgColor: "bg-orange-100",
|
||||||
|
iconColor: "#EA580C",
|
||||||
|
labelColor: "text-orange-600",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Helper Functions ============
|
||||||
|
const formatTimestamp = (timestamp?: number): string => {
|
||||||
|
if (!timestamp) return "N/A";
|
||||||
|
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ AlarmCard Component ============
|
||||||
|
const AlarmCard = React.memo(({ alarm, onPress }: AlarmCardProps) => {
|
||||||
|
const config = ALARM_CONFIG[alarm.type];
|
||||||
|
|
||||||
export default function warning() {
|
|
||||||
const [shipSearchFormOpen, setShipSearchFormOpen] = useState(true);
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<TouchableOpacity
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
onPress={onPress}
|
||||||
<View style={styles.container}>
|
activeOpacity={0.7}
|
||||||
<Text style={styles.titleText}>Cảnh báo</Text>
|
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-start gap-3">
|
||||||
|
{/* Icon Container */}
|
||||||
|
<View
|
||||||
|
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`}
|
||||||
|
>
|
||||||
|
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||||
</View>
|
</View>
|
||||||
<ShipSearchForm
|
|
||||||
isOpen={shipSearchFormOpen}
|
{/* Content */}
|
||||||
onClose={() => setShipSearchFormOpen(false)}
|
<View className="flex-1">
|
||||||
|
{/* Header: Ship name + Badge */}
|
||||||
|
<View className="flex-row items-center justify-between mb-1">
|
||||||
|
<ThemedText className="text-base font-bold text-gray-800 flex-1 mr-2">
|
||||||
|
{alarm.ship_name || alarm.thing_id}
|
||||||
|
</ThemedText>
|
||||||
|
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
|
||||||
|
<ThemedText
|
||||||
|
className={`text-xs font-semibold ${config.labelColor}`}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Zone Info */}
|
||||||
|
<ThemedText className="text-sm text-gray-600 mb-2" numberOfLines={2}>
|
||||||
|
{alarm.zone.message || alarm.zone.zone_name}
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
{/* Footer: Zone ID + Time */}
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Ionicons name="time-outline" size={20} color="#6B7280" />
|
||||||
|
<ThemedText className="text-xs text-gray-500">
|
||||||
|
{formatTimestamp(alarm.zone.gps_time)}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AlarmCard.displayName = "AlarmCard";
|
||||||
|
|
||||||
|
// ============ Main Component ============
|
||||||
|
interface WarningScreenProps {
|
||||||
|
alarms?: AlarmData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WarningScreen({ alarms = [] }: WarningScreenProps) {
|
||||||
|
// Mock data for demo - replace with actual props
|
||||||
|
const sampleAlarms: AlarmData[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
thing_id: "SHIP-001",
|
||||||
|
ship_name: "Ocean Star",
|
||||||
|
type: "entered",
|
||||||
|
zone: {
|
||||||
|
zone_type: 1,
|
||||||
|
zone_name: "Khu vực cấm A1",
|
||||||
|
zone_id: "A1",
|
||||||
|
message: "Tàu đã đi vào vùng cấm A1",
|
||||||
|
alarm_type: 1,
|
||||||
|
lat: 10.12345,
|
||||||
|
lon: 106.12345,
|
||||||
|
s: 12,
|
||||||
|
h: 180,
|
||||||
|
fishing: false,
|
||||||
|
gps_time: 1733389200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
thing_id: "SHIP-002",
|
||||||
|
ship_name: "Blue Whale",
|
||||||
|
type: "approaching",
|
||||||
|
zone: {
|
||||||
|
zone_type: 2,
|
||||||
|
zone_name: "Vùng cảnh báo B3",
|
||||||
|
zone_id: "B3",
|
||||||
|
message: "Tàu đang tiếp cận khu vực cấm B3",
|
||||||
|
alarm_type: 2,
|
||||||
|
lat: 9.87654,
|
||||||
|
lon: 105.87654,
|
||||||
|
gps_time: 1733389260,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
thing_id: "SHIP-003",
|
||||||
|
ship_name: "Sea Dragon",
|
||||||
|
type: "fishing",
|
||||||
|
zone: {
|
||||||
|
zone_type: 3,
|
||||||
|
zone_name: "Vùng cấm đánh bắt C2",
|
||||||
|
zone_id: "C2",
|
||||||
|
message: "Phát hiện hành vi đánh bắt trong vùng cấm C2",
|
||||||
|
alarm_type: 3,
|
||||||
|
lat: 11.11223,
|
||||||
|
lon: 107.44556,
|
||||||
|
fishing: true,
|
||||||
|
gps_time: 1733389320,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
thing_id: "SHIP-004",
|
||||||
|
ship_name: "Red Coral",
|
||||||
|
type: "entered",
|
||||||
|
zone: {
|
||||||
|
zone_type: 1,
|
||||||
|
zone_name: "Khu vực A2",
|
||||||
|
zone_id: "A2",
|
||||||
|
message: "Tàu đã đi sâu vào khu vực A2",
|
||||||
|
alarm_type: 1,
|
||||||
|
gps_time: 1733389380,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
thing_id: "SHIP-005",
|
||||||
|
ship_name: "Silver Wind",
|
||||||
|
type: "approaching",
|
||||||
|
zone: {
|
||||||
|
zone_type: 2,
|
||||||
|
zone_name: "Vùng B1",
|
||||||
|
zone_id: "B1",
|
||||||
|
message: "Tàu đang tiến gần vào vùng B1",
|
||||||
|
alarm_type: 2,
|
||||||
|
gps_time: 1733389440,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayAlarms = alarms.length > 0 ? alarms : sampleAlarms;
|
||||||
|
|
||||||
|
const handleAlarmPress = useCallback((alarm: AlarmData) => {
|
||||||
|
console.log("Alarm pressed:", alarm);
|
||||||
|
// TODO: Navigate to alarm detail or show modal
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderAlarmCard = useCallback(
|
||||||
|
({ item }: { item: AlarmData }) => (
|
||||||
|
<AlarmCard alarm={item} onPress={() => handleAlarmPress(item)} />
|
||||||
|
),
|
||||||
|
[handleAlarmPress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback(
|
||||||
|
(item: AlarmData, index: number) => `${item.thing_id}-${index}`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemSeparator = useCallback(() => <View className="h-3" />, []);
|
||||||
|
|
||||||
|
// Count alarms by type
|
||||||
|
const alarmCounts = useMemo(() => {
|
||||||
|
return displayAlarms.reduce((acc, alarm) => {
|
||||||
|
acc[alarm.type] = (acc[alarm.type] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<AlarmType, number>);
|
||||||
|
}, [displayAlarms]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={["top"]}>
|
||||||
|
<ThemedView style={styles.content}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<View className="w-10 h-10 rounded-xl bg-red-500 items-center justify-center">
|
||||||
|
<Ionicons name="warning" size={22} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
|
||||||
|
</View>
|
||||||
|
<View className="bg-red-500 px-3 py-1 rounded-full">
|
||||||
|
<ThemedText className="text-white text-sm font-semibold">
|
||||||
|
{displayAlarms.length}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Stats Bar */}
|
||||||
|
<View className="flex-row px-4 pb-3 gap-2">
|
||||||
|
{(["entered", "approaching", "fishing"] as AlarmType[]).map(
|
||||||
|
(type) => {
|
||||||
|
const config = ALARM_CONFIG[type];
|
||||||
|
const count = alarmCounts[type] || 0;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={type}
|
||||||
|
className={`flex-1 flex-row items-center justify-center gap-1 py-2 rounded-lg ${config.iconBgColor}`}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={config.icon}
|
||||||
|
size={14}
|
||||||
|
color={config.iconColor}
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
<ThemedText
|
||||||
|
className={`text-xs font-medium ${config.labelColor}`}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Alarm List */}
|
||||||
|
<FlatList
|
||||||
|
data={displayAlarms}
|
||||||
|
renderItem={renderAlarmCard}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
ItemSeparatorComponent={ItemSeparator}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
initialNumToRender={10}
|
||||||
|
maxToRenderPerBatch={10}
|
||||||
|
windowSize={5}
|
||||||
|
/>
|
||||||
|
</ThemedView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
scrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
container: {
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: 15,
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
},
|
},
|
||||||
titleText: {
|
titleText: {
|
||||||
fontSize: 32,
|
fontSize: 26,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
lineHeight: 40,
|
},
|
||||||
marginBottom: 30,
|
listContent: {
|
||||||
fontFamily: Platform.select({
|
paddingHorizontal: 16,
|
||||||
ios: "System",
|
paddingBottom: 20,
|
||||||
android: "Roboto",
|
|
||||||
default: "System",
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import dayjs from "dayjs";
|
|
||||||
import { FlatList, Text, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
type AlarmItem = {
|
|
||||||
name: string;
|
|
||||||
t: number;
|
|
||||||
level: number;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AlarmProp = {
|
|
||||||
alarmsData: AlarmItem[];
|
|
||||||
onPress?: (alarm: AlarmItem) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
|
|
||||||
const sortedAlarmsData = [...alarmsData].sort((a, b) => b.level - a.level);
|
|
||||||
return (
|
|
||||||
<FlatList
|
|
||||||
data={sortedAlarmsData}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => onPress?.(item)}
|
|
||||||
className="flex flex-row gap-5 p-3 justify-start items-baseline w-full"
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={`flex-none h-3 w-3 rounded-full ${getBackgroundColorByLevel(
|
|
||||||
item.level
|
|
||||||
)}`}
|
|
||||||
></View>
|
|
||||||
<View className="flex">
|
|
||||||
<Text className={`grow text-lg ${getTextColorByLevel(item.level)}`}>
|
|
||||||
{item.name}
|
|
||||||
</Text>
|
|
||||||
<Text className="grow text-md text-gray-400">
|
|
||||||
{formatTimestamp(item.t)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBackgroundColorByLevel = (level: number) => {
|
|
||||||
switch (level) {
|
|
||||||
case 1:
|
|
||||||
return "bg-yellow-500";
|
|
||||||
case 2:
|
|
||||||
return "bg-orange-500";
|
|
||||||
case 3:
|
|
||||||
return "bg-red-500";
|
|
||||||
default:
|
|
||||||
return "bg-gray-500";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTextColorByLevel = (level: number) => {
|
|
||||||
switch (level) {
|
|
||||||
case 1:
|
|
||||||
return "text-yellow-600";
|
|
||||||
case 2:
|
|
||||||
return "text-orange-600";
|
|
||||||
case 3:
|
|
||||||
return "text-red-600";
|
|
||||||
default:
|
|
||||||
return "text-gray-600";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: number) => {
|
|
||||||
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AlarmList;
|
|
||||||
197
components/alarm/WarningCard.tsx
Normal file
197
components/alarm/WarningCard.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { FlatList, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export type AlarmStatus = "confirmed" | "pending";
|
||||||
|
|
||||||
|
export interface AlarmListItem {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
station: string;
|
||||||
|
timestamp: number;
|
||||||
|
level: 1 | 2 | 3; // 1: warning (yellow), 2: caution (orange/yellow), 3: danger (red)
|
||||||
|
status: AlarmStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlarmProp = {
|
||||||
|
alarmsData: AlarmListItem[];
|
||||||
|
onPress?: (alarm: AlarmListItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={alarmsData}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8 }}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-3" />}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlarmCardProps = {
|
||||||
|
alarm: AlarmListItem;
|
||||||
|
onPress?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
|
||||||
|
const { bgColor, borderColor, iconColor, iconBgColor } = getColorsByLevel(
|
||||||
|
alarm.level
|
||||||
|
);
|
||||||
|
const statusConfig = getStatusConfig(alarm.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
className={`rounded-xl p-4 ${bgColor} ${borderColor} border`}
|
||||||
|
>
|
||||||
|
<View className="flex-row justify-between items-start">
|
||||||
|
{/* Left content */}
|
||||||
|
<View className="flex-row flex-1">
|
||||||
|
{/* Icon */}
|
||||||
|
<View
|
||||||
|
className={`w-10 h-10 rounded-full items-center justify-center mr-3 ${iconBgColor}`}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={getIconByLevel(alarm.level)}
|
||||||
|
size={20}
|
||||||
|
color={iconColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<View className="flex-1">
|
||||||
|
{/* Code */}
|
||||||
|
<Text
|
||||||
|
className={`text-xs font-medium mb-1 ${getCodeTextColor(
|
||||||
|
alarm.level
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{alarm.code}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text className="text-base font-semibold text-gray-800 mb-2">
|
||||||
|
{alarm.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Station and Time */}
|
||||||
|
<View className="flex-row">
|
||||||
|
<View className="mr-6">
|
||||||
|
<Text className="text-xs text-gray-400 mb-0.5">Trạm</Text>
|
||||||
|
<Text className="text-sm text-gray-600">{alarm.station}</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs text-gray-400 mb-0.5">Thời gian</Text>
|
||||||
|
<Text className="text-sm text-gray-600">
|
||||||
|
{formatTimestamp(alarm.timestamp)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
{/* <View className="mt-3">
|
||||||
|
<View
|
||||||
|
className={`self-start px-3 py-1.5 rounded-full ${statusConfig.bgColor}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-xs font-medium ${statusConfig.textColor}`}
|
||||||
|
>
|
||||||
|
{statusConfig.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View> */}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Checkmark for confirmed */}
|
||||||
|
{/* {alarm.status === "confirmed" && (
|
||||||
|
<View className="w-6 h-6 rounded-full bg-green-500 items-center justify-center">
|
||||||
|
<Ionicons name="checkmark" size={16} color="white" />
|
||||||
|
</View>
|
||||||
|
)} */}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColorsByLevel = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 3: // Danger - Red
|
||||||
|
return {
|
||||||
|
bgColor: "bg-red-50",
|
||||||
|
borderColor: "border-red-200",
|
||||||
|
iconColor: "#DC2626",
|
||||||
|
iconBgColor: "bg-red-100",
|
||||||
|
};
|
||||||
|
case 2: // Caution - Yellow/Orange
|
||||||
|
return {
|
||||||
|
bgColor: "bg-yellow-50",
|
||||||
|
borderColor: "border-yellow-200",
|
||||||
|
iconColor: "#CA8A04",
|
||||||
|
iconBgColor: "bg-yellow-100",
|
||||||
|
};
|
||||||
|
case 1: // Info - Green
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
bgColor: "bg-green-50",
|
||||||
|
borderColor: "border-green-200",
|
||||||
|
iconColor: "#16A34A",
|
||||||
|
iconBgColor: "bg-green-100",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconByLevel = (level: number): keyof typeof Ionicons.glyphMap => {
|
||||||
|
switch (level) {
|
||||||
|
case 3:
|
||||||
|
return "warning";
|
||||||
|
case 2:
|
||||||
|
return "alert-circle";
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return "checkmark-circle";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCodeTextColor = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 3:
|
||||||
|
return "text-red-600";
|
||||||
|
case 2:
|
||||||
|
return "text-yellow-600";
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return "text-green-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusConfig = (status: AlarmStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case "confirmed":
|
||||||
|
return {
|
||||||
|
label: "Đã xác nhận",
|
||||||
|
bgColor: "bg-green-100",
|
||||||
|
textColor: "text-green-700",
|
||||||
|
};
|
||||||
|
case "pending":
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: "Chờ xác nhận",
|
||||||
|
bgColor: "bg-yellow-100",
|
||||||
|
textColor: "text-yellow-700",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp: number) => {
|
||||||
|
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlarmList;
|
||||||
143
components/map/AlarmList.tsx
Normal file
143
components/map/AlarmList.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { AlarmData } from "@/app/(tabs)";
|
||||||
|
import { ThemedText } from "@/components/themed-text";
|
||||||
|
import { formatTimestamp } from "@/services/time_service";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
// ============ Types ============
|
||||||
|
type AlarmType = "approaching" | "entered" | "fishing";
|
||||||
|
|
||||||
|
interface AlarmCardProps {
|
||||||
|
alarm: AlarmData;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Config ============
|
||||||
|
const ALARM_CONFIG: Record<
|
||||||
|
AlarmType,
|
||||||
|
{
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label: string;
|
||||||
|
bgColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
iconBgColor: string;
|
||||||
|
iconColor: string;
|
||||||
|
labelColor: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
entered: {
|
||||||
|
icon: "warning",
|
||||||
|
label: "Xâm nhập",
|
||||||
|
bgColor: "bg-red-50",
|
||||||
|
borderColor: "border-red-200",
|
||||||
|
iconBgColor: "bg-red-100",
|
||||||
|
iconColor: "#DC2626",
|
||||||
|
labelColor: "text-red-600",
|
||||||
|
},
|
||||||
|
approaching: {
|
||||||
|
icon: "alert-circle",
|
||||||
|
label: "Tiếp cận",
|
||||||
|
bgColor: "bg-amber-50",
|
||||||
|
borderColor: "border-amber-200",
|
||||||
|
iconBgColor: "bg-amber-100",
|
||||||
|
iconColor: "#D97706",
|
||||||
|
labelColor: "text-amber-600",
|
||||||
|
},
|
||||||
|
fishing: {
|
||||||
|
icon: "fish",
|
||||||
|
label: "Đánh bắt",
|
||||||
|
bgColor: "bg-orange-50",
|
||||||
|
borderColor: "border-orange-200",
|
||||||
|
iconBgColor: "bg-orange-100",
|
||||||
|
iconColor: "#EA580C",
|
||||||
|
labelColor: "text-orange-600",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ AlarmCard Component ============
|
||||||
|
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
|
||||||
|
const config = ALARM_CONFIG[alarm.type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-start gap-3">
|
||||||
|
{/* Icon Container */}
|
||||||
|
<View
|
||||||
|
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`}
|
||||||
|
>
|
||||||
|
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View className="flex-1">
|
||||||
|
{/* Header: Ship name + Badge */}
|
||||||
|
<View className="flex-row items-center justify-between mb-1">
|
||||||
|
<ThemedText className="text-base font-bold text-gray-800 flex-1 mr-2">
|
||||||
|
{alarm.ship_name || alarm.thing_id}
|
||||||
|
</ThemedText>
|
||||||
|
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
|
||||||
|
<ThemedText
|
||||||
|
className={`text-xs font-semibold ${config.labelColor}`}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Zone Info */}
|
||||||
|
<ThemedText className="text-xs text-gray-600 mb-2" numberOfLines={2}>
|
||||||
|
{alarm.zone.message || alarm.zone.zone_name}
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
{/* Footer: Zone ID + Time */}
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<View className="flex-row items-center gap-1">
|
||||||
|
<Ionicons name="time-outline" size={20} color="#6B7280" />
|
||||||
|
<ThemedText className="text-xs text-gray-500">
|
||||||
|
{formatTimestamp(alarm.zone.gps_time)}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ Main Component ============
|
||||||
|
interface AlarmListProps {
|
||||||
|
data: AlarmData[];
|
||||||
|
onPress?: (alarm: AlarmData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlarmList({ data, onPress }: AlarmListProps) {
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item }: { item: AlarmData }) => (
|
||||||
|
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
|
||||||
|
),
|
||||||
|
[onPress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback(
|
||||||
|
(item: AlarmData, index: number) => `${item.thing_id}-${index}`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ItemSeparator = useCallback(() => <View className="h-3" />, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={data}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
ItemSeparatorComponent={ItemSeparator}
|
||||||
|
contentContainerStyle={{ padding: 16 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
components/map/CircleWithLabel.tsx
Normal file
133
components/map/CircleWithLabel.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { ANDROID_PLATFORM } from "@/constants";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
import { Circle, MapMarker, Marker } from "react-native-maps";
|
||||||
|
|
||||||
|
export interface CircleWithLabelProps {
|
||||||
|
center: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
radius: number;
|
||||||
|
label?: string;
|
||||||
|
content?: string;
|
||||||
|
fillColor?: string;
|
||||||
|
strokeColor?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
zIndex?: number;
|
||||||
|
zoomLevel?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component render Circle kèm Label/Text ở giữa
|
||||||
|
*/
|
||||||
|
export const CircleWithLabel: React.FC<CircleWithLabelProps> = ({
|
||||||
|
center,
|
||||||
|
radius,
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
fillColor = "rgba(220, 20, 60, 0.6)",
|
||||||
|
strokeColor = "rgba(220, 20, 60, 0.8)",
|
||||||
|
strokeWidth = 2,
|
||||||
|
zIndex = 50,
|
||||||
|
zoomLevel = 10,
|
||||||
|
}) => {
|
||||||
|
if (!center) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const platform = usePlatform();
|
||||||
|
const markerRef = useRef<MapMarker>(null);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Circle
|
||||||
|
center={center}
|
||||||
|
radius={radius}
|
||||||
|
fillColor={fillColor}
|
||||||
|
strokeColor={strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
zIndex={zIndex}
|
||||||
|
/>
|
||||||
|
{label && (
|
||||||
|
<Marker
|
||||||
|
ref={markerRef}
|
||||||
|
coordinate={center}
|
||||||
|
zIndex={50}
|
||||||
|
tracksViewChanges={platform === ANDROID_PLATFORM ? false : true}
|
||||||
|
anchor={{ x: 0.5, y: 0.5 }}
|
||||||
|
title={platform === ANDROID_PLATFORM ? label : undefined}
|
||||||
|
description={platform === ANDROID_PLATFORM ? content : undefined}
|
||||||
|
>
|
||||||
|
<View style={styles.markerContainer}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
110
components/map/MarkerCustom.tsx
Normal file
110
components/map/MarkerCustom.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { getShipIcon } from "@/services/map_service";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Image, StyleSheet, View } from "react-native";
|
||||||
|
import { Marker } from "react-native-maps";
|
||||||
|
|
||||||
|
interface MarkerCustomProps {
|
||||||
|
id: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
shipName?: string;
|
||||||
|
description?: string;
|
||||||
|
stateLevel?: number;
|
||||||
|
isFishing?: boolean;
|
||||||
|
heading?: number;
|
||||||
|
zIndex?: number;
|
||||||
|
anchor?: { x: number; y: number };
|
||||||
|
tracksViewChanges?: boolean;
|
||||||
|
identifier?: string;
|
||||||
|
animated?: {
|
||||||
|
scale: Animated.Value;
|
||||||
|
opacity: Animated.Value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkerCustom: React.FC<MarkerCustomProps> = ({
|
||||||
|
id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
shipName,
|
||||||
|
description,
|
||||||
|
stateLevel = 0,
|
||||||
|
isFishing = false,
|
||||||
|
heading = 0,
|
||||||
|
zIndex = 50,
|
||||||
|
anchor = { x: 0.5, y: 0.5 },
|
||||||
|
tracksViewChanges = false,
|
||||||
|
identifier,
|
||||||
|
animated,
|
||||||
|
}) => {
|
||||||
|
const uniqueKey =
|
||||||
|
id || `marker-${latitude.toFixed(6)}-${longitude.toFixed(6)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={uniqueKey}
|
||||||
|
coordinate={{
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
}}
|
||||||
|
zIndex={zIndex}
|
||||||
|
anchor={anchor}
|
||||||
|
title={shipName}
|
||||||
|
description={description}
|
||||||
|
tracksViewChanges={tracksViewChanges}
|
||||||
|
identifier={identifier || uniqueKey}
|
||||||
|
>
|
||||||
|
<View className="w-8 h-8 items-center justify-center">
|
||||||
|
<View style={styles.pingContainer}>
|
||||||
|
{animated && stateLevel === 3 && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.pingCircle,
|
||||||
|
{
|
||||||
|
transform: [{ scale: animated.scale }],
|
||||||
|
opacity: animated.opacity,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Image
|
||||||
|
source={(() => {
|
||||||
|
const icon = getShipIcon(stateLevel, isFishing);
|
||||||
|
return typeof icon === "string" ? { uri: icon } : icon;
|
||||||
|
})()}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
rotate: `${
|
||||||
|
typeof heading === "number" && !isNaN(heading) ? heading : 0
|
||||||
|
}deg`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkerCustom;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pingContainer: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
pingCircle: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "#ED3F27",
|
||||||
|
},
|
||||||
|
});
|
||||||
230
components/map/ZoneInMap.tsx
Normal file
230
components/map/ZoneInMap.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { BanzoneWithAlarm } from "@/app/(tabs)";
|
||||||
|
import {
|
||||||
|
convertWKTLineStringToLatLngArray,
|
||||||
|
convertWKTPointToLatLng,
|
||||||
|
convertWKTtoLatLngString,
|
||||||
|
} from "@/utils/geom";
|
||||||
|
import React, { useEffect, useMemo } from "react";
|
||||||
|
import { CircleWithLabel } from "./CircleWithLabel";
|
||||||
|
import { MarkerCustom } from "./MarkerCustom";
|
||||||
|
import { PolygonWithLabel } from "./PolygonWithLabel";
|
||||||
|
import { PolylineWithLabel } from "./PolylineWithLabel";
|
||||||
|
|
||||||
|
import MapView from "react-native-maps";
|
||||||
|
|
||||||
|
interface ZoneInMapProps {
|
||||||
|
banzones: BanzoneWithAlarm[];
|
||||||
|
mapRef?: React.RefObject<MapView | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to parse zone geometry
|
||||||
|
const parseZoneGeometry = (geometryString: string | undefined) => {
|
||||||
|
if (!geometryString) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const geometry: Model.Geom = JSON.parse(geometryString);
|
||||||
|
return geometry;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to parse geometry:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZoneInMap = (data: ZoneInMapProps) => {
|
||||||
|
const { banzones, mapRef } = data;
|
||||||
|
|
||||||
|
// Auto-focus camera to first alarm location when banzones change
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapRef?.current && banzones.length > 0) {
|
||||||
|
const firstAlarm = banzones[0].alarms;
|
||||||
|
if (
|
||||||
|
firstAlarm.zone.lat !== undefined &&
|
||||||
|
firstAlarm.zone.lon !== undefined
|
||||||
|
) {
|
||||||
|
setTimeout(() => {
|
||||||
|
mapRef.current?.animateToRegion(
|
||||||
|
{
|
||||||
|
latitude: firstAlarm.zone.lat as number,
|
||||||
|
longitude: firstAlarm.zone.lon as number,
|
||||||
|
latitudeDelta: 0.05,
|
||||||
|
longitudeDelta: 0.05,
|
||||||
|
},
|
||||||
|
1000
|
||||||
|
);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [banzones, mapRef]);
|
||||||
|
|
||||||
|
// Parse and render all banzones with their ship markers
|
||||||
|
const allElements = useMemo(() => {
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
console.log("ZoneInMap - banzones received:", banzones);
|
||||||
|
|
||||||
|
banzones.forEach((banzone, banzoneIndex) => {
|
||||||
|
const { zone, alarms } = banzone;
|
||||||
|
|
||||||
|
console.log(`Processing banzone ${banzoneIndex}:`, {
|
||||||
|
zone: zone,
|
||||||
|
alarms: alarms,
|
||||||
|
geometry: zone?.geometry,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse geometry with error handling
|
||||||
|
const geometry = parseZoneGeometry(zone?.geometry);
|
||||||
|
if (!geometry) {
|
||||||
|
console.warn(`No geometry for zone ${banzoneIndex}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { geom_type, geom_lines, geom_poly, geom_point, geom_radius } =
|
||||||
|
geometry;
|
||||||
|
|
||||||
|
console.log(`Parsed geometry for zone ${banzoneIndex}:`, {
|
||||||
|
geom_type,
|
||||||
|
geom_lines: geom_lines?.substring(0, 100) + "...",
|
||||||
|
geom_poly: geom_poly?.substring(0, 100) + "...",
|
||||||
|
geom_point,
|
||||||
|
geom_radius,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (geom_type === 2) {
|
||||||
|
// LINESTRING - use PolylineWithLabel
|
||||||
|
// console.log(`Processing LINESTRING for zone ${banzoneIndex}`);
|
||||||
|
const coordinates = convertWKTLineStringToLatLngArray(
|
||||||
|
geom_lines || ""
|
||||||
|
);
|
||||||
|
// console.log(`Converted coordinates:`, coordinates);
|
||||||
|
if (coordinates.length > 0) {
|
||||||
|
elements.push(
|
||||||
|
<PolylineWithLabel
|
||||||
|
key={`line-${zone?.id || banzoneIndex}`}
|
||||||
|
coordinates={coordinates.map((coord) => ({
|
||||||
|
latitude: coord[0],
|
||||||
|
longitude: coord[1],
|
||||||
|
}))}
|
||||||
|
label={zone?.name || alarms.zone.zone_name || ""}
|
||||||
|
content={alarms.zone.message || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// console.log(`Added PolylineWithLabel for zone ${banzoneIndex}`);
|
||||||
|
}
|
||||||
|
} else if (geom_type === 1) {
|
||||||
|
// MULTIPOLYGON - check both geom_poly and geom_lines
|
||||||
|
// console.log(`Processing MULTIPOLYGON for zone ${banzoneIndex}`);
|
||||||
|
``;
|
||||||
|
// First check if we have actual polygon data
|
||||||
|
if (geom_poly && geom_poly.trim() !== "") {
|
||||||
|
const polygons = convertWKTtoLatLngString(geom_poly);
|
||||||
|
// console.log(`Converted polygons from geom_poly:`, polygons);
|
||||||
|
polygons.forEach((polygon, polygonIndex) => {
|
||||||
|
if (polygon.length > 0) {
|
||||||
|
elements.push(
|
||||||
|
<PolygonWithLabel
|
||||||
|
key={`polygon-${zone?.id || banzoneIndex}-${polygonIndex}`}
|
||||||
|
coordinates={polygon.map((coord) => ({
|
||||||
|
latitude: coord[0],
|
||||||
|
longitude: coord[1],
|
||||||
|
}))}
|
||||||
|
label={zone?.name || alarms.zone.zone_name || ""}
|
||||||
|
content={alarms.zone.message || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// console.log(
|
||||||
|
// `Added PolygonWithLabel for zone ${banzoneIndex}-${polygonIndex}`
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (geom_lines && geom_lines.trim() !== "") {
|
||||||
|
// If no polygon data, treat geom_lines as a line (data inconsistency fix)
|
||||||
|
// console.log(
|
||||||
|
// `No polygon data, processing as LINESTRING from geom_lines`
|
||||||
|
// );
|
||||||
|
const coordinates = convertWKTLineStringToLatLngArray(geom_lines);
|
||||||
|
console.log(`Converted coordinates from geom_lines:`, coordinates);
|
||||||
|
if (coordinates.length > 0) {
|
||||||
|
elements.push(
|
||||||
|
<PolylineWithLabel
|
||||||
|
key={`line-${zone?.id || banzoneIndex}`}
|
||||||
|
coordinates={coordinates.map((coord) => ({
|
||||||
|
latitude: coord[0],
|
||||||
|
longitude: coord[1],
|
||||||
|
}))}
|
||||||
|
label={zone?.name || alarms.zone.zone_name || ""}
|
||||||
|
content={alarms.zone.message || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// console.log(
|
||||||
|
// `Added PolylineWithLabel for zone ${banzoneIndex} (from geom_lines)`
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// console.warn(`No valid geometry data for zone ${banzoneIndex}`);
|
||||||
|
}
|
||||||
|
} else if (geom_type === 3) {
|
||||||
|
// POINT/CIRCLE - use Circle
|
||||||
|
// console.log(`Processing POINT/CIRCLE for zone ${banzoneIndex}`);
|
||||||
|
const point = convertWKTPointToLatLng(geom_point || "");
|
||||||
|
// console.log(`Converted point:`, point, `radius:`, geom_radius);
|
||||||
|
if (point && geom_radius) {
|
||||||
|
elements.push(
|
||||||
|
<CircleWithLabel
|
||||||
|
key={`circle-${zone?.id || banzoneIndex}`}
|
||||||
|
center={{
|
||||||
|
latitude: point[1], // Note: convertWKTPointToLatLng returns [lng, lat]
|
||||||
|
longitude: point[0],
|
||||||
|
}}
|
||||||
|
radius={geom_radius}
|
||||||
|
label={zone?.name || alarms.zone.zone_name || ""}
|
||||||
|
content={alarms.zone.message || ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// console.log(`Added Circle for zone ${banzoneIndex}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`Unknown geom_type ${geom_type} for zone ${banzoneIndex}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"Error processing zone geometry for zone",
|
||||||
|
zone?.id,
|
||||||
|
":",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ship marker for the alarm location
|
||||||
|
if (alarms.zone.lat && alarms.zone.lon) {
|
||||||
|
elements.push(
|
||||||
|
<MarkerCustom
|
||||||
|
key={`ship-${alarms.thing_id || banzoneIndex}`}
|
||||||
|
id={`ship-${alarms.thing_id || banzoneIndex}`}
|
||||||
|
latitude={alarms.zone.lat}
|
||||||
|
longitude={alarms.zone.lon}
|
||||||
|
shipName={alarms.ship_name || "Tàu không xác định"}
|
||||||
|
description={
|
||||||
|
alarms.zone.gps_time
|
||||||
|
? new Date(alarms.zone.gps_time * 1000).toLocaleString()
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
heading={alarms.zone.h}
|
||||||
|
zIndex={100}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log(`Total elements rendered: ${elements.length}`);
|
||||||
|
return elements;
|
||||||
|
}, [banzones]);
|
||||||
|
|
||||||
|
return <>{allElements}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoneInMap;
|
||||||
@@ -4,3 +4,7 @@ import { API_GET_ALL_BANZONES } from "@/constants";
|
|||||||
export async function queryBanzones() {
|
export async function queryBanzones() {
|
||||||
return api.get<Model.Zone[]>(API_GET_ALL_BANZONES);
|
return api.get<Model.Zone[]>(API_GET_ALL_BANZONES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function queryBanzoneById(zoneId: string) {
|
||||||
|
return api.get<Model.Zone>(`${API_GET_ALL_BANZONES}/${zoneId}`);
|
||||||
|
}
|
||||||
|
|||||||
4
controller/typings.d.ts
vendored
4
controller/typings.d.ts
vendored
@@ -60,7 +60,9 @@ declare namespace Model {
|
|||||||
conditions?: Condition[];
|
conditions?: Condition[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
updated_at?: Date;
|
updated_at?: Date;
|
||||||
geom?: Geom;
|
geometry?: string;
|
||||||
|
description?: string;
|
||||||
|
province_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Condition {
|
interface Condition {
|
||||||
|
|||||||
@@ -41,3 +41,8 @@ export function formatRelativeTime(unixTime: number): string {
|
|||||||
if (diffMonths < 12) return `${diffMonths} tháng trước`;
|
if (diffMonths < 12) return `${diffMonths} tháng trước`;
|
||||||
return `${diffYears} năm trước`;
|
return `${diffYears} năm trước`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatTimestamp = (timestamp?: number): string => {
|
||||||
|
if (!timestamp) return "N/A";
|
||||||
|
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user