2026-01-30 13:01:54 +09:00
|
|
|
/**
|
|
|
|
|
* CSV 다운로드 유틸리티
|
|
|
|
|
* 참조: mda-react-front/src/widgets/rightMenu/ui/RightMenu.tsx (512-579)
|
|
|
|
|
*/
|
refactor: 관심구역, 측정, 미니맵 MapLibre 전환 + OpenLayers 제거 (Session E, F, H)
- Session E: 관심구역 폴리곤 + 추적 반경원 MapLibre GeoJSON 레이어로 전환
- useRealmLayer: OL VectorLayer → MapLibre fill/line layer
- useTrackingMode: 반경 원 @turf/circle → GeoJSON source
- Session F: 측정 도구 MapLibre 커스텀 구현
- useMeasure: OL Draw/Overlay → MapLibre Marker + GeoJSON layer
- 거리/면적: @turf/distance, @turf/length, @turf/area
- 툴 믹싱 지원, 세션 persistence
- Session H: 미니맵 MapLibre 전환 + OpenLayers 완전 제거
- VesselDetailModal/StsContactDetailModal: OL 임베디드 맵 → MapLibre 7개 레이어
- mapStore: map 타입 any → maplibregl.Map | null
- csvDownload: OL Polygon → Turf.js booleanPointInPolygon
- package.json: ol, ol-ext 제거 (~500KB 감소)
- main.tsx: OL CSS 제거
- 6개 OL 파일 @ts-nocheck 추가 (Session G 패스)
검증: yarn type-check, yarn lint, yarn build 통과
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 17:28:24 +09:00
|
|
|
import * as turf from '@turf/turf';
|
2026-01-30 13:01:54 +09:00
|
|
|
import { shipTypeMap } from '../assets/data/shiptype';
|
|
|
|
|
import { SHIP_KIND_LABELS, SIGNAL_SOURCE_LABELS } from '../types/constants';
|
|
|
|
|
|
2026-02-15 10:28:27 +09:00
|
|
|
/** 다운로드용 선박 데이터 */
|
|
|
|
|
interface DownloadShip {
|
|
|
|
|
downloadTargetId: string;
|
|
|
|
|
shipName: string;
|
|
|
|
|
signalKindCode: string;
|
|
|
|
|
shipType: string;
|
|
|
|
|
signalSourceCode: string;
|
|
|
|
|
sog: number;
|
|
|
|
|
cog: number;
|
|
|
|
|
longitude: string | number;
|
|
|
|
|
latitude: string | number;
|
|
|
|
|
draught: string;
|
|
|
|
|
receivedTime: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 해구도 캐시 엔트리 */
|
|
|
|
|
interface TrenchEntry {
|
|
|
|
|
zoneName: string;
|
refactor: 관심구역, 측정, 미니맵 MapLibre 전환 + OpenLayers 제거 (Session E, F, H)
- Session E: 관심구역 폴리곤 + 추적 반경원 MapLibre GeoJSON 레이어로 전환
- useRealmLayer: OL VectorLayer → MapLibre fill/line layer
- useTrackingMode: 반경 원 @turf/circle → GeoJSON source
- Session F: 측정 도구 MapLibre 커스텀 구현
- useMeasure: OL Draw/Overlay → MapLibre Marker + GeoJSON layer
- 거리/면적: @turf/distance, @turf/length, @turf/area
- 툴 믹싱 지원, 세션 persistence
- Session H: 미니맵 MapLibre 전환 + OpenLayers 완전 제거
- VesselDetailModal/StsContactDetailModal: OL 임베디드 맵 → MapLibre 7개 레이어
- mapStore: map 타입 any → maplibregl.Map | null
- csvDownload: OL Polygon → Turf.js booleanPointInPolygon
- package.json: ol, ol-ext 제거 (~500KB 감소)
- main.tsx: OL CSS 제거
- 6개 OL 파일 @ts-nocheck 추가 (Session G 패스)
검증: yarn type-check, yarn lint, yarn build 통과
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 17:28:24 +09:00
|
|
|
polygon: number[][][]; // GeoJSON Polygon coordinates
|
2026-02-15 10:28:27 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** GeoJSON Feature 형태 (largeTrench.json) */
|
|
|
|
|
interface TrenchFeature {
|
|
|
|
|
properties: { zone_name: string };
|
|
|
|
|
geometry: { coordinates: number[][][] };
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:01:54 +09:00
|
|
|
// 해구도 데이터 캐시 (첫 호출 시만 로딩)
|
2026-02-15 10:28:27 +09:00
|
|
|
let trenchCache: TrenchEntry[] | null = null;
|
2026-01-30 13:01:54 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 해구도 폴리곤 데이터 로딩 (동적 import, 캐시)
|
|
|
|
|
*/
|
2026-02-15 10:28:27 +09:00
|
|
|
async function loadTrenchData(): Promise<TrenchEntry[]> {
|
2026-01-30 13:01:54 +09:00
|
|
|
if (trenchCache) return trenchCache;
|
|
|
|
|
const data = await import('../assets/data/largeTrench.json');
|
|
|
|
|
const geojson = data.default || data;
|
2026-02-15 10:28:27 +09:00
|
|
|
trenchCache = (geojson as { features: TrenchFeature[] }).features.map((f: TrenchFeature) => ({
|
2026-01-30 13:01:54 +09:00
|
|
|
zoneName: f.properties.zone_name,
|
refactor: 관심구역, 측정, 미니맵 MapLibre 전환 + OpenLayers 제거 (Session E, F, H)
- Session E: 관심구역 폴리곤 + 추적 반경원 MapLibre GeoJSON 레이어로 전환
- useRealmLayer: OL VectorLayer → MapLibre fill/line layer
- useTrackingMode: 반경 원 @turf/circle → GeoJSON source
- Session F: 측정 도구 MapLibre 커스텀 구현
- useMeasure: OL Draw/Overlay → MapLibre Marker + GeoJSON layer
- 거리/면적: @turf/distance, @turf/length, @turf/area
- 툴 믹싱 지원, 세션 persistence
- Session H: 미니맵 MapLibre 전환 + OpenLayers 완전 제거
- VesselDetailModal/StsContactDetailModal: OL 임베디드 맵 → MapLibre 7개 레이어
- mapStore: map 타입 any → maplibregl.Map | null
- csvDownload: OL Polygon → Turf.js booleanPointInPolygon
- package.json: ol, ol-ext 제거 (~500KB 감소)
- main.tsx: OL CSS 제거
- 6개 OL 파일 @ts-nocheck 추가 (Session G 패스)
검증: yarn type-check, yarn lint, yarn build 통과
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 17:28:24 +09:00
|
|
|
polygon: f.geometry.coordinates,
|
2026-01-30 13:01:54 +09:00
|
|
|
}));
|
|
|
|
|
return trenchCache;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 선박 좌표 → 해구도 번호 일괄 조회
|
|
|
|
|
* @param {Array} ships - 선박 배열 (longitude, latitude 필드 필요)
|
|
|
|
|
* @returns {Map<number, string>} index → zone_name 매핑
|
|
|
|
|
*/
|
2026-02-15 10:28:27 +09:00
|
|
|
async function lookupTrenchNumbers(ships: DownloadShip[]): Promise<Map<number, string>> {
|
2026-01-30 13:01:54 +09:00
|
|
|
const trenchData = await loadTrenchData();
|
2026-02-15 10:28:27 +09:00
|
|
|
const result = new Map<number, string>();
|
2026-01-30 13:01:54 +09:00
|
|
|
|
|
|
|
|
ships.forEach((ship, idx) => {
|
2026-02-15 10:28:27 +09:00
|
|
|
const lon = parseFloat(String(ship.longitude));
|
|
|
|
|
const lat = parseFloat(String(ship.latitude));
|
2026-01-30 13:01:54 +09:00
|
|
|
if (isNaN(lon) || isNaN(lat)) {
|
|
|
|
|
result.set(idx, 'X');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let found = false;
|
|
|
|
|
for (const { zoneName, polygon } of trenchData) {
|
refactor: 관심구역, 측정, 미니맵 MapLibre 전환 + OpenLayers 제거 (Session E, F, H)
- Session E: 관심구역 폴리곤 + 추적 반경원 MapLibre GeoJSON 레이어로 전환
- useRealmLayer: OL VectorLayer → MapLibre fill/line layer
- useTrackingMode: 반경 원 @turf/circle → GeoJSON source
- Session F: 측정 도구 MapLibre 커스텀 구현
- useMeasure: OL Draw/Overlay → MapLibre Marker + GeoJSON layer
- 거리/면적: @turf/distance, @turf/length, @turf/area
- 툴 믹싱 지원, 세션 persistence
- Session H: 미니맵 MapLibre 전환 + OpenLayers 완전 제거
- VesselDetailModal/StsContactDetailModal: OL 임베디드 맵 → MapLibre 7개 레이어
- mapStore: map 타입 any → maplibregl.Map | null
- csvDownload: OL Polygon → Turf.js booleanPointInPolygon
- package.json: ol, ol-ext 제거 (~500KB 감소)
- main.tsx: OL CSS 제거
- 6개 OL 파일 @ts-nocheck 추가 (Session G 패스)
검증: yarn type-check, yarn lint, yarn build 통과
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 17:28:24 +09:00
|
|
|
// Turf.js booleanPointInPolygon으로 좌표 검사
|
|
|
|
|
if (turf.booleanPointInPolygon([lon, lat], turf.polygon(polygon))) {
|
2026-01-30 13:01:54 +09:00
|
|
|
result.set(idx, zoneName);
|
|
|
|
|
found = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!found) {
|
|
|
|
|
result.set(idx, 'X');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 수신시간 포맷 변환
|
|
|
|
|
* "YYYYMMDDHHmmss" → "YYYY-MM-DD HH:mm:ss"
|
|
|
|
|
*/
|
2026-02-15 10:28:27 +09:00
|
|
|
function formatRecvDateTime(raw: string | undefined | null): string {
|
2026-01-30 13:01:54 +09:00
|
|
|
if (!raw || raw.length < 14) return raw || '';
|
|
|
|
|
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)} ${raw.slice(8, 10)}:${raw.slice(10, 12)}:${raw.slice(12, 14)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* CSV 안전 필드 (쌍따옴표 감싸기)
|
|
|
|
|
*/
|
2026-02-15 10:28:27 +09:00
|
|
|
function csvField(val: string | number | null | undefined): string {
|
2026-01-30 13:01:54 +09:00
|
|
|
const str = val == null ? '' : String(val);
|
|
|
|
|
return `"${str.replace(/"/g, '""')}"`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* CSV 문자열 생성
|
|
|
|
|
* @param {Array} ships - 선박 배열
|
|
|
|
|
* @param {Map} trenchMap - index → zone_name 매핑
|
|
|
|
|
* @returns {string} CSV 문자열 (BOM 포함)
|
|
|
|
|
*/
|
2026-02-15 10:28:27 +09:00
|
|
|
function buildCsvString(ships: DownloadShip[], trenchMap: Map<number, string>): string {
|
2026-01-30 13:01:54 +09:00
|
|
|
const BOM = '\uFEFF';
|
|
|
|
|
const headers = [
|
|
|
|
|
'타겟ID', '선박명', '선종/기종', '선종/기종-유형', '신호',
|
|
|
|
|
'SOG', 'COG', '경도', '위도', '흘수', '수신시간', '해구도',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const rows = ships.map((ship, idx) => {
|
|
|
|
|
const fields = [
|
|
|
|
|
csvField(ship.downloadTargetId),
|
|
|
|
|
csvField(ship.shipName),
|
2026-02-15 10:28:27 +09:00
|
|
|
csvField((SHIP_KIND_LABELS as Record<string, string>)[ship.signalKindCode] || ship.signalKindCode),
|
2026-01-30 13:01:54 +09:00
|
|
|
csvField(shipTypeMap.get(String(ship.shipType)) || ship.shipType),
|
2026-02-15 10:28:27 +09:00
|
|
|
csvField((SIGNAL_SOURCE_LABELS as Record<string, string>)[ship.signalSourceCode] || ship.signalSourceCode),
|
2026-01-30 13:01:54 +09:00
|
|
|
csvField(ship.sog),
|
|
|
|
|
csvField(ship.cog),
|
|
|
|
|
csvField(ship.longitude),
|
|
|
|
|
csvField(ship.latitude),
|
|
|
|
|
csvField(ship.draught),
|
|
|
|
|
csvField(formatRecvDateTime(ship.receivedTime)),
|
|
|
|
|
csvField(trenchMap.get(idx) || 'X'),
|
|
|
|
|
];
|
|
|
|
|
return fields.join(',');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return BOM + headers.map(csvField).join(',') + '\n' + rows.join('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* CSV 다운로드 트리거
|
|
|
|
|
* @param {Array} ships - getDownloadShips()에서 반환된 선박 배열
|
|
|
|
|
*/
|
2026-02-15 10:28:27 +09:00
|
|
|
export async function downloadShipCsv(ships: DownloadShip[]): Promise<void> {
|
2026-01-30 13:01:54 +09:00
|
|
|
const trenchMap = await lookupTrenchNumbers(ships);
|
|
|
|
|
const csvString = buildCsvString(ships, trenchMap);
|
|
|
|
|
|
|
|
|
|
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
2026-02-15 10:28:27 +09:00
|
|
|
const pad = (n: number): string => String(n).padStart(2, '0');
|
2026-01-30 13:01:54 +09:00
|
|
|
const fileName = `ship_download_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.csv`;
|
|
|
|
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
link.href = url;
|
|
|
|
|
link.download = fileName;
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
link.click();
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}
|