feat(map): add event-driven GPS update

This commit is contained in:
Lê Tuấn Anh
2025-11-20 09:05:07 +07:00
parent eed98f7c29
commit dea435a4ec
11 changed files with 333 additions and 159 deletions

View File

@@ -0,0 +1,19 @@
// src/hooks/useRealtimeGps.ts
import { useEffect, useState } from 'react';
import { eventBus } from '@/utils/eventBus';
export default function useRealtimeGps() {
const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null);
useEffect(() => {
const handleGpsUpdate = (data: API.GPSResonse) => {
setGpsData(data);
};
eventBus.on('gpsData:update', handleGpsUpdate);
// cleanup khi unmount
return () => eventBus.off('gpsData:update', handleGpsUpdate);
}, []);
return gpsData;
}

View File

@@ -1,22 +1,43 @@
// src/models/useGetGpsModel.ts
import { getGPS } from '@/services/controller/DeviceController';
import { useCallback, useState } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { eventBus } from '@/utils/eventBus';
export default function useGetGpsModel() {
const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null);
const [loading, setLoading] = useState(false);
const getGPSData = useCallback(async () => {
const getGPSData = useCallback(async () => {
setLoading(true);
try {
const res = await getGPS(); // đổi URL cho phù hợp
console.log('GPS Data fetched:', res);
// Bypass cache để GPS luôn realtime, gọi trực tiếp API
const data = await getGPS();
console.log('GPS fetched from API:', data);
setGpsData(res);
setGpsData(data);
// Luôn emit event GPS để đảm bảo realtime
try {
eventBus.emit('gpsData:update', data);
} catch (e) {
console.warn('Failed to emit gpsData:update event', e);
}
} catch (err) {
console.error('Fetch gps data failed', err);
} finally {
setLoading(false);
}
}, []);
// ✅ Lắng nghe event cập nhật cache từ nơi khác (nếu có)
useEffect(() => {
const handleUpdate = (data: API.GPSResonse) => {
console.log('GPS cache updated via eventBus');
setGpsData(data);
};
eventBus.on('gpsData:update', handleUpdate);
return () => eventBus.off('gpsData:update', handleUpdate);
}, []);
return {
gpsData,
loading,

View File

@@ -59,11 +59,6 @@ interface FeatureData {
text?: string;
}
// Interface for layer object
interface Layer {
layer: VectorLayer<VectorSource>;
}
class MapManager {
mapRef: React.RefObject<HTMLDivElement>;
map: Map | null;

View File

@@ -23,12 +23,16 @@ import {
InfoOutlined,
} from '@ant-design/icons';
import { history, useModel } from '@umijs/max';
import { eventBus } from '@/utils/eventBus';
import { FloatButton, Popover } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import GpsInfo from './components/GpsInfo';
import ShipInfo from './components/ShipInfo';
import SosButton from './components/SosButton';
import VietNamMap, { MapManager } from './components/VietNamMap';
import Point from 'ol/geom/Point';
import LineString from 'ol/geom/LineString';
import { fromLonLat } from 'ol/proj';
// // Define missing types locally for now
// interface AlarmData {
@@ -56,7 +60,11 @@ const HomePage: React.FC = () => {
const mapRef = useRef<HTMLDivElement>(null);
const [isShipInfoOpen, setIsShipInfoOpen] = useState(false);
const { gpsData, getGPSData } = useModel('getSos');
const hasCenteredRef = useRef(false);
const banzonesCacheRef = useRef<any[]>([]);
function isSameBanzones(newData: any[], oldData: any[]): boolean {
return JSON.stringify(newData) === JSON.stringify(oldData);
}
const onFeatureClick = useCallback((feature: any) => {
console.log('OnClick Feature: ', feature);
console.log(
@@ -90,46 +98,19 @@ const HomePage: React.FC = () => {
const [isShowGPSData, setIsShowGPSData] = useState(true);
useEffect(() => {
getGPSData();
const interval = setInterval(() => {
getGPSData();
getEntitiesData(mapManagerRef.current, gpsData!);
fetchAndAddTrackPoints(mapManagerRef.current);
fetchAndProcessEntities(mapManagerRef.current);
}, 5000);
return () => {
clearInterval(interval);
console.log('MapManager destroyed in HomePage cleanup');
};
}, []);
// Hàm tổng hợp: gọi GPS trước, sau đó gọi các API còn lại
const refreshAllData = async () => {
try {
// Gọi GPS, khi xong sẽ emit event gpsData:update và các API khác sẽ tự động được gọi qua event handler
await getGPSData();
} catch (e) {
console.error('refreshAllData error', e);
}
};
useEffect(() => {
const timer = setTimeout(() => {
// getGPSData();
getEntitiesData(mapManagerRef.current, gpsData!);
fetchAndAddTrackPoints(mapManagerRef.current);
fetchAndProcessEntities(mapManagerRef.current);
}, 1000); // delay 1s
return () => clearTimeout(timer);
}, [gpsData]);
// Helper function to add features to the map
const addFeatureToMap = (
mapManager: MapManager,
gpsData: GpsData,
alarm: API.AlarmResponse,
) => {
console.log(
'Adding feature to map with GPS data:',
gpsData,
'and alarm:',
alarm,
);
const isSos = alarm?.alarms.find(
(a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING,
);
console.log('Is SOS Alarm Present:', isSos);
// Helper function to add features to the map (kept above pollers to avoid use-before-define)
function addFeatureToMap(mapManager: MapManager, gpsData: GpsData, alarm: API.AlarmResponse,) {
const isSos = alarm?.alarms.find((a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING);
if (mapManager?.featureLayer && gpsData) {
mapManager.featureLayer.getSource()?.clear();
@@ -143,26 +124,158 @@ const HomePage: React.FC = () => {
},
);
}
};
}
const fetchAndProcessEntities = async (mapManager: MapManager) => {
useEffect(() => {
// Chỉ poll GPS, các API còn lại sẽ được gọi khi có GPS mới
getGPSData();
const gpsInterval = setInterval(() => {
getGPSData();
}, 5000); // cập nhật mỗi 5s
// Vẽ marker tàu và polyline ngay khi vào trang nếu đã có dữ liệu
if (gpsData) {
eventBus.emit('ship:update', gpsData);
}
// Nếu có trackpoints, emit luôn (nếu bạn lưu trackpoints ở state, có thể emit ở đây)
// eventBus.emit('trackpoints:update', trackpoints);
return () => {
clearInterval(gpsInterval);
console.log('MapManager destroyed in HomePage cleanup');
};
}, []);
// Lắng nghe eventBus cho tất cả các loại dữ liệu
useEffect(() => {
// GPS cập nhật => emit ship update ngay lập tức, gọi API khác không đồng bộ
const onGpsUpdate = async (data: API.GPSResonse) => {
try {
if (data) {
// Emit ship update ngay lập tức để marker tàu cập nhật realtime
eventBus.emit('ship:update', data);
// Gọi các API khác trong background, không block ship update
setTimeout(async () => {
try {
const alarm = await queryAlarms();
eventBus.emit('alarm:update', alarm);
const trackpoints = await queryShipTrackPoints();
eventBus.emit('trackpoints:update', trackpoints);
const entities = await queryEntities();
eventBus.emit('entities:update', entities);
} catch (e) {
console.error('Background API calls error', e);
}
}, 0);
}
} catch (e) {
console.error('onGpsUpdate handler error', e);
}
};
// Cập nhật marker tàu khi có GPS mới
const onShipUpdate = (data: API.GPSResonse) => {
const mapManager = mapManagerRef.current;
if (!mapManager || !data) return;
// Cache feature trong mapManager
if (!mapManager.shipFeature) {
mapManager.shipFeature = mapManager.addPoint(
[data.lon, data.lat],
{ bearing: data.h || 0 },
{
icon: getShipIcon(0, data?.fishing || false),
scale: 0.1,
animate: false,
},
);
} else {
const geom = mapManager.shipFeature.getGeometry();
if (geom instanceof Point) {
geom.setCoordinates([data.lon, data.lat]);
}
}
// Set map center to current GPS position
if (!hasCenteredRef.current && mapManager.map && mapManager.map.getView) {
mapManager.map.getView().setCenter(fromLonLat([data.lon, data.lat]));
hasCenteredRef.current = true;
}
};
// Cập nhật alarm khi có event
const onAlarmUpdate = (alarm: API.AlarmResponse) => {
if (gpsData) addFeatureToMap(mapManagerRef.current, gpsData, alarm);
};
// Cập nhật trackpoints khi có event
const onTrackpointsUpdate = (trackpoints: API.ShipTrackPoint[]) => {
const source = mapManagerRef.current?.featureLayer?.getSource();
if (source && trackpoints.length > 0) {
const features = source.getFeatures();
// Tìm polyline theo id
const polyline = features.find(f => f.get('id') === MAP_TRACKPOINTS_ID);
const coordinates = trackpoints.map((point) => [point.lon, point.lat]);
if (polyline && polyline.getGeometry() instanceof LineString) {
polyline.getGeometry().setCoordinates(coordinates);
} else {
mapManagerRef.current.addLineString(
coordinates,
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
}
};
// Cập nhật entities/banzones khi có event
const onEntitiesUpdate = async (entities: API.TransformedEntity[]) => {
const mapManager = mapManagerRef.current;
if (mapManager) {
await drawBanzones(mapManager);
}
};
eventBus.on('gpsData:update', onGpsUpdate);
eventBus.on('ship:update', onShipUpdate);
eventBus.on('alarm:update', onAlarmUpdate);
eventBus.on('trackpoints:update', onTrackpointsUpdate);
eventBus.on('entities:update', onEntitiesUpdate);
return () => {
eventBus.off('gpsData:update', onGpsUpdate);
eventBus.off('ship:update', onShipUpdate);
eventBus.off('alarm:update', onAlarmUpdate);
eventBus.off('trackpoints:update', onTrackpointsUpdate);
eventBus.off('entities:update', onEntitiesUpdate);
};
}, [gpsData]);
// Vẽ lại banzones (polygon, polyline vùng cấm, vùng cá thử...)
async function drawBanzones(mapManager: MapManager) {
try {
const entities: API.TransformedEntity[] = await queryEntities();
console.log('Fetched entities:', entities.length);
const layer = mapManager.featureLayer?.getSource();
layer?.getFeatures()?.forEach(f => {
if ([MAP_POLYGON_BAN, MAP_POLYLINE_BAN].includes(f.get('id'))) {
layer.removeFeature(f);
}
});
const entities = await queryEntities();
for (const entity of entities) {
if (entity.id === '50:2' && entity.valueString !== '[]') {
const banzones = await queryBanzones();
const zones: any[] = JSON.parse(entity.valueString);
zones.forEach((zone: any) => {
const geom = banzones.find((b) => b.id === zone.zone_id);
if (geom) {
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
if (geom_type === 2) {
const coordinates = convertWKTLineStringToLatLngArray(
geom_lines || '',
);
const coordinates = convertWKTLineStringToLatLngArray(geom_lines || '');
if (coordinates.length > 0) {
mapManager.addLineString(
coordinates,
@@ -197,33 +310,17 @@ const HomePage: React.FC = () => {
}
}
} catch (error) {
console.error('Error fetching entities:', error);
console.error('Error drawing banzones:', error);
}
};
}
const fetchAndAddTrackPoints = async (mapManager: MapManager) => {
try {
const trackpoints: API.ShipTrackPoint[] = await queryShipTrackPoints();
if (trackpoints.length > 0) {
mapManager.addLineString(
trackpoints.map((point) => [point.lon, point.lat]),
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
} catch (error) {
console.error('Error fetching ship track points:', error);
useEffect(() => {
// Vẽ lại banzones khi vào trang
const mapManager = mapManagerRef.current;
if (mapManager) {
drawBanzones(mapManager);
}
};
const getEntitiesData = async (mapManager: MapManager, gpsData: GpsData) => {
try {
const alarm: API.AlarmResponse = await queryAlarms();
addFeatureToMap(mapManager, gpsData, alarm);
} catch (error) {
console.error('Error fetching entities:', error);
}
};
}, []);
return (
<div>
@@ -269,14 +366,9 @@ const HomePage: React.FC = () => {
</Popover>
<div className="absolute top-3 right-3 ">
<SosButton
onRefresh={(value) => {
onRefresh={async (value) => {
if (value) {
// Ensure null check for gpsData in all usages
if (gpsData) {
getEntitiesData(mapManagerRef.current, gpsData);
} else {
console.warn('GPS data is null, skipping getEntitiesData call');
}
await refreshAllData();
}
}}
/>
@@ -286,4 +378,4 @@ const HomePage: React.FC = () => {
);
};
export default HomePage;
export default HomePage;

View File

@@ -31,7 +31,6 @@ export const osmLayer = new TileLayer({
});
// const layerVN = await getLayer('base-vn');
export const tinhGeoLayer = createGeoJSONLayer({
data: '',
style: createLabelAndFillStyle('#77BEF0', '#000000'), // vừa màu vừa label

30
src/utils/cacheStore.ts Normal file
View File

@@ -0,0 +1,30 @@
// src/utils/cacheStore.ts
interface CacheItem<T> {
data: T;
timestamp: number;
}
const TTL = 1 * 1000; // dữ liệu có hiệu lực trong 1 giây để GPS realtime
const cache: Record<string, CacheItem<any>> = {};
export function getCache<T>(key: string): T | null {
const item = cache[key];
if (!item) return null;
if (Date.now() - item.timestamp > TTL) {
delete cache[key];
return null;
}
return item.data;
}
export function setCache<T>(key: string, data: T) {
cache[key] = { data, timestamp: Date.now() };
}
export function invalidate(key: string) {
delete cache[key];
}
export function getAllCacheKeys(): string[] {
return Object.keys(cache);
}

24
src/utils/eventBus.ts Normal file
View File

@@ -0,0 +1,24 @@
// src/utils/eventBus.ts
type EventHandler<T = any> = (data: T) => void;
class EventBus {
private listeners: Record<string, EventHandler[]> = {};
on(event: string, handler: EventHandler) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(handler);
}
off(event: string, handler: EventHandler) {
this.listeners[event] = (this.listeners[event] || []).filter(
(h) => h !== handler,
);
}
emit(event: string, data?: any) {
(this.listeners[event] || []).forEach((h) => h(data));
}
}
// ✅ phải có dòng này
export const eventBus = new EventBus();

View File

@@ -0,0 +1,27 @@
// utils/queryWithCache.ts
import { getCache, setCache, invalidate, getAllCacheKeys } from './cacheStore';
import { eventBus } from './eventBus';
export async function queryWithCache<T>(
key: string,
fetcher: () => Promise<T>,
): Promise<T> {
const cached = getCache<T>(key);
if (cached) return cached;
const data = await fetcher();
setCache(key, data);
// Phát sự kiện để các component khác biết có data mới
eventBus.emit(`${key}:update`, data);
return data;
}
// Xóa cache theo key hoặc prefix
queryWithCache.clear = (prefix = '') => {
const keys = getAllCacheKeys();
for (const key of keys) {
if (key.startsWith(prefix)) invalidate(key);
}
};