From 695066a5e7342696c8c5e793ec7476fe460eeb70 Mon Sep 17 00:00:00 2001 From: Tran Anh Tuan Date: Mon, 8 Dec 2025 09:31:28 +0700 Subject: [PATCH] =?UTF-8?q?Hi=E1=BB=83n=20th=E1=BB=8B=20t=E1=BB=8Da=20?= =?UTF-8?q?=C4=91=E1=BB=99=20v=C3=A0=20khu=20v=E1=BB=B1c=20khi=20t=C3=A0u?= =?UTF-8?q?=20vi=20ph=E1=BA=A1m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 585 +++++++++++++++++++---------- app/(tabs)/warning.tsx | 331 ++++++++++++++-- components/AlarmList.tsx | 76 ---- components/alarm/WarningCard.tsx | 197 ++++++++++ components/map/AlarmList.tsx | 143 +++++++ components/map/CircleWithLabel.tsx | 133 +++++++ components/map/MarkerCustom.tsx | 110 ++++++ components/map/ZoneInMap.tsx | 230 ++++++++++++ controller/MapController.ts | 4 + controller/typings.d.ts | 4 +- services/time_service.tsx | 5 + 11 files changed, 1517 insertions(+), 301 deletions(-) delete mode 100644 components/AlarmList.tsx create mode 100644 components/alarm/WarningCard.tsx create mode 100644 components/map/AlarmList.tsx create mode 100644 components/map/CircleWithLabel.tsx create mode 100644 components/map/MarkerCustom.tsx create mode 100644 components/map/ZoneInMap.tsx diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index baa2f07..f499465 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,46 +1,69 @@ import DraggablePanel from "@/components/DraggablePanel"; import IconButton from "@/components/IconButton"; -import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel"; -import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel"; +import AlarmList from "@/components/map/AlarmList"; import ShipInfo from "@/components/map/ShipInfo"; import { TagState, TagStateCallbackPayload } from "@/components/map/TagState"; +import ZoneInMap from "@/components/map/ZoneInMap"; import ShipSearchForm, { SearchShipResponse, } from "@/components/ShipSearchForm"; +import { ThemedText } from "@/components/themed-text"; import { EVENT_SEARCH_THINGS, IOS_PLATFORM, LIGHT_THEME } from "@/constants"; +import { queryBanzoneById } from "@/controller/MapController"; 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 { AntDesign } from "@expo/vector-icons"; +import { AntDesign, Ionicons } from "@expo/vector-icons"; import { useEffect, useRef, useState } from "react"; import { Animated, + Dimensions, Image, ScrollView, StyleSheet, Text, + TouchableOpacity, View, } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; 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() { - const [alarmData, setAlarmData] = useState(null); - const [banzoneData, setBanzoneData] = useState(null); - const [trackPointsData, setTrackPointsData] = useState< - Model.ShipTrackPoint[] | null - >(null); - const [circleRadius, setCircleRadius] = useState(100); - const [zoomLevel, setZoomLevel] = useState(10); + const mapRef = useRef(null); + const [alarms, setAlarms] = useState([]); + const [banzoneWithAlarm, setBanzoneWithAlarm] = + useState(null); + const [allBanZones, setAllBanZones] = useState([]); + const [showAllAlarmsOnMap, setShowAllAlarmsOnMap] = useState(false); const [isFirstLoad, setIsFirstLoad] = useState(true); - const [polylineCoordinates, setPolylineCoordinates] = useState< - PolylineWithLabelProps[] - >([]); - const [polygonCoordinates, setPolygonCoordinates] = useState< - PolygonWithLabelProps[] - >([]); + const [shipSearchFormOpen, setShipSearchFormOpen] = useState(false); const [isPanelExpanded, setIsPanelExpanded] = useState(false); const [things, setThings] = useState(null); @@ -54,15 +77,36 @@ export default function HomeScreen() { const [tagStatePayload, setTagStatePayload] = useState(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 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(() => { if (tagStatePayload) { searchThings(); } }, [tagStatePayload]); + const openAlarmList = () => { + setIsAlarmListMounted(true); + setIsShowAlarmList(true); + }; + + const closeAlarmList = () => { + // Trigger the animation; the effect will unmount when complete + setIsShowAlarmList(false); + setBanzoneWithAlarm(null); + }; + useEffect(() => { if (shipSearchFormData) { searchThings(); @@ -93,7 +137,7 @@ export default function HomeScreen() { ...thingsData, things: sortedThings, }; - console.log("Things Updated: ", sortedThingsResponse.things?.length); + // console.log("Things Updated: ", sortedThingsResponse.things?.length); setThings(sortedThingsResponse); }; @@ -105,101 +149,85 @@ export default function HomeScreen() { }, []); 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); + if (things?.things) { + const alarmTypes = [ + { + key: "zone_approaching_alarm_list", + type: "approaching" as const, + }, + { key: "zone_entered_alarm_list", type: "entered" as const }, + { key: "zone_fishing_alarm_list", type: "fishing" as const }, + ]; + + 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]); - // useEffect(() => { - // setPolylineCoordinates([]); - // setPolygonCoordinates([]); - // if (!entityData) return; - // if (!banzoneData) return; - // for (const entity of entityData) { - // if (entity.id !== ENTITY.ZONE_ALARM_LIST) { - // continue; - // } + useEffect(() => { + const approaching = alarms.filter((a) => a.type === "approaching"); + const entered = alarms.filter((a) => a.type === "entered"); + const fishing = alarms.filter((a) => a.type === "fishing"); - // 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; - // } + if (approaching.length > 0) { + console.log("ZoneApproachingAlarm: ", approaching); + } else { + // console.log("No ZoneApproachingAlarm"); + } + if (entered.length > 0) { + console.log("ZoneEnteredAlarm: ", entered); + } else { + // console.log("No ZoneEnteredAlarm"); + } + if (fishing.length > 0) { + console.log("ZoneFishingAlarm: ", fishing); + } else { + // console.log("No ZoneFishingAlarm"); + } + }, [alarms]); - // 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 + // Load all banzones when alarms change (for showing all alarms on map) + useEffect(() => { + if (alarms.length > 0 && showAllAlarmsOnMap) { + loadAllBanZones(); + } + }, [alarms, showAllAlarmsOnMap]); const calculateRadiusFromZoom = (zoom: number) => { const baseZoom = 10; @@ -217,8 +245,8 @@ export default function HomeScreen() { // 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); + // setCircleRadius(newRadius); + // setZoomLevel(zoom); // console.log("Zoom level:", zoom, "Circle radius:", newRadius); }; @@ -303,8 +331,53 @@ export default function HomeScreen() { } }, [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 () => { - 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 const stateNormalQuery = tagStatePayload?.isNormal ? "normal" : ""; @@ -391,7 +464,7 @@ export default function HomeScreen() { not_empty: "ship_id", }, }; - console.log("Search Params: ", searchParams); + // console.log("Search Params: ", searchParams); // Gọi API tìm kiếm searchThingEventBus(searchParams); @@ -401,6 +474,50 @@ export default function HomeScreen() { 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 => 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 ? shipSearchFormData.ship_name !== "" || shipSearchFormData.reg_number !== "" || @@ -418,6 +535,7 @@ export default function HomeScreen() { - {things?.things && things.things.length > 0 && ( + {!banzoneWithAlarm && things?.things && things.things.length > 0 && ( <> {things.things .filter((thing) => thing.metadata?.gps) // Filter trước để tránh null check @@ -454,6 +573,10 @@ export default function HomeScreen() { }} zIndex={50} 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 tracksViewChanges={ platform === IOS_PLATFORM ? tracksViewChanges : true @@ -461,6 +584,7 @@ export default function HomeScreen() { // Thêm identifier để iOS optimize identifier={uniqueKey} > + {/* */} {thing.metadata?.state_level === 3 && ( @@ -507,9 +631,16 @@ export default function HomeScreen() { })} )} + {(banzoneWithAlarm || + (showAllAlarmsOnMap && allBanZones.length > 0)) && ( + + )} - - {!isPanelExpanded && ( + + {!isAlarmListMounted && !isPanelExpanded && ( } type="primary" @@ -528,102 +659,158 @@ export default function HomeScreen() { */} {/* Draggable Panel */} - { - console.log("Panel expanded:", expanded); - setIsPanelExpanded(expanded); - }} - > - <> - - { - setTagStatePayload(tagState); - }} - /> - - - - - - Danh sách tàu thuyền - - - {isPanelExpanded && ( - } - type="primary" - shape="circle" - size="middle" - style={{ - // borderRadius: 10, - backgroundColor: hasActiveFilters ? "#B8D576" : "#fff", - }} - onPress={() => setShipSearchFormOpen(true)} - > - )} + {!isAlarmListMounted && ( + { + // console.log("Panel expanded:", expanded); + setIsPanelExpanded(expanded); + }} + > + <> + + { + setTagStatePayload(tagState); + }} + /> + + + + + Danh sách tàu thuyền + + + {isPanelExpanded && ( + } + type="primary" + shape="circle" + size="middle" + style={{ + // borderRadius: 10, + backgroundColor: hasActiveFilters ? "#B8D576" : "#fff", + }} + onPress={() => setShipSearchFormOpen(true)} + > + )} + - + {things && things.things && things.things.length > 0 && ( + <> + {things.things.map((thing, index) => { + return ( + + + {index < (things.things?.length ?? 0) - 1 && ( + + )} + + ); + })} + + )} + + + + + )} + {/* Alarm list overlay */} + {isAlarmListMounted && ( + + + + + Danh sách cảnh báo + + closeAlarmList()} + style={{ padding: 6 }} + > + + + + - {things && things.things && things.things.length > 0 && ( - <> - {things.things.map((thing, index) => { - return ( - - - {index < (things.things?.length ?? 0) - 1 && ( - - )} - - ); - })} - - )} - + {/* Body */} + + - - + + )} setShipSearchFormOpen(false)} onSubmit={handleOnSubmitSearchForm} /> + {!isAlarmListMounted && alarms.length > 0 && ( + + } + type="danger" + size="middle" + onPress={() => openAlarmList()} + > + + {alarms.length} + + + + )} ); diff --git a/app/(tabs)/warning.tsx b/app/(tabs)/warning.tsx index 66874c0..5d7f167 100644 --- a/app/(tabs)/warning.tsx +++ b/app/(tabs)/warning.tsx @@ -1,42 +1,323 @@ -import ShipSearchForm from "@/components/ShipSearchForm"; -import { useState } from "react"; -import { Platform, ScrollView, StyleSheet, Text, View } from "react-native"; +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +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 { 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 ( - - - - Cảnh báo + + + {/* Icon Container */} + + - setShipSearchFormOpen(false)} + + {/* Content */} + + {/* Header: Ship name + Badge */} + + + {alarm.ship_name || alarm.thing_id} + + + + {config.label} + + + + + {/* Zone Info */} + + {alarm.zone.message || alarm.zone.zone_name} + + + {/* Footer: Zone ID + Time */} + + + + + {formatTimestamp(alarm.zone.gps_time)} + + + + + + + ); +}); + +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 }) => ( + handleAlarmPress(item)} /> + ), + [handleAlarmPress] + ); + + const keyExtractor = useCallback( + (item: AlarmData, index: number) => `${item.thing_id}-${index}`, + [] + ); + + const ItemSeparator = useCallback(() => , []); + + // Count alarms by type + const alarmCounts = useMemo(() => { + return displayAlarms.reduce((acc, alarm) => { + acc[alarm.type] = (acc[alarm.type] || 0) + 1; + return acc; + }, {} as Record); + }, [displayAlarms]); + + return ( + + + {/* Header */} + + + + + + Cảnh báo + + + + {displayAlarms.length} + + + + + {/* Stats Bar */} + + {(["entered", "approaching", "fishing"] as AlarmType[]).map( + (type) => { + const config = ALARM_CONFIG[type]; + const count = alarmCounts[type] || 0; + return ( + + + + {count} + + + ); + } + )} + + + {/* Alarm List */} + - + ); } const styles = StyleSheet.create({ - scrollContent: { - flexGrow: 1, - }, container: { + flex: 1, + }, + content: { + flex: 1, + }, + header: { + flexDirection: "row", alignItems: "center", - padding: 15, + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 16, }, titleText: { - fontSize: 32, + fontSize: 26, fontWeight: "700", - lineHeight: 40, - marginBottom: 30, - fontFamily: Platform.select({ - ios: "System", - android: "Roboto", - default: "System", - }), + }, + listContent: { + paddingHorizontal: 16, + paddingBottom: 20, }, }); diff --git a/components/AlarmList.tsx b/components/AlarmList.tsx deleted file mode 100644 index 89f1f8e..0000000 --- a/components/AlarmList.tsx +++ /dev/null @@ -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 ( - ( - onPress?.(item)} - className="flex flex-row gap-5 p-3 justify-start items-baseline w-full" - > - - - - {item.name} - - - {formatTimestamp(item.t)} - - - - )} - 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; diff --git a/components/alarm/WarningCard.tsx b/components/alarm/WarningCard.tsx new file mode 100644 index 0000000..39ca756 --- /dev/null +++ b/components/alarm/WarningCard.tsx @@ -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 ( + } + renderItem={({ item }) => ( + 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 ( + + + {/* Left content */} + + {/* Icon */} + + + + + {/* Info */} + + {/* Code */} + + {alarm.code} + + + {/* Title */} + + {alarm.title} + + + {/* Station and Time */} + + + Trạm + {alarm.station} + + + Thời gian + + {formatTimestamp(alarm.timestamp)} + + + + + {/* Status Badge */} + {/* + + + {statusConfig.label} + + + */} + + + + {/* Checkmark for confirmed */} + {/* {alarm.status === "confirmed" && ( + + + + )} */} + + + ); +}; + +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; diff --git a/components/map/AlarmList.tsx b/components/map/AlarmList.tsx new file mode 100644 index 0000000..c4341ad --- /dev/null +++ b/components/map/AlarmList.tsx @@ -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 ( + + + {/* Icon Container */} + + + + + {/* Content */} + + {/* Header: Ship name + Badge */} + + + {alarm.ship_name || alarm.thing_id} + + + + {config.label} + + + + + {/* Zone Info */} + + {alarm.zone.message || alarm.zone.zone_name} + + + {/* Footer: Zone ID + Time */} + + + + + {formatTimestamp(alarm.zone.gps_time)} + + + + + + + ); +}; + +// ============ Main Component ============ +interface AlarmListProps { + data: AlarmData[]; + onPress?: (alarm: AlarmData) => void; +} + +export default function AlarmList({ data, onPress }: AlarmListProps) { + const renderItem = useCallback( + ({ item }: { item: AlarmData }) => ( + onPress?.(item)} /> + ), + [onPress] + ); + + const keyExtractor = useCallback( + (item: AlarmData, index: number) => `${item.thing_id}-${index}`, + [] + ); + + const ItemSeparator = useCallback(() => , []); + + return ( + + ); +} diff --git a/components/map/CircleWithLabel.tsx b/components/map/CircleWithLabel.tsx new file mode 100644 index 0000000..854d63d --- /dev/null +++ b/components/map/CircleWithLabel.tsx @@ -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 = ({ + 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(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 ( + <> + + {label && ( + + + + + {label} + + {content && ( + + {content} + + )} + + + + )} + + ); +}; + +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, + }, +}); diff --git a/components/map/MarkerCustom.tsx b/components/map/MarkerCustom.tsx new file mode 100644 index 0000000..fbb7759 --- /dev/null +++ b/components/map/MarkerCustom.tsx @@ -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 = ({ + 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 ( + + + + {animated && stateLevel === 3 && ( + + )} + { + 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`, + }, + ], + }} + /> + + + + ); +}; + +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", + }, +}); diff --git a/components/map/ZoneInMap.tsx b/components/map/ZoneInMap.tsx new file mode 100644 index 0000000..2109d11 --- /dev/null +++ b/components/map/ZoneInMap.tsx @@ -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; +} + +// 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( + ({ + 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( + ({ + 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( + ({ + 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( + + ); + // 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( + + ); + } + }); + + // console.log(`Total elements rendered: ${elements.length}`); + return elements; + }, [banzones]); + + return <>{allElements}; +}; + +export default ZoneInMap; diff --git a/controller/MapController.ts b/controller/MapController.ts index 17f1a27..c2b2715 100644 --- a/controller/MapController.ts +++ b/controller/MapController.ts @@ -4,3 +4,7 @@ import { API_GET_ALL_BANZONES } from "@/constants"; export async function queryBanzones() { return api.get(API_GET_ALL_BANZONES); } + +export async function queryBanzoneById(zoneId: string) { + return api.get(`${API_GET_ALL_BANZONES}/${zoneId}`); +} diff --git a/controller/typings.d.ts b/controller/typings.d.ts index 20c9e4f..7b6543e 100644 --- a/controller/typings.d.ts +++ b/controller/typings.d.ts @@ -60,7 +60,9 @@ declare namespace Model { conditions?: Condition[]; enabled?: boolean; updated_at?: Date; - geom?: Geom; + geometry?: string; + description?: string; + province_code?: string; } interface Condition { diff --git a/services/time_service.tsx b/services/time_service.tsx index 904d08c..b9d425d 100644 --- a/services/time_service.tsx +++ b/services/time_service.tsx @@ -41,3 +41,8 @@ export function formatRelativeTime(unixTime: number): string { if (diffMonths < 12) return `${diffMonths} tháng 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"); +};