Files
SMATEC-FRONTEND/src/pages/Slave/SGW/Map/index.tsx

646 lines
20 KiB
TypeScript

import { getBadgeStatus } from '@/components/shared/ThingShared';
import { DURATION_POLLING_PRESENTATIONS } from '@/constants';
import useGetShipSos from '@/models/slave/sgw/useShipSos';
import { apiSearchThings } from '@/services/master/ThingController';
import { formatUnixTime } from '@/utils/slave/sgw/timeUtils';
import { DownOutlined, FilterOutlined, UpOutlined } from '@ant-design/icons';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { ParamsType, useToken } from '@ant-design/pro-components';
import ProTable from '@ant-design/pro-table';
import { useIntl } from '@umijs/max';
import {
Button,
Drawer,
Flex,
message,
notification,
Tag,
Tooltip,
Typography,
} from 'antd';
import { Feature } from 'ol';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { useCallback, useEffect, useRef, useState } from 'react';
import TagState from '../../../../components/shared/TagState';
import { BaseMap } from './components/BaseMap';
import MultipleShips from './components/MultipleShips';
import ShipDetail, { ShipDetailMessageAction } from './components/ShipDetail';
import ShipSearchForm, {
SearchShipResponse,
} from './components/ShipSearchForm';
import { renderSosOverlay } from './components/SosOverlay';
import VietNamMap, { VietNamMapRef } from './components/VietNamMap';
import { getShipNameColor } from './config/MapConfig';
import styles from './index.less';
import {
DATA_LAYER,
GPSParseResult,
TagStateCallbackPayload,
TEMPORARY_LAYER,
} from './type';
const MapPage = () => {
// Create a ref for VietNamMap to access BaseMap methods
const vietNamMapRef = useRef<VietNamMapRef>(null);
const baseMap = useRef<BaseMap | null>(null);
// Lưu các cancel functions của sosPulse để cleanup
const sosPulseCancelRefs = useRef<(() => void)[]>([]);
// Lưu các cleanup functions của overlay để cleanup
const overlayCleanupRefs = useRef<Map<string, () => void>>(new Map());
const [stateQuery, setStateQuery] = useState<
TagStateCallbackPayload | undefined
>(undefined);
const token = useToken();
const intl = useIntl();
const [things, setThings] = useState<SgwModel.SgwThingsResponse | null>(null);
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
const tableRef = useRef<ActionType>();
const [notificationApi, contextHolder] = notification.useNotification();
const [messageApi, messageContextHolder] = message.useMessage();
const [showMultiShipsDrawer, setShowMultiShipsDrawer] =
useState<boolean>(false);
const [multipleThingsSelected, setMultipleThingsSelected] = useState<
SgwModel.SgwThing[]
>([]);
const [formSearchData, setFormSearchData] =
useState<SearchShipResponse | null>(null);
const [openFormSearch, setOpenFormSearch] = useState(false);
const { shipSos, getShipSosWs } = useGetShipSos();
const handleClickOneThing = (thing: SgwModel.SgwThing) => {
if (baseMap.current === null) {
console.log('BaseMap is not ready yet');
return;
}
notificationApi.open({
message: (
<Flex justify="center">
<Typography.Title level={4}>{`${intl.formatMessage({
id: 'map.ship_detail.name',
defaultMessage: 'Device Information',
})} ${thing.name}`}</Typography.Title>
</Flex>
),
key: `ship-detail-${thing.id}`,
description: baseMap && (
<ShipDetail
thing={thing}
messageApi={messageApi}
mapController={baseMap.current}
/>
),
placement: 'bottomRight',
actions: <ShipDetailMessageAction />,
duration: 100,
onClose() {
baseMap.current?.clearFeatures(TEMPORARY_LAYER);
baseMap.current?.toggleLayer(DATA_LAYER, true);
baseMap.current?.zoomToFeaturesInLayer(DATA_LAYER);
},
style: {
width:
window.innerWidth <= 576
? '80vw'
: window.innerWidth <= 768
? '60vw'
: '800px',
maxWidth: '800px',
height: 'auto',
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
});
};
const onFeaturesClick = useCallback((features: Feature[]) => {
console.log('Multiple features clicked:', features);
const things: SgwModel.SgwThing[] = features.map((feature) =>
feature.get('thing'),
) as SgwModel.SgwThing[];
setMultipleThingsSelected(things);
setShowMultiShipsDrawer(true);
}, []);
const onFeatureClick = useCallback(
(feature: Feature) => {
const thing: SgwModel.SgwThing = feature.get('thing');
baseMap.current?.zoomToFeatures([feature]);
handleClickOneThing(thing);
},
[baseMap],
);
const onError = useCallback(
(error: any) => {
console.error(
intl.formatMessage({ id: 'home.mapError' }),
'at',
new Date().toLocaleTimeString(),
':',
error,
);
},
[intl],
);
// Handler called when map is ready
const handleMapReady = useCallback((baseMapInstance: BaseMap) => {
// console.log('Map is ready!', baseMapInstance);
baseMap.current = baseMapInstance;
// Create a vector layer for dynamic features
const vectorDataLayer = new VectorLayer({
source: new VectorSource(),
});
vectorDataLayer.set('id', DATA_LAYER);
const vectorTemporaryLayer = new VectorLayer({
source: new VectorSource(),
});
vectorTemporaryLayer.set('id', TEMPORARY_LAYER);
baseMapInstance.addLayer(vectorDataLayer);
baseMapInstance.addLayer(vectorTemporaryLayer);
getShipSosWs();
}, []);
useEffect(() => {
if (shipSos !== null && things && things.things) {
const check = things?.things?.find(
(thing) => thing.id === shipSos.thing_id,
);
if (check) {
tableRef.current?.reload();
}
}
}, [shipSos]);
useEffect(() => {
if (formSearchData && tableRef.current) {
tableRef.current.reload(); // gọi lại request
}
}, [formSearchData]);
const queryThings = async (params: ParamsType) => {
try {
const { current = 1, pageSize = 10, keyword = '' } = params;
const offset = current === 1 ? 0 : (current - 1) * pageSize;
const query: MasterModel.SearchThingPaginationBody = {
offset: offset,
limit: 200,
order: 'name',
dir: 'asc',
};
const stateNormalQuery = stateQuery?.isNormal ? 'normal' : '';
const stateSosQuery = stateQuery?.isSos ? 'sos' : '';
const stateWarningQuery = stateQuery?.isWarning
? stateNormalQuery + ',warning'
: stateNormalQuery;
const stateCriticalQuery = stateQuery?.isCritical
? stateWarningQuery + ',critical'
: stateWarningQuery;
const stateQueryParams =
stateQuery?.isNormal &&
stateQuery?.isWarning &&
stateQuery?.isCritical &&
stateQuery?.isSos
? ''
: [stateCriticalQuery, stateSosQuery].filter(Boolean).join(',');
let metaFormQuery: Record<string, any> = {};
if (stateQuery?.isDisconnected)
metaFormQuery = { ...metaFormQuery, connected: false };
const metaStateQuery =
stateQueryParams !== '' ? { state_level: stateQueryParams } : null;
if (formSearchData) {
const {
ship_name,
reg_number,
ship_length,
ship_power,
ship_type,
alarm_list,
group_id,
ship_group_id,
} = formSearchData;
if (ship_name) metaFormQuery.ship_name = ship_name;
if (reg_number) metaFormQuery.ship_reg_number = reg_number;
if (Array.isArray(ship_length) && ship_length.length === 2) {
metaFormQuery.ship_length = ship_length;
}
if (Array.isArray(ship_power) && ship_power.length === 2) {
metaFormQuery.ship_power = ship_power;
}
if (Array.isArray(ship_type) && ship_type.length > 0) {
metaFormQuery.ship_type = ship_type;
}
if (Array.isArray(alarm_list) && alarm_list.length > 0) {
metaFormQuery.alarm_list = alarm_list;
}
if (group_id) metaFormQuery.group_id = group_id;
if (Array.isArray(ship_group_id) && ship_group_id.length > 0) {
metaFormQuery.ship_group_id = ship_group_id;
}
}
const metaQuery = {
...query,
metadata: {
ship_name: keyword,
...metaFormQuery,
...metaStateQuery,
not_empty: 'ship_id',
},
};
setThings(null);
const resp = await apiSearchThings(metaQuery, 'sgw');
return resp;
} catch (error) {
console.error('Error when searchThings: ', error);
return {};
}
};
const handleZoomToFeature = (layerName: string) => {
if (!baseMap) return;
// Get all features and zoom to them
const vectorLayer = baseMap.current?.getLayerById(layerName);
if (vectorLayer && vectorLayer instanceof VectorLayer) {
const features = vectorLayer.getSource()?.getFeatures();
if (features && features.length > 0) {
baseMap.current?.zoomToFeatures(features);
}
}
};
useEffect(() => {
if (!things || !baseMap.current || !things.things) return;
// Cleanup các animation và overlay cũ trước khi render lại
sosPulseCancelRefs.current.forEach((cancel) => cancel());
sosPulseCancelRefs.current = [];
overlayCleanupRefs.current.forEach((cleanup) => cleanup());
overlayCleanupRefs.current.clear();
baseMap.current.clearOverlays();
baseMap.current.clearFeatures(DATA_LAYER);
for (const thing of things.things) {
const gpsData: GPSParseResult = JSON.parse(thing.metadata?.gps || '{}');
if (gpsData.lat && gpsData.lon) {
if (thing.metadata?.state_level === 3) {
console.log('Cos tau sos');
const feature = baseMap.current?.addPoint(
DATA_LAYER,
[gpsData.lon, gpsData.lat],
{
thing,
type: 'sos-point',
},
);
// Bắt đầu hiệu ứng gợn sóng
const cancel = baseMap.current?.sosPulse(feature, DATA_LAYER);
if (cancel) {
sosPulseCancelRefs.current.push(cancel);
}
// Tạo overlay hiển thị thông tin SOS
const overlayElement = document.createElement('div');
const cleanup = renderSosOverlay(overlayElement, {
shipName: thing.metadata?.ship_name || thing.name,
reason: thing.metadata.sos || 'Không rõ lý do',
time: thing.metadata?.sos_time
? formatUnixTime(thing.metadata.sos_time)
: undefined,
});
overlayCleanupRefs.current.set(thing.id!, cleanup);
baseMap.current.addOrUpdateOverlay(
`sos-${thing.id}`,
[gpsData.lon, gpsData.lat],
overlayElement,
);
} else {
baseMap.current?.addPoint(DATA_LAYER, [gpsData.lon, gpsData.lat], {
thing,
type: 'main-point',
});
}
}
}
handleZoomToFeature(DATA_LAYER);
// Cleanup khi component unmount
return () => {
sosPulseCancelRefs.current.forEach((cancel) => cancel());
sosPulseCancelRefs.current = [];
overlayCleanupRefs.current.forEach((cleanup) => cleanup());
overlayCleanupRefs.current.clear();
};
}, [things]);
const columns: ProColumns<SgwModel.SgwThing>[] = [
{
title: intl.formatMessage({
id: 'thing.name',
defaultMessage: 'Name',
}),
dataIndex: ['metadata', 'ship_name'],
key: 'ship_name',
hideInSearch: true,
width: '30%',
ellipsis: true,
render: (_, row) => {
const text = row?.metadata?.ship_name || '';
return (
<Tooltip title={text}>
<span
style={{
color: getShipNameColor(row?.metadata?.state_level || 0),
}}
>
{text}
</span>
</Tooltip>
);
},
},
{
title: intl.formatMessage({
id: 'thing.status',
defaultMessage: 'Status',
}),
dataIndex: ['metadata', 'state_level'],
key: 'status',
hideInSearch: true,
width: '70%',
filters: true,
onFilter: true,
valueType: 'select',
valueEnum: {
0: {
text: intl.formatMessage({
id: 'thing.status.normal',
defaultMessage: 'Normal',
}),
status: 'Normal',
},
1: {
text: intl.formatMessage({
id: 'thing.status.warning',
defaultMessage: 'Warning',
}),
status: 'Warning',
},
2: {
text: intl.formatMessage({
id: 'thing.status.critical',
defaultMessage: 'Critical',
}),
status: 'Critical',
},
3: {
text: intl.formatMessage({
id: 'thing.status.sos',
defaultMessage: 'SOS',
}),
status: 'SOS',
},
},
ellipsis: true,
render: (_, row) => {
const alarm = JSON.parse(row?.metadata?.alarm_list || '{}');
const text = alarm?.map((a: any) => a?.name).join(', ');
return (
<Tooltip placement="top" title={text}>
{getBadgeStatus(row?.metadata?.state_level || 0)}
<span className="ml-2 text-gray-500">{text}</span>
</Tooltip>
);
},
},
];
const handleTagStateChange = (payload: TagStateCallbackPayload) => {
setStateQuery(payload);
tableRef.current?.reload();
};
return (
<div className="h-screen w-screen relative">
{contextHolder}
{messageContextHolder}
<div
style={{
position: 'absolute',
top: 5,
left: 2,
zIndex: 10,
borderRadius: 10,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
backgroundColor: token.token.colorBgContainer,
width: isCollapsed ? '28%' : '30%',
minWidth: '25%',
maxWidth: '30%',
overflowX: 'auto',
whiteSpace: 'nowrap',
transition: 'width 0.5s ease-in-out',
}}
>
<div>
<Flex
justify="space-between"
gap={30}
align="center"
style={{
width: '100%',
padding: '8px 12px',
}}
>
<Flex gap={8} align="center">
<Button
type="text"
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => setIsCollapsed(!isCollapsed)}
style={{ padding: '4px 8px' }}
/>
{formSearchData === null ? (
<Button
icon={<FilterOutlined />}
onClick={(e) => {
e.stopPropagation();
messageApi.destroy();
setOpenFormSearch(true);
}}
/>
) : (
<Tag
style={{
cursor: 'pointer',
fontSize: '12px',
padding: '4px 8px',
lineHeight: '20px',
minWidth: '0',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
onClick={(e) => {
e.stopPropagation();
messageApi.destroy();
setOpenFormSearch(true);
}}
closeIcon
onClose={(e) => {
e.stopPropagation();
setFormSearchData(null);
if (tableRef.current) {
tableRef.current.reload();
}
}}
color="#87d068"
>
Tìm theo bộ lọc
</Tag>
)}
</Flex>
{isCollapsed && (
<div onClick={(e) => e.stopPropagation()}>
<TagState
normalCount={things?.metadata?.total_state_level_0 || 0}
warningCount={things?.metadata?.total_state_level_1 || 0}
criticalCount={things?.metadata?.total_state_level_2 || 0}
sosCount={things?.metadata?.total_sos || 0}
disconnectedCount={
(things?.metadata?.total_thing ?? 0) -
(things?.metadata?.total_connected ?? 0) || 0
}
onTagPress={handleTagStateChange}
/>
</div>
)}
</Flex>
<ProTable<SgwModel.SgwThing>
tableClassName="table-row-select cursor-pointer-row"
actionRef={tableRef}
toolbar={{
title: null,
actions: [
<TagState
key="tag-state"
normalCount={things?.metadata?.total_state_level_0 || 0}
warningCount={things?.metadata?.total_state_level_1 || 0}
criticalCount={things?.metadata?.total_state_level_2 || 0}
sosCount={things?.metadata?.total_sos || 0}
disconnectedCount={
(things?.metadata?.total_thing ?? 0) -
(things?.metadata?.total_connected ?? 0) || 0
}
onTagPress={handleTagStateChange}
/>,
],
}}
bordered={true}
polling={DURATION_POLLING_PRESENTATIONS}
style={{
display: isCollapsed ? 'none' : 'block',
}}
pagination={{
size: 'small',
defaultPageSize: 10,
showSizeChanger: true,
pageSizeOptions: ['10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]} trên ${total} ${intl.formatMessage({
id: 'thing.ships',
defaultMessage: 'ships',
})}`,
}}
columns={columns}
request={async (params: ParamsType) => {
const data = await queryThings(params);
const sortedThings = [...(data.things || [])].sort((a, b) => {
const stateLevelA = a.metadata?.state_level || 0;
const stateLevelB = b.metadata?.state_level || 0;
return stateLevelB - stateLevelA;
});
setThings(data);
return Promise.resolve({
success: true,
data: sortedThings,
total: data?.metadata?.total_filter || 0,
});
}}
onRow={(row) => {
return {
onClick: () => {
handleClickOneThing(row);
},
};
}}
rowClassName={(record) =>
record?.metadata?.state_level
? 'cursor-pointer'
: `${styles.disconnected} cursor-pointer`
}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
defaultSize="small"
search={false}
rowKey="id"
dateFormatter="string"
/>
</div>
</div>
{/* VietNamMap with ref */}
<VietNamMap
ref={vietNamMapRef}
style={{
height: '100vh',
width: '100vw',
}}
onMapReady={handleMapReady}
onFeatureClick={onFeatureClick}
onFeaturesClick={onFeaturesClick}
onError={onError}
/>
<Drawer
styles={{
header: {
padding: 5,
},
}}
open={showMultiShipsDrawer}
onClose={() => {
setShowMultiShipsDrawer(false);
setMultipleThingsSelected([]);
}}
title={
<Flex align="center" justify="center">
<Typography.Title level={4}>
Thông tin {multipleThingsSelected.length} tàu
</Typography.Title>
</Flex>
}
size="large"
>
<MultipleShips
things={multipleThingsSelected}
messageApi={messageApi}
mapController={baseMap.current}
/>
</Drawer>
<ShipSearchForm
initialValues={formSearchData || undefined}
isModalOpen={openFormSearch}
onClose={setOpenFormSearch}
things={things?.things || []}
onSubmit={(value: SearchShipResponse) => {
setFormSearchData(value);
}}
/>
</div>
);
};
export default MapPage;