646 lines
20 KiB
TypeScript
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;
|