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

@@ -1,5 +1,4 @@
import { ROUTE_LOGIN } from '@/constants'; import { ROUTE_LOGIN } from '@/constants';
import { apiConfig } from '@/services/ApiConfigService';
import { getToken, removeToken } from '@/utils/localStorageUtils'; import { getToken, removeToken } from '@/utils/localStorageUtils';
import { history, RequestConfig } from '@umijs/max'; import { history, RequestConfig } from '@umijs/max';
import { message } from 'antd'; import { message } from 'antd';
@@ -85,18 +84,9 @@ export const handleRequestConfig: RequestConfig = {
// Request interceptors // Request interceptors
requestInterceptors: [ requestInterceptors: [
(url: string, options: any) => { (url: string, options: any) => {
console.log('URL Request:', url, options);
// Nếu URL không phải absolute URL, thêm base URL
let finalUrl = url;
if (!url.startsWith('http')) {
const baseUrl = apiConfig.getApiBaseUrl();
finalUrl = `${baseUrl}${url}`;
}
const token = getToken(); const token = getToken();
return { return {
url: finalUrl, url,
options: { options: {
...options, ...options,
headers: { headers: {

View File

@@ -1,59 +1,24 @@
// Hàm lấy IP từ hostname hiện tại (chỉ hoạt động runtime) const proxy: Record<string, any> = {
const getCurrentIP = () => {
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
// Nếu là localhost hoặc IP local, trả về IP mặc định
if (
hostname === 'localhost' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.')
) {
console.log('Host name: ', hostname);
return hostname;
}
// Nếu là domain, có thể cần map sang IP tương ứng
return hostname;
}
return process.env.REACT_APP_API_HOST || '192.168.30.102'; // fallback từ env
};
// Hàm tạo proxy config động
const createDynamicProxy = () => {
const currentIP = getCurrentIP();
const isLocalIP =
currentIP.startsWith('192.168.') ||
currentIP.startsWith('10.') ||
currentIP === 'localhost';
return {
dev: { dev: {
'/api': { '/api': {
target: `http://${currentIP}:81`, target: 'http://192.168.30.103:81',
changeOrigin: true, changeOrigin: true,
}, },
}, },
test: { test: {
'/test': { '/test': {
target: isLocalIP target: 'https://test-sgw-device.gms.vn',
? `http://${currentIP}:81`
: 'https://test-sgw-device.gms.vn',
changeOrigin: true, changeOrigin: true,
secure: !isLocalIP, secure: false,
}, },
}, },
prod: { prod: {
'/test': { '/test': {
target: isLocalIP target: 'https://prod-sgw-device.gms.vn',
? `http://${currentIP}:81`
: 'https://prod-sgw-device.gms.vn',
changeOrigin: true, changeOrigin: true,
secure: !isLocalIP, secure: false,
}, },
}, },
};
}; };
const proxy: Record<string, any> = createDynamicProxy();
export default proxy; export default proxy;

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 { 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() { export default function useGetGpsModel() {
const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null); const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const getGPSData = useCallback(async () => { const getGPSData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await getGPS(); // đổi URL cho phù hợp // Bypass cache để GPS luôn realtime, gọi trực tiếp API
console.log('GPS Data fetched:', res); 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) { } catch (err) {
console.error('Fetch gps data failed', err); console.error('Fetch gps data failed', err);
} finally { } finally {
setLoading(false); 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 { return {
gpsData, gpsData,
loading, loading,

View File

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

View File

@@ -23,12 +23,16 @@ import {
InfoOutlined, InfoOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { history, useModel } from '@umijs/max'; import { history, useModel } from '@umijs/max';
import { eventBus } from '@/utils/eventBus';
import { FloatButton, Popover } from 'antd'; import { FloatButton, Popover } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import GpsInfo from './components/GpsInfo'; import GpsInfo from './components/GpsInfo';
import ShipInfo from './components/ShipInfo'; import ShipInfo from './components/ShipInfo';
import SosButton from './components/SosButton'; import SosButton from './components/SosButton';
import VietNamMap, { MapManager } from './components/VietNamMap'; 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 // // Define missing types locally for now
// interface AlarmData { // interface AlarmData {
@@ -56,7 +60,11 @@ const HomePage: React.FC = () => {
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const [isShipInfoOpen, setIsShipInfoOpen] = useState(false); const [isShipInfoOpen, setIsShipInfoOpen] = useState(false);
const { gpsData, getGPSData } = useModel('getSos'); 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) => { const onFeatureClick = useCallback((feature: any) => {
console.log('OnClick Feature: ', feature); console.log('OnClick Feature: ', feature);
console.log( console.log(
@@ -90,46 +98,19 @@ const HomePage: React.FC = () => {
const [isShowGPSData, setIsShowGPSData] = useState(true); const [isShowGPSData, setIsShowGPSData] = useState(true);
useEffect(() => { // Hàm tổng hợp: gọi GPS trước, sau đó gọi các API còn lại
getGPSData(); const refreshAllData = async () => {
const interval = setInterval(() => { try {
getGPSData(); // 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
getEntitiesData(mapManagerRef.current, gpsData!); await getGPSData();
fetchAndAddTrackPoints(mapManagerRef.current); } catch (e) {
fetchAndProcessEntities(mapManagerRef.current); console.error('refreshAllData error', e);
}, 5000); }
return () => {
clearInterval(interval);
console.log('MapManager destroyed in HomePage cleanup');
}; };
}, []);
useEffect(() => { // Helper function to add features to the map (kept above pollers to avoid use-before-define)
const timer = setTimeout(() => { function addFeatureToMap(mapManager: MapManager, gpsData: GpsData, alarm: API.AlarmResponse,) {
// getGPSData(); const isSos = alarm?.alarms.find((a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING);
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);
if (mapManager?.featureLayer && gpsData) { if (mapManager?.featureLayer && gpsData) {
mapManager.featureLayer.getSource()?.clear(); mapManager.featureLayer.getSource()?.clear();
@@ -143,26 +124,158 @@ const HomePage: React.FC = () => {
}, },
); );
} }
}
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);
}
}; };
const fetchAndProcessEntities = async (mapManager: MapManager) => {
try {
const entities: API.TransformedEntity[] = await queryEntities();
console.log('Fetched entities:', entities.length);
// 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 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) { for (const entity of entities) {
if (entity.id === '50:2' && entity.valueString !== '[]') { if (entity.id === '50:2' && entity.valueString !== '[]') {
const banzones = await queryBanzones(); const banzones = await queryBanzones();
const zones: any[] = JSON.parse(entity.valueString); const zones: any[] = JSON.parse(entity.valueString);
zones.forEach((zone: any) => { zones.forEach((zone: any) => {
const geom = banzones.find((b) => b.id === zone.zone_id); const geom = banzones.find((b) => b.id === zone.zone_id);
if (geom) { if (geom) {
const { geom_type, geom_lines, geom_poly } = geom.geom || {}; const { geom_type, geom_lines, geom_poly } = geom.geom || {};
if (geom_type === 2) { if (geom_type === 2) {
const coordinates = convertWKTLineStringToLatLngArray( const coordinates = convertWKTLineStringToLatLngArray(geom_lines || '');
geom_lines || '',
);
if (coordinates.length > 0) { if (coordinates.length > 0) {
mapManager.addLineString( mapManager.addLineString(
coordinates, coordinates,
@@ -197,33 +310,17 @@ const HomePage: React.FC = () => {
} }
} }
} catch (error) { } catch (error) {
console.error('Error fetching entities:', error); console.error('Error drawing banzones:', error);
}
} }
};
const fetchAndAddTrackPoints = async (mapManager: MapManager) => { useEffect(() => {
try { // Vẽ lại banzones khi vào trang
const trackpoints: API.ShipTrackPoint[] = await queryShipTrackPoints(); const mapManager = mapManagerRef.current;
if (trackpoints.length > 0) { if (mapManager) {
mapManager.addLineString( drawBanzones(mapManager);
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);
}
};
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 ( return (
<div> <div>
@@ -269,14 +366,9 @@ const HomePage: React.FC = () => {
</Popover> </Popover>
<div className="absolute top-3 right-3 "> <div className="absolute top-3 right-3 ">
<SosButton <SosButton
onRefresh={(value) => { onRefresh={async (value) => {
if (value) { if (value) {
// Ensure null check for gpsData in all usages await refreshAllData();
if (gpsData) {
getEntitiesData(mapManagerRef.current, gpsData);
} else {
console.warn('GPS data is null, skipping getEntitiesData call');
}
} }
}} }}
/> />

View File

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

View File

@@ -1,3 +1,15 @@
{ {
"extends": "./src/.umi/tsconfig.json" "extends": "./src/.umi/tsconfig.json",
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "es2017"],
"module": "esnext",
"moduleResolution": "node",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
} }