8 Commits

Author SHA1 Message Date
Tran Anh Tuan
ff66a95bc5 chore(version): update version to 1.2.1 2025-10-09 11:24:16 +07:00
Tran Anh Tuan
cd8332a7ef chore(proxy): change proxy to request 2025-10-09 11:21:13 +07:00
Tran Anh Tuan
65f9468bbd chore(maps): display position in DMS 2025-10-08 09:38:54 +07:00
Tran Anh Tuan
28739ddcd9 style(maps): update base color for map's layer 2025-10-01 09:30:15 +07:00
Tran Anh Tuan
53dfb861dd chore(release): bump version to 1.2 2025-09-30 14:13:19 +07:00
Tran Anh Tuan
076d0460cb chore(maps): update animation marker when sos_alarm 2025-09-30 14:04:02 +07:00
Tran Anh Tuan
b44f1a9b29 chore(maps): update banzone display when alarm 2025-09-29 21:11:51 +07:00
Tran Anh Tuan
ef353862e4 style(trips): fix responsive layout 2025-09-29 15:05:51 +07:00
23 changed files with 596 additions and 73 deletions

View File

@@ -1,6 +1,5 @@
import { defineConfig } from '@umijs/max'; import { defineConfig } from '@umijs/max';
import proxy from './config/proxy'; import proxy from './config/proxy';
const { REACT_APP_ENV = 'dev' } = process.env as { const { REACT_APP_ENV = 'dev' } = process.env as {
REACT_APP_ENV: 'dev' | 'test' | 'prod'; REACT_APP_ENV: 'dev' | 'test' | 'prod';
}; };
@@ -14,6 +13,7 @@ export default defineConfig({
locale: { locale: {
default: 'vi-VN', default: 'vi-VN',
}, },
favicons: ['/logo.png'],
layout: { layout: {
title: '2025 Sản phẩm của Mobifone v1.0', title: '2025 Sản phẩm của Mobifone v1.0',
}, },

View File

@@ -1,7 +1,7 @@
const proxy: Record<string, any> = { const proxy: Record<string, any> = {
dev: { dev: {
'/api': { '/api': {
target: 'http://192.168.30.102:81', target: 'http://192.168.30.103:81',
changeOrigin: true, changeOrigin: true,
}, },
}, },

1
public/avatar.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1679201365371" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1897" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M509.31257004 540.79389452h4.91415801c44.99525866-0.76783723 81.39074114-16.58528313 108.26504222-46.83806846 59.12346278-66.6482671 49.29514673-180.9024399 48.2201749-191.80572705-3.83918618-81.85144307-42.53817965-121.01113989-74.48020676-139.28566479C572.42878628 149.19693275 544.63307997 141.82569571 513.61245812 141.21142578H511.00181181c-17.04598575 0-50.52368662 2.76421361-82.61928101 21.03873851-32.24916171 18.27452489-71.56242513 57.43422101-75.40161058 139.89993473-1.07497185 10.90328787-10.90328787 125.15746067 48.22017491 191.80572704 26.72073376 30.2527846 63.11621625 46.07023049 108.11147491 46.83806846z m-115.32914464-234.80461006c0-0.46070263 0.15356731-0.92140454 0.15356731-1.22853914 5.06772532-110.10785129 83.23355022-121.93254442 116.7112518-121.93254443H512.69105358c41.4632078 0.92140454 111.95066108 17.81382227 116.71125108 121.93254443 0 0.46070263 0 0.92140454 0.15356731 1.22853914 0.15356731 1.07497185 10.90328787 105.50082861-37.93115624 160.47797059-19.34949674 21.80657575-45.14882595 32.55629632-79.08722946 32.86343166h-1.53567446c-33.78483618-0.30713461-59.73773271-11.05685517-78.93366214-32.86343165-48.68087753-54.67000739-38.23829157-159.55656606-38.08472427-160.4779706z" p-id="1898"></path><path d="M827.3507296 730.29611058v-0.46070263c0-1.22853915-0.15356731-2.457079-0.15356731-3.83918618-0.92140454-30.40635263-2.91778164-101.50807511-69.56604874-124.23605539-0.46070263-0.15356731-1.07497185-0.30713461-1.53567375-0.46070264-69.25891341-17.66025497-126.84670245-57.5877883-127.46097237-58.04849021-9.3676134-6.60339979-22.26727838-4.29988808-28.87067744 5.06772532-6.60339979 9.3676134-4.29988808 22.26727838 5.0677253 28.87067743 2.6106463 1.84280907 63.73048619 44.38098873 140.20706862 64.03762079 35.78121256 12.74609694 39.77396603 50.98438852 40.84893787 85.99776458 0 1.38210717 0 2.6106463 0.15356802 3.83918545 0.15356731 13.82106951-0.76783723 35.16694264-3.22491624 47.45233838-24.87792469 14.12820412-122.39324633 62.96264895-270.73938991 62.96264823-147.73187366 0-245.86146522-48.98801213-270.89295721-63.11621625-2.457079-12.28539504-3.53205084-33.63126816-3.22491624-47.45233767 0-1.22853915 0.15356731-2.457079 0.15356802-3.83918545 1.07497185-35.01337533 5.06772532-73.25166689 40.84893786-85.99776457 76.47658314-19.65663206 137.596423-62.34837901 140.20706862-64.03762079 9.3676134-6.60339979 11.67112511-19.50306404 5.06772531-28.87067744-6.60339979-9.3676134-19.50306404-11.67112511-28.87067745-5.06772605-0.61426993 0.46070263-57.89492363 40.38823598-127.46097236 58.04849094-0.61426993 0.15356731-1.07497185 0.30713461-1.53567375 0.46070264-66.6482671 22.88154832-68.64464421 93.9832708-69.56604874 124.2360554 0 1.38210717 0 2.6106463-0.15356731 3.83918617v0.46070191c-0.15356731 7.98550697-0.30713461 48.98801213 7.83193893 69.56604875 1.53567446 3.99275348 4.29988808 7.37123702 7.98550697 9.67474873 4.60702342 3.07134893 115.02200931 73.4052342 299.76363465 73.40523419s295.15661197-70.48745329 299.76363538-73.40523419c3.53205084-2.3035117 6.44983249-5.68199525 7.98550624-9.67474873 7.67837163-20.4244693 7.52480433-61.42697448 7.37123703-69.41248072z" p-id="1899"></path></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,8 +1,9 @@
import { LogoutOutlined } from '@ant-design/icons';
import { history, RunTimeLayoutConfig } from '@umijs/max'; import { history, RunTimeLayoutConfig } from '@umijs/max';
import { Dropdown } from 'antd';
import { handleRequestConfig } from '../config/Request'; import { handleRequestConfig } from '../config/Request';
import logo from '../public/logo.png'; import logo from '../public/logo.png';
import UnAccessPage from './components/403/403Page'; import UnAccessPage from './components/403/403Page';
import Footer from './components/Footer/Footer';
import { ROUTE_LOGIN } from './constants'; import { ROUTE_LOGIN } from './constants';
import { parseJwt } from './utils/jwtTokenUtils'; import { parseJwt } from './utils/jwtTokenUtils';
import { getToken, removeToken } from './utils/localStorageUtils'; import { getToken, removeToken } from './utils/localStorageUtils';
@@ -69,13 +70,40 @@ export const layout: RunTimeLayoutConfig = (initialState) => {
}, },
contentStyle: { contentStyle: {
padding: 0, padding: 0,
margin: 0,
paddingInline: 0,
},
avatarProps: {
size: 'small',
src: '/avatar.svg', // 👈 ở đây dùng icon thay vì src
render: (_, dom) => {
return (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Đăng xuất',
onClick: () => {
removeToken();
history.push(ROUTE_LOGIN);
},
},
],
}}
>
{dom}
</Dropdown>
);
},
}, },
layout: 'mix', layout: 'mix',
logout: () => { logout: () => {
removeToken(); removeToken();
history.push(ROUTE_LOGIN); history.push(ROUTE_LOGIN);
}, },
footerRender: () => <Footer />, // footerRender: () => <Footer />,
onPageChange: () => { onPageChange: () => {
if (!initialState.initialState) { if (!initialState.initialState) {
history.push(ROUTE_LOGIN); history.push(ROUTE_LOGIN);
@@ -103,6 +131,12 @@ export const layout: RunTimeLayoutConfig = (initialState) => {
}, },
], ],
unAccessible: <UnAccessPage />, unAccessible: <UnAccessPage />,
token: {
pageContainer: {
paddingInlinePageContainerContent: 0,
paddingBlockPageContainerContent: 0,
},
},
}; };
}; };

View File

@@ -1,7 +1,15 @@
import { DefaultFooter } from '@ant-design/pro-components'; import { DefaultFooter } from '@ant-design/pro-components';
import './style.less';
const Footer = () => { const Footer = () => {
return <DefaultFooter copyright="2025 Sản phẩm của Mobifone v1.0" />; return (
<DefaultFooter
style={{
background: 'none',
color: 'white',
}}
copyright="2025 Sản phẩm của Mobifone v1.2.1"
/>
);
}; };
export default Footer; export default Footer;

View File

@@ -0,0 +1,3 @@
.ant-pro-global-footer-copyright {
color: white !important; /* hoặc mã màu bạn muốn */
}

View File

@@ -6,3 +6,7 @@ export enum STATUS {
UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS', UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS',
UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL', UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL',
} }
export enum ENTITY_TYPE_ENUM {
SOS_WARNING = '50:15',
}

View File

@@ -1,6 +1,10 @@
export const DEFAULT_NAME = 'Umi Max'; export const DEFAULT_NAME = 'Umi Max';
export const TOKEN = 'token'; export const TOKEN = 'token';
export const BASE_URL = 'https://sgw-device.gms.vn'; export const BASE_URL = 'https://sgw-device.gms.vn';
export const MAP_TRACKPOINTS_ID = 'ship-trackpoints';
export const MAP_POLYLINE_BAN = 'ban-polyline';
export const MAP_POLYGON_BAN = 'ban-polygon';
// Global Constants // Global Constants
// Route Constants // Route Constants
@@ -10,15 +14,17 @@ export const ROUTE_TRIP = '/trip';
// API Path Constants // API Path Constants
export const API_PATH_LOGIN = '/api/agent/login'; export const API_PATH_LOGIN = '/api/agent/login';
export const API_PATH_ENTITIES = '/api/agent/entities'; export const API_PATH_ENTITIES = '/api/io/entities';
export const API_PATH_SHIP_INFO = '/api/sgw/shipinfo'; export const API_PATH_SHIP_INFO = '/api/sgw/shipinfo';
export const API_GET_ALL_LAYER = '/api/sgw/geojsonlist'; export const API_GET_ALL_LAYER = '/api/sgw/geojsonlist';
export const API_GET_LAYER_INFO = '/api/sgw/geojson'; export const API_GET_LAYER_INFO = '/api/sgw/geojson';
export const API_GET_TRIP = '/api/sgw/trip'; export const API_GET_TRIP = '/api/sgw/trip';
export const API_GET_ALARMS = '/api/agent/alarms'; export const API_GET_ALARMS = '/api/io/alarms';
export const API_UPDATE_TRIP_STATUS = '/api/sgw/tripState'; export const API_UPDATE_TRIP_STATUS = '/api/sgw/tripState';
export const API_HAUL_HANDLE = '/api/sgw/fishingLog'; export const API_HAUL_HANDLE = '/api/sgw/fishingLog';
export const API_GET_GPS = '/api/sgw/gps'; export const API_GET_GPS = '/api/sgw/gps';
export const API_GET_FISH = '/api/sgw/fishspecies'; export const API_GET_FISH = '/api/sgw/fishspecies';
export const API_UPDATE_FISHING_LOGS = '/api/sgw/fishingLog'; export const API_UPDATE_FISHING_LOGS = '/api/sgw/fishingLog';
export const API_SOS = '/api/sgw/sos'; export const API_SOS = '/api/sgw/sos';
export const API_PATH_SHIP_TRACK_POINTS = '/api/sgw/trackpoints';
export const API_GET_ALL_BANZONES = '/api/sgw/banzones';

View File

@@ -10,7 +10,7 @@ export default function useGetGpsModel() {
const res = await getGPS(); // đổi URL cho phù hợp const res = await getGPS(); // đổi URL cho phù hợp
console.log('GPS Data fetched:', res); console.log('GPS Data fetched:', res);
setGpsData(res || []); setGpsData(res);
} catch (err) { } catch (err) {
console.error('Fetch gps data failed', err); console.error('Fetch gps data failed', err);
} finally { } finally {

View File

@@ -1,3 +1,4 @@
import Footer from '@/components/Footer/Footer';
import { ROUTE_HOME } from '@/constants'; import { ROUTE_HOME } from '@/constants';
import { login } from '@/services/controller/AuthController'; import { login } from '@/services/controller/AuthController';
import { parseJwt } from '@/utils/jwtTokenUtils'; import { parseJwt } from '@/utils/jwtTokenUtils';
@@ -127,6 +128,17 @@ const LoginPage = () => {
/> />
</> </>
</LoginFormPage> </LoginFormPage>
<div
style={{
backgroundColor: 'transparent',
position: 'absolute',
bottom: 0,
zIndex: 99,
width: '100%',
}}
>
<Footer />
</div>
</div> </div>
); );
}; };

View File

@@ -7,16 +7,21 @@ import {
} from '@/utils/mapUtils'; } from '@/utils/mapUtils';
import { Feature, Map, View } from 'ol'; import { Feature, Map, View } from 'ol';
import { Coordinate } from 'ol/coordinate'; import { Coordinate } from 'ol/coordinate';
import { easeOut } from 'ol/easing';
import { EventsKey } from 'ol/events';
import { createEmpty, extend, Extent, isEmpty } from 'ol/extent'; import { createEmpty, extend, Extent, isEmpty } from 'ol/extent';
import { MultiPolygon, Point, Polygon } from 'ol/geom'; import { LineString, MultiPolygon, Point, Polygon } from 'ol/geom';
import BaseLayer from 'ol/layer/Base'; import BaseLayer from 'ol/layer/Base';
import VectorLayer from 'ol/layer/Vector'; import VectorLayer from 'ol/layer/Vector';
import { unByKey } from 'ol/Observable';
import { fromLonLat } from 'ol/proj'; import { fromLonLat } from 'ol/proj';
import { getVectorContext } from 'ol/render';
import RenderEvent from 'ol/render/Event';
import VectorSource from 'ol/source/Vector'; import VectorSource from 'ol/source/Vector';
import { Circle as CircleStyle, Style } from 'ol/style';
import Fill from 'ol/style/Fill'; import Fill from 'ol/style/Fill';
import Icon from 'ol/style/Icon'; import Icon from 'ol/style/Icon';
import Stroke from 'ol/style/Stroke'; import Stroke from 'ol/style/Stroke';
import Style from 'ol/style/Style';
import Text from 'ol/style/Text'; import Text from 'ol/style/Text';
interface MapManagerConfig { interface MapManagerConfig {
@@ -25,6 +30,12 @@ interface MapManagerConfig {
onFeaturesClick?: (features: any[]) => void; onFeaturesClick?: (features: any[]) => void;
onError?: (error: string[]) => void; onError?: (error: string[]) => void;
} }
interface AnimationConfig {
duration?: number;
maxRadius?: number;
strokeColor?: string;
strokeWidthBase?: number;
}
interface StyleConfig { interface StyleConfig {
icon?: string; // URL của icon icon?: string; // URL của icon
@@ -32,10 +43,18 @@ interface StyleConfig {
textColor?: string; // màu chữ textColor?: string; // màu chữ
textStrokeColor?: string; // màu viền chữ textStrokeColor?: string; // màu viền chữ
textOffsetY?: number; // độ lệch theo trục Y textOffsetY?: number; // độ lệch theo trục Y
strokeColor?: string; // màu đường kẻ cho LineString
strokeWidth?: number; // độ dày đường kẻ cho LineString
fillColor?: string; // màu fill cho Polygon
borderColor?: string; // màu viền cho Polygon
borderWidth?: number; // độ dày viền cho Polygon
minScale?: number; // tỷ lệ nhỏ nhất
maxScale?: number; // tỷ lệ lớn nhất
} }
// Interface for feature data // Interface for feature data
interface FeatureData { interface FeatureData {
id?: string | number;
bearing?: number; bearing?: number;
text?: string; text?: string;
} }
@@ -58,6 +77,7 @@ class MapManager {
private errors: string[]; private errors: string[];
private layers: BaseLayer[]; // Assuming layers is defined elsewhere private layers: BaseLayer[]; // Assuming layers is defined elsewhere
private isInitialized: boolean; // Thêm thuộc tính để theo dõi trạng thái khởi tạo private isInitialized: boolean; // Thêm thuộc tính để theo dõi trạng thái khởi tạo
private eventKey: EventsKey | undefined;
constructor( constructor(
mapRef: React.RefObject<HTMLDivElement>, mapRef: React.RefObject<HTMLDivElement>,
@@ -75,11 +95,12 @@ class MapManager {
this.errors = []; this.errors = [];
this.layers = []; // Initialize layers (adjust based on actual usage) this.layers = []; // Initialize layers (adjust based on actual usage)
this.isInitialized = false; // Khởi tạo là false this.isInitialized = false; // Khởi tạo là false
this.eventKey = undefined; // Khởi tạo eventKey
} }
async getListLayers(): Promise<[]> { async getListLayers(): Promise<[]> {
const resp: [] = await getAllLayer(); const resp: [] = await getAllLayer();
console.log('resp', resp); // console.log('resp', resp);
return resp; return resp;
} }
@@ -91,10 +112,15 @@ class MapManager {
for (const layerMeta of listLayers) { for (const layerMeta of listLayers) {
try { try {
const data = await getLayer(layerMeta); // lấy GeoJSON từ server const data = await getLayer(layerMeta); // lấy GeoJSON từ server
let style = {};
if (layerMeta === 'base-countries') {
style = createLabelAndFillStyle('#fafaf8', '#E5BEB5');
} else {
style = createLabelAndFillStyle('#77BEF0', '#000000');
}
const vectorLayer = createGeoJSONLayer({ const vectorLayer = createGeoJSONLayer({
data, data,
style: createLabelAndFillStyle('#77BEF0', '#000000'), style: style,
}); });
dynamicLayers.push(vectorLayer); dynamicLayers.push(vectorLayer);
@@ -319,10 +345,11 @@ class MapManager {
// Lưu feature // Lưu feature
this.features.push(feature); this.features.push(feature);
this.flyToFeature(feature);
this.featureLayer?.getSource()?.addFeature(feature); this.featureLayer?.getSource()?.addFeature(feature);
if (styleConfig.animate) {
console.log('Point added successfully:', { coord, data, styleConfig }); this.animatedMarker(feature, styleConfig.animationConfig);
}
// console.log('Point added successfully:', { coord, data, styleConfig });
return feature; return feature;
} catch (error: any) { } catch (error: any) {
@@ -333,6 +360,74 @@ class MapManager {
return null; return null;
} }
} }
animatedMarker = (
feature: Feature<Point>,
config: AnimationConfig = {},
): void => {
const {
duration = 3000,
maxRadius = 20,
strokeColor = 'rgba(255, 0, 0, 0.8)',
strokeWidthBase = 0.25,
} = config;
console.log('Starting animatedMarker with config:', config);
const flashGeom = feature.getGeometry()?.clone() as Point | undefined;
if (!flashGeom || !this.featureLayer) {
console.error('Invalid geometry or featureLayer for animation');
return;
}
// Tạo 2 "pha" sóng, mỗi sóng bắt đầu cách nhau duration/2
const waveCount = 1;
const waveOffsets = Array.from(
{ length: waveCount },
(_, i) => (duration / waveCount) * i,
);
const start = Date.now();
const listenerKey: EventsKey = this.featureLayer.on(
'postrender',
(event: RenderEvent) => {
const frameState = event.frameState;
if (!frameState) return;
const elapsedTotal = frameState.time - start;
if (elapsedTotal >= duration * 3) {
// chạy 2 chu kỳ rồi dừng (bạn có thể bỏ điều kiện này để chạy mãi)
unByKey(listenerKey);
return;
}
const vectorContext = getVectorContext(event);
waveOffsets.forEach((offset) => {
const elapsed =
(frameState.time - start - offset + duration) % duration;
const elapsedRatio = elapsed / duration;
const radius = easeOut(elapsedRatio) * (maxRadius - 5) + 5;
const opacity = easeOut(1 - elapsedRatio);
const style = new Style({
image: new CircleStyle({
radius,
stroke: new Stroke({
color: strokeColor.replace('0.8', opacity.toString()),
width: strokeWidthBase + opacity,
}),
}),
});
vectorContext.setStyle(style);
vectorContext.drawGeometry(flashGeom);
});
this.map!.render();
},
);
this.eventKey = listenerKey;
};
addPolygon( addPolygon(
coords: Coordinate[][], coords: Coordinate[][],
@@ -444,15 +539,99 @@ class MapManager {
} }
} }
// Hàm addLineString đã được sửa đổi
addLineString(
coords: Coordinate[],
data: FeatureData = {},
styleConfig: StyleConfig = {},
): Feature | null {
try {
// Kiểm tra coords là mảng hợp lệ và có ít nhất 2 tọa độ
if (
!Array.isArray(coords) ||
coords.length < 2 ||
!coords.every(
(coord) =>
Array.isArray(coord) &&
coord.length === 2 &&
typeof coord[0] === 'number' &&
typeof coord[1] === 'number',
)
) {
throw new Error(
`Invalid coordinates for LineString: ${JSON.stringify(coords)}`,
);
}
// Chuyển đổi tọa độ từ [lon, lat] sang hệ tọa độ của OpenLayers
const transformedCoords = coords.map((coord) => fromLonLat(coord));
const geometry = new LineString(transformedCoords);
const feature = new Feature({
geometry,
...data,
});
// Tạo style cho LineString
const style = new Style({
text: new Text({
text: data.text || '',
font: styleConfig.font || '16px Arial', // Tăng kích thước font
fill: new Fill({ color: styleConfig.textColor || 'black' }), // Đổi màu thành đen để nổi bật
stroke: new Stroke({
color: styleConfig.textStrokeColor || 'white',
width: 2, // Tăng độ rộng viền
}),
placement: 'line',
textAlign: 'center',
textBaseline: 'middle', // Đổi thành 'middle' để căn giữa theo chiều dọc
offsetY: styleConfig.textOffsetY || -10, // Dịch text lên trên một chút
}),
stroke: new Stroke({
color: styleConfig.strokeColor || 'blue',
width: styleConfig.strokeWidth || 5,
}),
});
feature.setStyle(style);
// Thêm feature vào features và featureLayer
if (this.featureLayer && this.isInitialized) {
this.features.push(feature);
this.featureLayer.getSource()?.addFeature(feature);
// console.log(
// 'LineString added successfully at',
// new Date().toLocaleTimeString(),
// ':',
// { coords, data, styleConfig },
// );
// Đảm bảo bản đồ được render lại
this.map?.render();
return feature;
} else {
throw new Error('featureLayer or map is not initialized');
}
} catch (error: any) {
this.errors.push(error.message);
this.onError(this.errors);
console.error(
'Error adding LineString at',
new Date().toLocaleTimeString(),
':',
error.message,
);
return null;
}
}
createPolygonStyle = ( createPolygonStyle = (
data: Record<string, any> = {}, data: Record<string, any> = {},
styleConfig: Record<string, any> = {}, styleConfig: Record<string, any> = {},
zoom: number, zoom: number,
) => { ) => {
console.log( // console.log(
`createPolygonStyle called with zoom: ${zoom}, styleConfig:`, // `createPolygonStyle called with zoom: ${zoom}, styleConfig:`,
styleConfig, // styleConfig,
); // Debug // ); // Debug
const textContent = `Tên: ${data?.name ?? 'Chưa có'}\n\n Loại: ${ const textContent = `Tên: ${data?.name ?? 'Chưa có'}\n\n Loại: ${
data?.type ?? 0 data?.type ?? 0
}\n\nThông tin: ${data?.description ?? 'Chưa có'}`; }\n\nThông tin: ${data?.description ?? 'Chưa có'}`;
@@ -490,7 +669,7 @@ class MapManager {
text: textStyle, text: textStyle,
}); });
console.log(`Polygon style created:`, style); // Debug // console.log(`Polygon style created:`, style); // Debug
return [style]; return [style];
}; };
@@ -635,7 +814,7 @@ class MapManager {
view.fit(extent, { view.fit(extent, {
padding: [300, 300, 300, 300], padding: [300, 300, 300, 300],
maxZoom: features.length === 1 ? 10 : 12, maxZoom: features.length === 1 ? 8 : 10,
duration, duration,
}); });

View File

@@ -1,3 +1,4 @@
import { convertToDMS } from '@/services/service/MapService';
import { ProDescriptions } from '@ant-design/pro-components'; import { ProDescriptions } from '@ant-design/pro-components';
import { GpsData } from '..'; import { GpsData } from '..';
const GpsInfo = ({ gpsData }: { gpsData: GpsData | null }) => { const GpsInfo = ({ gpsData }: { gpsData: GpsData | null }) => {
@@ -18,19 +19,20 @@ const GpsInfo = ({ gpsData }: { gpsData: GpsData | null }) => {
title: 'Kinh độ', title: 'Kinh độ',
dataIndex: 'lat', dataIndex: 'lat',
render: (_, record) => render: (_, record) =>
record?.lat != null ? `${Number(record.lat).toFixed(5)}°` : '--', record?.lat != null ? `${convertToDMS(record.lat, true)}°` : '--',
}, },
{ {
title: 'Vĩ độ', title: 'Vĩ độ',
dataIndex: 'lon', dataIndex: 'lon',
render: (_, record) => render: (_, record) =>
record?.lon != null ? `${Number(record.lon).toFixed(5)}°` : '--', record?.lon != null ? `${convertToDMS(record.lon, false)}°` : '--',
}, },
{ {
title: 'Tốc độ', title: 'Tốc độ',
dataIndex: 's', dataIndex: 's',
valueType: 'digit', valueType: 'digit',
render: (_, record) => `${record.s} km/h`, render: (_, record) =>
record?.s != null ? `${record.s} km/h` : '-- km/h',
span: 1, span: 1,
}, },
{ {

View File

@@ -1,6 +1,22 @@
import { ROUTE_TRIP } from '@/constants'; import {
import { queryAlarms } from '@/services/controller/DeviceController'; MAP_POLYGON_BAN,
MAP_POLYLINE_BAN,
MAP_TRACKPOINTS_ID,
ROUTE_TRIP,
} from '@/constants';
import { ENTITY_TYPE_ENUM } from '@/constants/enums';
import {
queryAlarms,
queryEntities,
queryShipTrackPoints,
} from '@/services/controller/DeviceController';
import { queryBanzones } from '@/services/controller/MapController';
import { getShipIcon } from '@/services/service/MapService'; import { getShipIcon } from '@/services/service/MapService';
import {
convertWKTLineStringToLatLngArray,
convertWKTtoLatLngString,
getBanzoneNameByType,
} from '@/utils/geomUtils';
import { import {
CommentOutlined, CommentOutlined,
InfoCircleOutlined, InfoCircleOutlined,
@@ -14,6 +30,21 @@ 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';
// // Define missing types locally for now
// interface AlarmData {
// level: number;
// }
// interface EntityData {
// id: string;
// valueString: string;
// }
// interface TrackPoint {
// lat: number;
// lon: number;
// }
export interface GpsData { export interface GpsData {
lat: number; lat: number;
lon: number; lon: number;
@@ -61,42 +92,108 @@ const HomePage: React.FC = () => {
useEffect(() => { useEffect(() => {
getGPSData(); getGPSData();
const interval = setInterval(() => {
getGPSData();
getEntitiesData(mapManagerRef.current, gpsData!);
fetchAndAddTrackPoints(mapManagerRef.current);
fetchAndProcessEntities(mapManagerRef.current);
}, 5000);
return () => { return () => {
mapManagerRef.current?.destroy(); clearInterval(interval);
console.log('MapManager destroyed in HomePage cleanup'); console.log('MapManager destroyed in HomePage cleanup');
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
getEntitiesData(); // getGPSData();
}, 500); // delay 1s getEntitiesData(mapManagerRef.current, gpsData!);
fetchAndAddTrackPoints(mapManagerRef.current);
fetchAndProcessEntities(mapManagerRef.current);
}, 1000); // delay 1s
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [gpsData]); }, [gpsData]);
const getEntitiesData = async () => { // Helper function to add features to the map
try { const addFeatureToMap = (
const alarm = await queryAlarms(); mapManager: MapManager,
console.log('GPS Data:', gpsData); 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 (mapManagerRef.current?.featureLayer) { if (mapManager?.featureLayer && gpsData) {
mapManagerRef.current.featureLayer.getSource()?.clear(); mapManager.featureLayer.getSource()?.clear();
try { mapManager.addPoint(
mapManagerRef.current.addPoint( [gpsData.lon, gpsData.lat],
[gpsData!.lon, gpsData!.lat], { bearing: gpsData.h || 0 },
{ {
bearing: gpsData!.h || 0, icon: getShipIcon(alarm.level || 0, gpsData?.fishing || false),
}, scale: 0.1,
{ animate: isSos ? true : false,
icon: getShipIcon(alarm.level || 1, gpsData?.fishing || false), },
scale: 0.1, );
}, }
); };
} catch (parseError) {
console.error( const fetchAndProcessEntities = async (mapManager: MapManager) => {
`Error parsing valueString for entity: ${parseError}`, try {
parseError, const entities: API.TransformedEntity[] = await queryEntities();
); console.log('Fetched entities:', entities.length);
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 || '',
);
if (coordinates.length > 0) {
mapManager.addLineString(
coordinates,
{
id: MAP_POLYLINE_BAN,
text: `${geom.name} - ${zone.message}`,
},
{ strokeColor: 'red', strokeWidth: 4 },
);
}
} else if (geom_type === 1) {
const coordinates = convertWKTtoLatLngString(geom_poly || '');
if (coordinates.length > 0) {
mapManager.addPolygon(
coordinates,
{
id: MAP_POLYGON_BAN,
name: geom.name,
type: getBanzoneNameByType(geom.type || 4),
description: zone.message,
},
{
strokeColor: '#FCC61D',
strokeWidth: 2,
fillColor: '#FCC61D',
},
);
}
}
}
});
} }
} }
} catch (error) { } catch (error) {
@@ -104,6 +201,30 @@ const HomePage: React.FC = () => {
} }
}; };
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);
}
};
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>
<VietNamMap <VietNamMap
@@ -118,7 +239,7 @@ const HomePage: React.FC = () => {
/> />
<Popover <Popover
styles={{ styles={{
root: { width: '85%', maxWidth: 500, paddingLeft: 15 }, root: { width: '85%', maxWidth: 600, paddingLeft: 15 },
}} }}
placement="left" placement="left"
title="Trạng thái hiện tại" title="Trạng thái hiện tại"
@@ -150,7 +271,12 @@ const HomePage: React.FC = () => {
<SosButton <SosButton
onRefresh={(value) => { onRefresh={(value) => {
if (value) { if (value) {
getEntitiesData(); // Ensure null check for gpsData in all usages
if (gpsData) {
getEntitiesData(mapManagerRef.current, gpsData);
} else {
console.warn('GPS data is null, skipping getEntitiesData call');
}
} }
}} }}
/> />

View File

@@ -86,7 +86,7 @@ export const AlarmTable: React.FC<AlarmTableProps> = ({
return ( return (
<> <>
<ProList<API.Alarm> <ProList<API.Alarm>
// bordered bordered
actionRef={actionRef} actionRef={actionRef}
metas={columns} metas={columns}
polling={DURATION_POLLING_PRESENTATIONS} polling={DURATION_POLLING_PRESENTATIONS}

View File

@@ -6,7 +6,7 @@ import {
} from '@/services/controller/TripController'; } from '@/services/controller/TripController';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { useModel } from '@umijs/max'; import { useModel } from '@umijs/max';
import { Button, message, theme } from 'antd'; import { Button, Grid, message, theme } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import CreateOrUpdateFishingLog from './CreateOrUpdateFishingLog'; import CreateOrUpdateFishingLog from './CreateOrUpdateFishingLog';
@@ -25,6 +25,8 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
const checkHaulFinished = () => { const checkHaulFinished = () => {
return trips?.fishing_logs?.some((h) => h.status === 0); return trips?.fishing_logs?.some((h) => h.status === 0);
}; };
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
const createNewHaul = async () => { const createNewHaul = async () => {
if (trips?.fishing_logs?.some((f) => f.status === 0)) { if (trips?.fishing_logs?.some((f) => f.status === 0)) {
@@ -76,7 +78,10 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
} }
return ( return (
<> <div style={{
padding: screens.sm ? '0px': '10px',
marginRight: screens.sm ? '24px': '0px',
}}>
{trips?.trip_status === 2 ? ( {trips?.trip_status === 2 ? (
<Button <Button
color="green" color="green"
@@ -126,7 +131,7 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
} }
}} }}
/> />
</> </div>
); );
}; };

View File

@@ -1,7 +1,6 @@
import { ProCard } from '@ant-design/pro-components'; import { ProCard } from '@ant-design/pro-components';
import { useModel } from '@umijs/max'; import { useModel } from '@umijs/max';
import { Flex, Grid } from 'antd'; import { Flex, Grid } from 'antd';
import { useState } from 'react';
import HaulTable from './HaulTable'; import HaulTable from './HaulTable';
import TripCostTable from './TripCost'; import TripCostTable from './TripCost';
import TripCrews from './TripCrews'; import TripCrews from './TripCrews';
@@ -23,7 +22,6 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
// console.log('tripInfo:', tripInfo); // console.log('tripInfo:', tripInfo);
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
const screens = useBreakpoint(); const screens = useBreakpoint();
const [isResponsive, setIsResponsive] = useState(false);
const { data, getApi } = useModel('getTrip'); const { data, getApi } = useModel('getTrip');
const tripCosts = Array.isArray(tripInfo?.trip_cost) const tripCosts = Array.isArray(tripInfo?.trip_cost)
? tripInfo.trip_cost ? tripInfo.trip_cost
@@ -77,11 +75,14 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
padding: 0, padding: 0,
paddingInline: 0, paddingInline: 0,
gap: 10, gap: 10,
backgroundColor: 'transparent',
}} }}
> >
<ProCard <ProCard
split={screens.lg ? 'vertical' : 'horizontal'} gutter={16} // tạo khoảng cách thay vì split (không có border)
bodyStyle={{ padding: 0, gap: 5 }} direction={screens.lg ? 'row' : 'column'} // responsive: ngang/dọc
bodyStyle={{ padding: 0, gap: 10 }}
style={{ color: 'transparent' }}
> >
<ProCard <ProCard
colSpan={{ xs: 24, sm: 24, lg: 12, xl: 12 }} colSpan={{ xs: 24, sm: 24, lg: 12, xl: 12 }}
@@ -92,7 +93,7 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderBottom: '1px solid #f0f0f0', // borderBottom: '1px solid #f0f0f0',
}} }}
title="Chi phí chuyến đi" title="Chi phí chuyến đi"
style={{ minHeight: 300 }} style={{ minHeight: 300 }}

View File

@@ -33,7 +33,7 @@ const TripFishingGearTable: React.FC<TripFishingGearTableProps> = ({
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`, showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
}} }}
options={false} options={false}
// bordered // bordered
size="middle" size="middle"
scroll={{ x: '100%' }} scroll={{ x: '100%' }}
style={{ flex: 1 }} style={{ flex: 1 }}

View File

@@ -12,7 +12,6 @@ import MainTripBody from './components/MainTripBody';
import TripCancleOrFinishedButton from './components/TripCancelOrFinishButton'; import TripCancleOrFinishedButton from './components/TripCancelOrFinishButton';
const DetailTrip = () => { const DetailTrip = () => {
const intl = useIntl(); const intl = useIntl();
const [responsive, setResponsive] = useState(false);
const [showAlarmList, setShowAlarmList] = useState(true); const [showAlarmList, setShowAlarmList] = useState(true);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [alarmList, setAlarmList] = useState<API.Alarm[]>([]); const [alarmList, setAlarmList] = useState<API.Alarm[]>([]);
@@ -49,10 +48,16 @@ const DetailTrip = () => {
( (
<PageContainer <PageContainer
header={{ header={{
title: data ? data.name : 'Chuyến đi', title: (
<div style={{ marginLeft: screens.md ? '24px' : '10px' }}>
{data ? data.name : 'Chuyến đi'}
</div>
),
tags: <BadgeTripStatus status={data?.trip_status || 0} />, tags: <BadgeTripStatus status={data?.trip_status || 0} />,
}} }}
content={null}
loading={isLoading} loading={isLoading}
ghost
extra={[ extra={[
<CreateNewHaulOrTrip <CreateNewHaulOrTrip
trips={data || undefined} trips={data || undefined}
@@ -99,8 +104,9 @@ const DetailTrip = () => {
defaultMessage: 'Cảnh báo', defaultMessage: 'Cảnh báo',
})} })}
colSpan={{ xs: 24, md: 24, lg: 5 }} colSpan={{ xs: 24, md: 24, lg: 5 }}
bodyStyle={{ paddingInline: 0, paddingBlock: 8 }} bodyStyle={{ paddingInline: 0, paddingBlock: 0 }}
bordered bordered
// style={{ borderBlockEnd: 'none'}}
> >
{data ? ( {data ? (
<AlarmTable alarmList={alarmList} isLoading={isLoading} /> <AlarmTable alarmList={alarmList} isLoading={isLoading} />

View File

@@ -3,11 +3,12 @@ import {
API_GET_GPS, API_GET_GPS,
API_PATH_ENTITIES, API_PATH_ENTITIES,
API_PATH_SHIP_INFO, API_PATH_SHIP_INFO,
API_PATH_SHIP_TRACK_POINTS,
API_SOS, API_SOS,
} from '@/constants'; } from '@/constants';
import { request } from '@umijs/max'; import { request } from '@umijs/max';
function transformEntityResponse( export function transformEntityResponse(
raw: API.EntityResponse, raw: API.EntityResponse,
): API.TransformedEntity { ): API.TransformedEntity {
return { return {
@@ -19,7 +20,7 @@ function transformEntityResponse(
}; };
} }
export async function getEntities(): Promise<API.TransformedEntity[]> { export async function queryEntities(): Promise<API.TransformedEntity[]> {
const rawList = await request<API.EntityResponse[]>(API_PATH_ENTITIES); const rawList = await request<API.EntityResponse[]>(API_PATH_ENTITIES);
return rawList.map(transformEntityResponse); return rawList.map(transformEntityResponse);
} }
@@ -52,3 +53,7 @@ export async function sendSosMessage(message: string) {
}, },
}); });
} }
export async function queryShipTrackPoints() {
return await request<API.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
}

View File

@@ -1,4 +1,4 @@
import { API_GET_ALL_LAYER, API_GET_LAYER_INFO } from '@/constants'; import { API_GET_ALL_BANZONES, API_GET_ALL_LAYER, API_GET_LAYER_INFO } from '@/constants';
import { request } from '@umijs/max'; import { request } from '@umijs/max';
export async function getLayer(name: string) { export async function getLayer(name: string) {
@@ -8,3 +8,7 @@ export async function getLayer(name: string) {
export async function getAllLayer() { export async function getAllLayer() {
return request(API_GET_ALL_LAYER); return request(API_GET_ALL_LAYER);
} }
export async function queryBanzones() {
return request<API.Zone[]>(API_GET_ALL_BANZONES);
}

View File

@@ -124,6 +124,14 @@ declare namespace API {
fishing: boolean; fishing: boolean;
} }
interface ShipTrackPoint {
time: number;
lon: number;
lat: number;
s: number;
h: number;
}
// Trips // Trips
interface FishingGear { interface FishingGear {
name: string; name: string;
@@ -328,4 +336,36 @@ declare namespace API {
evtid?: string; evtid?: string;
content?: string; content?: string;
} }
// Banzone
export interface Zone {
id?: string;
name?: string;
type?: number;
conditions?: Condition[];
enabled?: boolean;
updated_at?: Date;
geom?: Geom;
}
export interface Condition {
max?: number;
min?: number;
type?: Type;
to?: number;
from?: number;
}
export enum Type {
LengthLimit = 'length_limit',
MonthRange = 'month_range',
}
export interface Geom {
geom_type?: number;
geom_poly?: string;
geom_lines?: string;
geom_point?: string;
geom_radius?: number;
}
} }

View File

@@ -15,8 +15,8 @@ export const BASEMAP_ATTRIBUTIONS = '© OpenStreetMap contributors, © CartoDB';
export const INITIAL_VIEW_CONFIG = { export const INITIAL_VIEW_CONFIG = {
// Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát // Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát
center: [109.5, 16.0], center: [116.152685, 15.70581],
zoom: 5.5, zoom: 6.5,
minZoom: 5, minZoom: 5,
maxZoom: 12, maxZoom: 12,
minScale: 0.1, minScale: 0.1,
@@ -62,3 +62,14 @@ export const getShipIcon = (type: number, isFishing: boolean) => {
return shipUndefineIcon; return shipUndefineIcon;
} }
}; };
export const convertToDMS = (value: number, isLat: boolean): string => {
const deg = Math.floor(Math.abs(value));
const minFloat = (Math.abs(value) - deg) * 60;
const min = Math.floor(minFloat);
const sec = (minFloat - min) * 60;
const direction = value >= 0 ? (isLat ? 'N' : 'E') : isLat ? 'S' : 'W';
return `${deg}°${min}'${sec.toFixed(2)}"${direction}`;
};

76
src/utils/geomUtils.ts Normal file
View File

@@ -0,0 +1,76 @@
export const convertWKTPointToLatLng = (wktString: string) => {
if (
!wktString ||
typeof wktString !== 'string' ||
!wktString.startsWith('POINT')
) {
return null;
}
const matched = wktString.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
if (!matched) return null;
const lng = parseFloat(matched[1]);
const lat = parseFloat(matched[2]);
return [lng, lat]; // [longitude, latitude]
};
export const convertWKTLineStringToLatLngArray = (wktString: string) => {
if (
!wktString ||
typeof wktString !== 'string' ||
!wktString.startsWith('LINESTRING')
) {
return [];
}
const matched = wktString.match(/LINESTRING\s*\((.*)\)/);
if (!matched) return [];
const coordinates = matched[1].split(',').map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y]; // [lng, lat]
});
return coordinates;
};
export const convertWKTtoLatLngString = (wktString: string) => {
if (
!wktString ||
typeof wktString !== 'string' ||
!wktString.startsWith('MULTIPOLYGON')
) {
return [];
}
const matched = wktString.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
if (!matched) return [];
const polygons = matched[1]
.split(')),((') // chia các polygon
.map((polygonStr) =>
polygonStr
.trim()
.split(',')
.map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y];
}),
);
return polygons;
};
export const getBanzoneNameByType = (type: number) => {
switch (type) {
case 1:
return 'Cấm đánh bắt';
case 2:
return 'Cấm di chuyển';
case 3:
return 'Vùng an toàn';
default:
return 'Chưa có';
}
};