/** * 항적분석 검색 결과 CSV 내보내기 * BOM + UTF-8 인코딩 (한글 엑셀 호환) */ import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import { getCountryIsoCode } from '../../replay/components/VesselListManager/utils/countryCodeUtils'; function formatTimestamp(ms) { if (!ms) return ''; const d = new Date(ms); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function formatPosition(pos) { if (!pos || pos.length < 2) return ''; const lon = pos[0]; const lat = pos[1]; const latDir = lat >= 0 ? 'N' : 'S'; const lonDir = lon >= 0 ? 'E' : 'W'; return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`; } function escapeCsvField(value) { const str = String(value ?? ''); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"`; } return str; } /** * 검색 결과를 CSV로 내보내기 (다중 방문 동적 컬럼 지원) * * @param {Array} tracks ProcessedTrack 배열 * @param {Object} hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] } * @param {Array} zones 구역 배열 */ export function exportSearchResultToCSV(tracks, hitDetails, zones) { // 구역별 최대 방문 횟수 계산 const maxVisitsPerZone = {}; zones.forEach((z) => { maxVisitsPerZone[z.id] = 1; }); Object.values(hitDetails).forEach((hits) => { const countByZone = {}; (Array.isArray(hits) ? hits : []).forEach((h) => { countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1; }); for (const [zoneId, count] of Object.entries(countByZone)) { maxVisitsPerZone[zoneId] = Math.max(maxVisitsPerZone[zoneId] || 0, count); } }); // 헤더 구성 const baseHeaders = [ '신호원', '식별번호', '선박명', '선종', '국적', '포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)', ]; const zoneHeaders = []; zones.forEach((zone) => { const max = maxVisitsPerZone[zone.id] || 1; if (max === 1) { zoneHeaders.push( `구역${zone.name}_진입시각`, `구역${zone.name}_진입위치`, `구역${zone.name}_진출시각`, `구역${zone.name}_진출위치`, ); } else { for (let v = 1; v <= max; v++) { zoneHeaders.push( `구역${zone.name}_${v}차_진입시각`, `구역${zone.name}_${v}차_진입위치`, `구역${zone.name}_${v}차_진출시각`, `구역${zone.name}_${v}차_진출위치`, ); } } }); const headers = [...baseHeaders, ...zoneHeaders]; // 데이터 행 생성 const rows = tracks.map((track) => { const baseRow = [ getSignalSourceName(track.sigSrcCd), track.targetId || '', track.shipName || '', getShipKindName(track.shipKindCode), track.nationalCode ? getCountryIsoCode(track.nationalCode) : '', track.stats?.pointCount ?? track.geometry.length, track.stats?.totalDistance != null ? track.stats.totalDistance.toFixed(2) : '', track.stats?.avgSpeed != null ? track.stats.avgSpeed.toFixed(1) : '', track.stats?.maxSpeed != null ? track.stats.maxSpeed.toFixed(1) : '', ]; const hits = hitDetails[track.vesselId] || []; const zoneData = []; zones.forEach((zone) => { const max = maxVisitsPerZone[zone.id] || 1; const zoneHits = hits .filter((h) => h.polygonId === zone.id) .sort((a, b) => (a.visitIndex || 1) - (b.visitIndex || 1)); for (let v = 0; v < max; v++) { const hit = zoneHits[v]; if (hit) { zoneData.push( formatTimestamp(hit.entryTimestamp), formatPosition(hit.entryPosition), formatTimestamp(hit.exitTimestamp), formatPosition(hit.exitPosition), ); } else { zoneData.push('', '', '', ''); } } }); return [...baseRow, ...zoneData]; }); // CSV 문자열 생성 const csvLines = [ headers.map(escapeCsvField).join(','), ...rows.map((row) => row.map(escapeCsvField).join(',')), ]; const csvContent = csvLines.join('\n'); // BOM + UTF-8 Blob 생성 및 다운로드 const BOM = '\uFEFF'; const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv`; const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); }