6 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
11 changed files with 271 additions and 135 deletions

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,
}, },
}, },

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

@@ -14,12 +14,12 @@ 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';

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 { LineString, 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
@@ -66,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>,
@@ -83,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;
} }
@@ -99,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);
@@ -328,8 +346,10 @@ class MapManager {
// Lưu feature // Lưu feature
this.features.push(feature); this.features.push(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) {
@@ -340,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[][],
@@ -510,12 +598,12 @@ class MapManager {
if (this.featureLayer && this.isInitialized) { if (this.featureLayer && this.isInitialized) {
this.features.push(feature); this.features.push(feature);
this.featureLayer.getSource()?.addFeature(feature); this.featureLayer.getSource()?.addFeature(feature);
console.log( // console.log(
'LineString added successfully at', // 'LineString added successfully at',
new Date().toLocaleTimeString(), // new Date().toLocaleTimeString(),
':', // ':',
{ coords, data, styleConfig }, // { coords, data, styleConfig },
); // );
// Đảm bảo bản đồ được render lại // Đảm bảo bản đồ được render lại
this.map?.render(); this.map?.render();
return feature; return feature;
@@ -540,10 +628,10 @@ class MapManager {
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ó'}`;
@@ -581,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];
}; };

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

@@ -4,6 +4,7 @@ import {
MAP_TRACKPOINTS_ID, MAP_TRACKPOINTS_ID,
ROUTE_TRIP, ROUTE_TRIP,
} from '@/constants'; } from '@/constants';
import { ENTITY_TYPE_ENUM } from '@/constants/enums';
import { import {
queryAlarms, queryAlarms,
queryEntities, queryEntities,
@@ -29,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;
@@ -78,9 +94,9 @@ const HomePage: React.FC = () => {
getGPSData(); getGPSData();
const interval = setInterval(() => { const interval = setInterval(() => {
getGPSData(); getGPSData();
getEntitiesData(); getEntitiesData(mapManagerRef.current, gpsData!);
getShipTrackPoints(); fetchAndAddTrackPoints(mapManagerRef.current);
getEntityData(); fetchAndProcessEntities(mapManagerRef.current);
}, 5000); }, 5000);
return () => { return () => {
clearInterval(interval); clearInterval(interval);
@@ -91,107 +107,76 @@ const HomePage: React.FC = () => {
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
// getGPSData(); // getGPSData();
getEntitiesData(); getEntitiesData(mapManagerRef.current, gpsData!);
getShipTrackPoints(); fetchAndAddTrackPoints(mapManagerRef.current);
getEntityData(); fetchAndProcessEntities(mapManagerRef.current);
}, 1000); // delay 1s }, 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('Fetched alarm data:', alarm); gpsData: GpsData,
// console.log('GPS Data:', 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 && gpsData) { if (mapManager?.featureLayer && gpsData) {
// Clear trước mapManager.featureLayer.getSource()?.clear();
mapManagerRef.current.featureLayer.getSource()?.clear(); mapManager.addPoint(
try {
// Tạo feature mới
const feature = 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), icon: getShipIcon(alarm.level || 0, gpsData?.fishing || false),
scale: 0.1, scale: 0.1,
animate: isSos ? true : false,
}, },
); );
} catch (parseError) {
console.error(
`Error parsing valueString for entity: ${parseError}`,
parseError,
);
}
}
} catch (error) {
console.error('Error fetching entities:', error);
}
};
const getShipTrackPoints = async () => {
try {
const trackpoints = await queryShipTrackPoints();
// console.log('Fetched ship track points:', trackpoints.length);
if (trackpoints.length > 0) {
mapManagerRef.current?.addLineString(
trackpoints.map((point) => [point.lon, point.lat]),
{
id: MAP_TRACKPOINTS_ID,
// You can add more properties here if needed
},
{
strokeColor: 'blue',
strokeWidth: 3,
},
);
}
} catch (error) {
console.error('Error fetching ship track points:', error);
} }
}; };
const getEntityData = async () => { const fetchAndProcessEntities = async (mapManager: MapManager) => {
try { try {
const entities = await queryEntities(); const entities: API.TransformedEntity[] = await queryEntities();
console.log('Fetched entities:', entities.length); console.log('Fetched entities:', entities.length);
if (entities.length > 0) {
entities.forEach(async (entity) => { for (const entity of entities) {
if (entity.id == '50:2') { if (entity.id === '50:2' && entity.valueString !== '[]') {
const banzones = await queryBanzones(); const banzones = await queryBanzones();
console.log('Fetched banzones:', banzones.length); const zones: any[] = JSON.parse(entity.valueString);
if (entity.valueString != '[]') {
const zones = JSON.parse(entity.valueString); zones.forEach((zone: any) => {
console.log('Parsed zones:', zones);
for (const zone of zones) {
const geom = banzones.find((b) => b.id === zone.zone_id); const geom = banzones.find((b) => b.id === zone.zone_id);
if (geom) { if (geom) {
if (geom.geom?.geom_type === 2) { const { geom_type, geom_lines, geom_poly } = geom.geom || {};
// Polyline if (geom_type === 2) {
const coordinates = convertWKTLineStringToLatLngArray( const coordinates = convertWKTLineStringToLatLngArray(
geom?.geom?.geom_lines || '', geom_lines || '',
); );
if (coordinates.length > 0) { if (coordinates.length > 0) {
mapManagerRef.current.addLineString( mapManager.addLineString(
coordinates, coordinates,
{ {
id: MAP_POLYLINE_BAN, id: MAP_POLYLINE_BAN,
text: `${geom.name} - ${zone.message}`, text: `${geom.name} - ${zone.message}`,
}, },
{ { strokeColor: 'red', strokeWidth: 4 },
strokeColor: 'red',
strokeWidth: 4,
},
); );
} }
} else if (geom.geom?.geom_type === 1) { } else if (geom_type === 1) {
const coordinates = convertWKTtoLatLngString( const coordinates = convertWKTtoLatLngString(geom_poly || '');
geom?.geom?.geom_poly || '',
);
if (coordinates.length > 0) { if (coordinates.length > 0) {
mapManagerRef.current.addPolygon( mapManager.addPolygon(
coordinates, coordinates,
{ {
id: MAP_POLYGON_BAN, id: MAP_POLYGON_BAN,
@@ -200,23 +185,41 @@ const HomePage: React.FC = () => {
description: zone.message, description: zone.message,
}, },
{ {
strokeColor: 'yellow', strokeColor: '#FCC61D',
strokeWidth: 2, strokeWidth: 2,
fillColor: 'rgba(255, 255, 0, 0.3)', // Màu vàng với độ trong suốt fillColor: '#FCC61D',
}, },
); );
} }
} }
} else {
console.log('No geometry found for zone:', zone.id);
}
}
} else {
console.log('Zone rỗng');
}
} }
}); });
} }
}
} catch (error) {
console.error('Error fetching entities:', error);
}
};
const fetchAndAddTrackPoints = async (mapManager: MapManager) => {
try {
const trackpoints: API.ShipTrackPoint[] = await queryShipTrackPoints();
if (trackpoints.length > 0) {
mapManager.addLineString(
trackpoints.map((point) => [point.lon, point.lat]),
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
} catch (error) {
console.error('Error fetching ship track points:', error);
}
};
const getEntitiesData = async (mapManager: MapManager, gpsData: GpsData) => {
try {
const alarm: API.AlarmResponse = await queryAlarms();
addFeatureToMap(mapManager, gpsData, alarm);
} catch (error) { } catch (error) {
console.error('Error fetching entities:', error); console.error('Error fetching entities:', error);
} }
@@ -236,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"
@@ -268,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

@@ -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}`;
};