Compare commits
1 커밋
main
...
feature/op
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
|
|
cebe5ce06b |
@ -4,6 +4,9 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage';
|
import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage';
|
||||||
import { KoreaMap } from './KoreaMap';
|
import { KoreaMap } from './KoreaMap';
|
||||||
import { FieldAnalysisModal } from './FieldAnalysisModal';
|
import { FieldAnalysisModal } from './FieldAnalysisModal';
|
||||||
|
import { OpsGuideModal } from './OpsGuideModal';
|
||||||
|
import type { OpsRoute } from './OpsGuideModal';
|
||||||
|
import { ReportModal } from './ReportModal';
|
||||||
import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel';
|
import { LayerPanel, type LayerTreeNode } from '../common/LayerPanel';
|
||||||
import { EventLog } from '../common/EventLog';
|
import { EventLog } from '../common/EventLog';
|
||||||
import { LiveControls } from '../common/LiveControls';
|
import { LiveControls } from '../common/LiveControls';
|
||||||
@ -79,6 +82,10 @@ export const KoreaDashboard = ({
|
|||||||
onTimeZoneChange,
|
onTimeZoneChange,
|
||||||
}: KoreaDashboardProps) => {
|
}: KoreaDashboardProps) => {
|
||||||
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
|
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
|
||||||
|
const [showOpsGuide, setShowOpsGuide] = useState(false);
|
||||||
|
const [showReport, setShowReport] = useState(false);
|
||||||
|
const [opsRoute, setOpsRoute] = useState<OpsRoute | null>(null);
|
||||||
|
const [flyToTarget, setFlyToTarget] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
|
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
|
||||||
@ -293,6 +300,10 @@ export const KoreaDashboard = ({
|
|||||||
onClick={() => setShowFieldAnalysis(v => !v)} title="현장분석">
|
onClick={() => setShowFieldAnalysis(v => !v)} title="현장분석">
|
||||||
<span className="text-[11px]">📊</span>현장분석
|
<span className="text-[11px]">📊</span>현장분석
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className={`mode-btn ${showOpsGuide ? 'active' : ''}`}
|
||||||
|
onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드">
|
||||||
|
<span className="text-[11px]">⚓</span>작전가이드
|
||||||
|
</button>
|
||||||
</div>,
|
</div>,
|
||||||
headerSlot,
|
headerSlot,
|
||||||
)}
|
)}
|
||||||
@ -312,8 +323,20 @@ export const KoreaDashboard = ({
|
|||||||
ships={koreaData.ships}
|
ships={koreaData.ships}
|
||||||
vesselAnalysis={vesselAnalysis}
|
vesselAnalysis={vesselAnalysis}
|
||||||
onClose={() => setShowFieldAnalysis(false)}
|
onClose={() => setShowFieldAnalysis(false)}
|
||||||
|
onReport={() => setShowReport(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showOpsGuide && (
|
||||||
|
<OpsGuideModal
|
||||||
|
ships={koreaData.ships}
|
||||||
|
onClose={() => { setShowOpsGuide(false); setOpsRoute(null); }}
|
||||||
|
onFlyTo={(lat, lng, zoom) => setFlyToTarget({ lat, lng, zoom })}
|
||||||
|
onRouteSelect={setOpsRoute}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showReport && (
|
||||||
|
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} />
|
||||||
|
)}
|
||||||
<KoreaMap
|
<KoreaMap
|
||||||
ships={koreaFiltersResult.filteredShips}
|
ships={koreaFiltersResult.filteredShips}
|
||||||
allShips={koreaData.ships}
|
allShips={koreaData.ships}
|
||||||
@ -332,6 +355,9 @@ export const KoreaDashboard = ({
|
|||||||
groupPolygons={groupPolygons}
|
groupPolygons={groupPolygons}
|
||||||
hiddenShipCategories={hiddenShipCategories}
|
hiddenShipCategories={hiddenShipCategories}
|
||||||
hiddenNationalities={hiddenNationalities}
|
hiddenNationalities={hiddenNationalities}
|
||||||
|
externalFlyTo={flyToTarget}
|
||||||
|
onExternalFlyToDone={() => setFlyToTarget(null)}
|
||||||
|
opsRoute={opsRoute}
|
||||||
/>
|
/>
|
||||||
<div className="map-overlay-left">
|
<div className="map-overlay-left">
|
||||||
<LayerPanel
|
<LayerPanel
|
||||||
|
|||||||
@ -69,6 +69,9 @@ interface Props {
|
|||||||
groupPolygons?: UseGroupPolygonsResult;
|
groupPolygons?: UseGroupPolygonsResult;
|
||||||
hiddenShipCategories?: Set<string>;
|
hiddenShipCategories?: Set<string>;
|
||||||
hiddenNationalities?: Set<string>;
|
hiddenNationalities?: Set<string>;
|
||||||
|
externalFlyTo?: { lat: number; lng: number; zoom: number } | null;
|
||||||
|
onExternalFlyToDone?: () => void;
|
||||||
|
opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
||||||
@ -144,7 +147,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
|
|||||||
cnFishing: 'filters.cnFishingMonitor',
|
cnFishing: 'filters.cnFishingMonitor',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities }: Props) {
|
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mapRef = useRef<MapRef>(null);
|
const mapRef = useRef<MapRef>(null);
|
||||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||||
@ -179,6 +182,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
}
|
}
|
||||||
}, [flyToTarget]);
|
}, [flyToTarget]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalFlyTo && mapRef.current) {
|
||||||
|
mapRef.current.flyTo({ center: [externalFlyTo.lng, externalFlyTo.lat], zoom: externalFlyTo.zoom, duration: 1500 });
|
||||||
|
onExternalFlyToDone?.();
|
||||||
|
}
|
||||||
|
}, [externalFlyTo, onExternalFlyToDone]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedAnalysisMmsi) setTrackCoords(null);
|
if (!selectedAnalysisMmsi) setTrackCoords(null);
|
||||||
}, [selectedAnalysisMmsi]);
|
}, [selectedAnalysisMmsi]);
|
||||||
@ -926,6 +936,36 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
|||||||
onExpandedChange={setAnalysisPanelOpen}
|
onExpandedChange={setAnalysisPanelOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 작전가이드 임검침로 점선 */}
|
||||||
|
{opsRoute && (() => {
|
||||||
|
const riskColor = opsRoute.riskLevel === 'CRITICAL' ? '#ef4444' : opsRoute.riskLevel === 'HIGH' ? '#f59e0b' : '#3b82f6';
|
||||||
|
const routeGeoJson: GeoJSON.FeatureCollection = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [[opsRoute.from.lng, opsRoute.from.lat], [opsRoute.to.lng, opsRoute.to.lat]] } }],
|
||||||
|
};
|
||||||
|
const midLng = (opsRoute.from.lng + opsRoute.to.lng) / 2;
|
||||||
|
const midLat = (opsRoute.from.lat + opsRoute.to.lat) / 2;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Source id="ops-route-line" type="geojson" data={routeGeoJson}>
|
||||||
|
<Layer id="ops-route-dash" type="line" paint={{ 'line-color': riskColor, 'line-width': 2.5, 'line-dasharray': [4, 4], 'line-opacity': 0.8 }} />
|
||||||
|
</Source>
|
||||||
|
<Marker longitude={opsRoute.from.lng} latitude={opsRoute.from.lat} anchor="center">
|
||||||
|
<div style={{ fontSize: 18, filter: 'drop-shadow(0 0 4px rgba(0,0,0,0.8))' }}>⚓</div>
|
||||||
|
</Marker>
|
||||||
|
<Marker longitude={opsRoute.to.lng} latitude={opsRoute.to.lat} anchor="center">
|
||||||
|
<div style={{ width: 12, height: 12, borderRadius: '50%', background: riskColor, border: '2px solid #fff', boxShadow: `0 0 8px ${riskColor}` }} />
|
||||||
|
</Marker>
|
||||||
|
<Marker longitude={midLng} latitude={midLat} anchor="bottom">
|
||||||
|
<div style={{ background: 'rgba(0,0,0,0.8)', padding: '2px 6px', borderRadius: 3, border: `1px solid ${riskColor}`, fontSize: 9, color: '#fff', fontWeight: 700, whiteSpace: 'nowrap', textAlign: 'center' }}>
|
||||||
|
{opsRoute.distanceNM.toFixed(1)} NM
|
||||||
|
<div style={{ fontSize: 7, color: riskColor }}>{opsRoute.from.name} → {opsRoute.to.name}</div>
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Map>
|
</Map>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
409
frontend/src/components/korea/OpsGuideModal.tsx
Normal file
409
frontend/src/components/korea/OpsGuideModal.tsx
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||||
|
import type { Ship } from '../../types';
|
||||||
|
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../../services/coastGuard';
|
||||||
|
import type { CoastGuardFacility } from '../../services/coastGuard';
|
||||||
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
|
import { analyzeFishing, classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||||
|
|
||||||
|
export interface OpsRoute {
|
||||||
|
from: { lat: number; lng: number; name: string };
|
||||||
|
to: { lat: number; lng: number; name: string; mmsi: string };
|
||||||
|
distanceNM: number;
|
||||||
|
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ships: Ship[];
|
||||||
|
onClose: () => void;
|
||||||
|
onFlyTo?: (lat: number, lng: number, zoom: number) => void;
|
||||||
|
onRouteSelect?: (route: OpsRoute | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuspectVessel {
|
||||||
|
ship: Ship;
|
||||||
|
distance: number;
|
||||||
|
reasons: string[];
|
||||||
|
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM';
|
||||||
|
estimatedType: 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineNM(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
|
const R = 3440.065;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_COLOR = { CRITICAL: '#ef4444', HIGH: '#f59e0b', MEDIUM: '#3b82f6' };
|
||||||
|
const RISK_ICON = { CRITICAL: '🔴', HIGH: '🟡', MEDIUM: '🔵' };
|
||||||
|
type Tab = 'detect' | 'procedure' | 'alert';
|
||||||
|
|
||||||
|
// ── 중국어 경고문 ──
|
||||||
|
const CN_WARNINGS: Record<string, { zh: string; ko: string; usage: string }[]> = {
|
||||||
|
PT: [
|
||||||
|
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF Ch.16 + 확성기' },
|
||||||
|
{ zh: '请出示捕捞许可证', ko: '어업허가증을 제시하시오', usage: '승선 검사 시' },
|
||||||
|
{ zh: '请出示作业日志', ko: '조업일지를 제시하시오', usage: '어획량 확인' },
|
||||||
|
{ zh: '你的网目不符合规定', ko: '망목이 규정에 미달합니다', usage: '어구 검사 (54mm 미만)' },
|
||||||
|
],
|
||||||
|
GN: [
|
||||||
|
{ zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀 대응' },
|
||||||
|
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
|
||||||
|
{ zh: '你在非许可区域作业', ko: '비허가 구역에서 조업 중입니다', usage: '수역 이탈 시' },
|
||||||
|
{ zh: '请立即收回渔网', ko: '어망을 즉시 회수하시오', usage: '불법 자망 발견' },
|
||||||
|
],
|
||||||
|
PS: [
|
||||||
|
{ zh: '所有船只立即停止作业', ko: '모든 선박 즉시 조업 중단', usage: '선단 제압 시' },
|
||||||
|
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
|
||||||
|
{ zh: '关闭集鱼灯', ko: '집어등을 끄시오', usage: '조명선 대응' },
|
||||||
|
{ zh: '不要试图逃跑', ko: '도주를 시도하지 마시오', usage: '도주 시' },
|
||||||
|
],
|
||||||
|
FC: [
|
||||||
|
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
|
||||||
|
{ zh: '请出示货物清单', ko: '화물 목록을 제시하시오', usage: '환적 검사' },
|
||||||
|
{ zh: '禁止转运渔获物', ko: '어획물 환적을 금지합니다', usage: '환적 현장' },
|
||||||
|
],
|
||||||
|
GEAR: [
|
||||||
|
{ zh: '这些渔具属于非法设置', ko: '이 어구는 불법 설치되었습니다', usage: '어구 수거 시' },
|
||||||
|
],
|
||||||
|
UNKNOWN: [
|
||||||
|
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: '기본 경고' },
|
||||||
|
{ zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function estimateVesselType(ship: Ship): 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN' {
|
||||||
|
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||||
|
const isGear = /[_]\d+[_]|%$/.test(ship.name);
|
||||||
|
if (isGear) return 'GEAR';
|
||||||
|
if (cat === 'cargo' || (cat === 'unspecified' && (ship.length || 0) > 50)) return 'FC';
|
||||||
|
if (cat !== 'fishing' && ship.category !== 'fishing' && ship.typecode !== '30') return 'UNKNOWN';
|
||||||
|
const spd = ship.speed || 0;
|
||||||
|
if (spd >= 7) return 'PS';
|
||||||
|
if (spd < 1.5) return 'GN';
|
||||||
|
return 'PT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpsGuideModal({ ships, onClose, onFlyTo, onRouteSelect }: Props) {
|
||||||
|
const [selectedKCG, setSelectedKCG] = useState<CoastGuardFacility | null>(null);
|
||||||
|
const [searchRadius, setSearchRadius] = useState(30);
|
||||||
|
const [pos, setPos] = useState({ x: 60, y: 60 });
|
||||||
|
const [tab, setTab] = useState<Tab>('detect');
|
||||||
|
const [selectedSuspect, setSelectedSuspect] = useState<SuspectVessel | null>(null);
|
||||||
|
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
|
||||||
|
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||||
|
|
||||||
|
const onDragStart = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
setPos({ x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: dragRef.current.origY + (ev.clientY - dragRef.current.startY) });
|
||||||
|
};
|
||||||
|
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}, [pos]);
|
||||||
|
|
||||||
|
const kcgBases = useMemo(() =>
|
||||||
|
COAST_GUARD_FACILITIES.filter(f => ['hq', 'regional', 'station', 'navy'].includes(f.type)).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
[]);
|
||||||
|
|
||||||
|
const suspects = useMemo<SuspectVessel[]>(() => {
|
||||||
|
if (!selectedKCG) return [];
|
||||||
|
const results: SuspectVessel[] = [];
|
||||||
|
for (const ship of ships) {
|
||||||
|
if (ship.flag !== 'CN') continue;
|
||||||
|
const dist = haversineNM(selectedKCG.lat, selectedKCG.lng, ship.lat, ship.lng);
|
||||||
|
if (dist > searchRadius) continue;
|
||||||
|
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||||
|
const isFishing = cat === 'fishing' || ship.category === 'fishing' || ship.typecode === '30';
|
||||||
|
const isGear = /[_]\d+[_]|%$/.test(ship.name);
|
||||||
|
const zone = classifyFishingZone(ship.lat, ship.lng);
|
||||||
|
const reasons: string[] = [];
|
||||||
|
let riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' = 'MEDIUM';
|
||||||
|
if (isFishing && zone.zone === 'OUTSIDE') { reasons.push('비허가 수역 진입'); riskLevel = 'CRITICAL'; }
|
||||||
|
if (isFishing && zone.zone === 'ZONE_I' && ship.speed >= 2 && ship.speed <= 5) { reasons.push('수역I 저인망 의심'); riskLevel = 'HIGH'; }
|
||||||
|
if (isFishing && ship.speed === 0 && (!ship.heading || ship.heading === 0)) { reasons.push('다크베셀 의심'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; }
|
||||||
|
if (isFishing && ship.speed >= 2 && ship.speed <= 6) reasons.push(`조업 추정 (${ship.speed.toFixed(1)}kn)`);
|
||||||
|
if (isGear) { reasons.push('어구/어망 AIS'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; }
|
||||||
|
if (!isFishing && (cat === 'cargo' || cat === 'unspecified') && ship.speed < 3) reasons.push('운반선/환적 의심');
|
||||||
|
if (reasons.length > 0) results.push({ ship, distance: dist, reasons, riskLevel, estimatedType: estimateVesselType(ship) });
|
||||||
|
}
|
||||||
|
return results.sort((a, b) => ({ CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[a.riskLevel] - { CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[b.riskLevel]) || a.distance - b.distance);
|
||||||
|
}, [selectedKCG, ships, searchRadius]);
|
||||||
|
|
||||||
|
const criticalCount = suspects.filter(s => s.riskLevel === 'CRITICAL').length;
|
||||||
|
const highCount = suspects.filter(s => s.riskLevel === 'HIGH').length;
|
||||||
|
|
||||||
|
const [speakingIdx, setSpeakingIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string, idx: number) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1500); });
|
||||||
|
};
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
const speakChinese = useCallback((text: string, idx: number) => {
|
||||||
|
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
|
||||||
|
setSpeakingIdx(idx);
|
||||||
|
const encoded = encodeURIComponent(text);
|
||||||
|
const url = `/api/gtts?ie=UTF-8&q=${encoded}&tl=zh-CN&total=1&idx=0&textlen=${text.length}&client=webapp&prev=input&ttsspeed=0.24`;
|
||||||
|
const audio = new Audio(url);
|
||||||
|
audioRef.current = audio;
|
||||||
|
audio.onended = () => setSpeakingIdx(null);
|
||||||
|
audio.onerror = () => setSpeakingIdx(null);
|
||||||
|
audio.play().catch(() => setSpeakingIdx(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSuspectClick = (s: SuspectVessel) => {
|
||||||
|
setSelectedSuspect(s);
|
||||||
|
setTab('procedure');
|
||||||
|
onFlyTo?.(s.ship.lat, s.ship.lng, 10);
|
||||||
|
if (selectedKCG) {
|
||||||
|
onRouteSelect?.({ from: { lat: selectedKCG.lat, lng: selectedKCG.lng, name: selectedKCG.name }, to: { lat: s.ship.lat, lng: s.ship.lng, name: s.ship.name || s.ship.mmsi, mmsi: s.ship.mmsi }, distanceNM: s.distance, riskLevel: s.riskLevel });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = { PT: '저인망(PT)', GN: '유자망(GN)', PS: '위망(PS)', FC: '운반선(FC)', GEAR: '어구/어망', UNKNOWN: '미분류' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', left: pos.x, top: pos.y, zIndex: 9999, width: 760, maxHeight: '85vh', overflow: 'hidden', background: '#0a0f1a', borderRadius: 8, border: '1px solid #1e293b', display: 'flex', flexDirection: 'column', boxShadow: '0 8px 32px rgba(0,0,0,0.6)', resize: 'both' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div onMouseDown={onDragStart} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderBottom: '1px solid #1e293b', background: 'rgba(30,58,95,0.5)', cursor: 'grab', userSelect: 'none', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 10, color: '#475569', letterSpacing: 2 }}>⠿</span>
|
||||||
|
<span style={{ fontSize: 14 }}>⚓</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}>경비함정 작전 가이드</span>
|
||||||
|
<button onClick={onClose} style={{ marginLeft: 'auto', background: 'rgba(255,82,82,0.1)', border: '1px solid rgba(255,82,82,0.4)', color: '#ef4444', padding: '4px 12px', cursor: 'pointer', fontSize: 10, borderRadius: 2 }}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 2, padding: '4px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0 }}>
|
||||||
|
{([['detect', '🔍 실시간 탐지'], ['procedure', '📋 대응 절차'], ['alert', '🚨 조치 기준']] as [Tab, string][]).map(([k, l]) => (
|
||||||
|
<button key={k} onClick={() => setTab(k)} style={{
|
||||||
|
padding: '3px 10px', fontSize: 10, fontWeight: 700, borderRadius: 3, cursor: 'pointer',
|
||||||
|
background: tab === k ? 'rgba(59,130,246,0.2)' : 'transparent',
|
||||||
|
border: tab === k ? '1px solid rgba(59,130,246,0.4)' : '1px solid transparent',
|
||||||
|
color: tab === k ? '#60a5fa' : '#64748b',
|
||||||
|
}}>{l}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls (detect tab) */}
|
||||||
|
{tab === 'detect' && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, padding: '6px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<select value={selectedKCG?.id ?? ''} onChange={e => { const f = kcgBases.find(b => b.id === Number(e.target.value)); setSelectedKCG(f || null); }} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0', minWidth: 160 }}>
|
||||||
|
<option value="">출동 기지 선택</option>
|
||||||
|
{kcgBases.map(b => <option key={b.id} value={b.id}>[{CG_TYPE_LABEL[b.type]}] {b.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={searchRadius} onChange={e => setSearchRadius(Number(e.target.value))} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0' }}>
|
||||||
|
{[10, 20, 30, 50, 100].map(n => <option key={n} value={n}>{n} NM</option>)}
|
||||||
|
</select>
|
||||||
|
{selectedKCG && <div style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9 }}>
|
||||||
|
<span style={{ color: '#ef4444', fontWeight: 700 }}>🔴 {criticalCount}</span>
|
||||||
|
<span style={{ color: '#f59e0b', fontWeight: 700 }}>🟡 {highCount}</span>
|
||||||
|
<span style={{ color: '#3b82f6', fontWeight: 700 }}>🔵 {suspects.length}</span>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '8px 16px', minHeight: 200 }}>
|
||||||
|
|
||||||
|
{/* ── TAB: 실시간 탐지 ── */}
|
||||||
|
{tab === 'detect' && (<>
|
||||||
|
{!selectedKCG ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}>⚓ 출동 기지를 선택하면 주변 불법어선·어구를 자동 탐지합니다</div>
|
||||||
|
) : suspects.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '30px 0', color: '#22c55e', fontSize: 11 }}>✅ {selectedKCG.name} 반경 {searchRadius}NM 내 의심 선박 없음</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{suspects.map((s, i) => (
|
||||||
|
<div key={s.ship.mmsi} onClick={() => handleSuspectClick(s)} style={{
|
||||||
|
background: '#111827', borderRadius: 4, padding: '6px 10px', borderLeft: `3px solid ${RISK_COLOR[s.riskLevel]}`, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
|
||||||
|
<span style={{ color: '#475569', minWidth: 18 }}>#{i + 1}</span>
|
||||||
|
<span>{RISK_ICON[s.riskLevel]}</span>
|
||||||
|
<span style={{ fontSize: 8, fontWeight: 700, padding: '0 4px', borderRadius: 2, background: RISK_COLOR[s.riskLevel] + '20', color: RISK_COLOR[s.riskLevel] }}>{s.riskLevel}</span>
|
||||||
|
<span style={{ fontWeight: 700, color: '#e2e8f0' }}>{s.ship.name || s.ship.mmsi}</span>
|
||||||
|
<span style={{ fontSize: 8, color: '#64748b' }}>[{TYPE_LABEL[s.estimatedType]}]</span>
|
||||||
|
<span style={{ marginLeft: 'auto', color: '#60a5fa', fontWeight: 700 }}>{s.distance.toFixed(1)} NM</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 3, marginTop: 2, flexWrap: 'wrap' }}>
|
||||||
|
{s.reasons.map((r, j) => <span key={j} style={{ fontSize: 7, padding: '0 4px', borderRadius: 2, background: 'rgba(255,255,255,0.05)', color: '#94a3b8' }}>{r}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* ── TAB: 대응 절차 ── */}
|
||||||
|
{tab === 'procedure' && (<>
|
||||||
|
{selectedSuspect ? (
|
||||||
|
<div style={{ fontSize: 10, color: '#e2e8f0', lineHeight: 1.7 }}>
|
||||||
|
{/* 선박 정보 */}
|
||||||
|
<div style={{ background: '#111827', borderRadius: 6, padding: '8px 12px', border: `1px solid ${RISK_COLOR[selectedSuspect.riskLevel]}30`, marginBottom: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span>{RISK_ICON[selectedSuspect.riskLevel]}</span>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 12 }}>{selectedSuspect.ship.name || selectedSuspect.ship.mmsi}</span>
|
||||||
|
<span style={{ fontSize: 9, padding: '1px 6px', borderRadius: 3, background: RISK_COLOR[selectedSuspect.riskLevel] + '20', color: RISK_COLOR[selectedSuspect.riskLevel], fontWeight: 700 }}>{selectedSuspect.riskLevel}</span>
|
||||||
|
<span style={{ fontSize: 9, color: '#64748b' }}>추정: {TYPE_LABEL[selectedSuspect.estimatedType]}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 9, color: '#64748b', marginTop: 2 }}>MMSI: {selectedSuspect.ship.mmsi} | SOG: {selectedSuspect.ship.speed?.toFixed(1)}kn | {selectedSuspect.distance.toFixed(1)} NM</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 업종별 대응 절차 */}
|
||||||
|
<ProcedureSteps type={selectedSuspect.estimatedType} />
|
||||||
|
|
||||||
|
{/* 중국어 경고문 */}
|
||||||
|
<div style={{ marginTop: 12, borderTop: '1px solid #1e293b', paddingTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#f59e0b', marginBottom: 6 }}>📢 중국어 경고문 (클릭: 복사 | 🔊: 음성)</div>
|
||||||
|
{(CN_WARNINGS[selectedSuspect.estimatedType] || CN_WARNINGS.UNKNOWN).map((w, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
background: copiedIdx === i ? 'rgba(34,197,94,0.15)' : speakingIdx === i ? 'rgba(251,191,36,0.1)' : '#111827',
|
||||||
|
border: copiedIdx === i ? '1px solid rgba(34,197,94,0.4)' : speakingIdx === i ? '1px solid rgba(251,191,36,0.4)' : '1px solid #1e293b',
|
||||||
|
borderRadius: 4, padding: '6px 10px', marginBottom: 4, transition: 'all 0.2s',
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => copyToClipboard(w.zh, i)}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#fbbf24' }}>{w.zh}</div>
|
||||||
|
<div style={{ fontSize: 9, color: '#94a3b8' }}>{w.ko}</div>
|
||||||
|
<div style={{ fontSize: 8, color: '#475569' }}>
|
||||||
|
사용: {w.usage}
|
||||||
|
{copiedIdx === i && <span style={{ color: '#22c55e', fontWeight: 700, marginLeft: 4 }}>✓ 복사됨</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); speakChinese(w.zh, i); }}
|
||||||
|
style={{
|
||||||
|
background: speakingIdx === i ? 'rgba(251,191,36,0.3)' : 'rgba(251,191,36,0.1)',
|
||||||
|
border: '1px solid rgba(251,191,36,0.3)',
|
||||||
|
borderRadius: 4, padding: '4px 8px', cursor: 'pointer',
|
||||||
|
fontSize: 14, lineHeight: 1, flexShrink: 0,
|
||||||
|
animation: speakingIdx === i ? 'pulse 1s ease-in-out infinite' : 'none',
|
||||||
|
}}
|
||||||
|
title="중국어 음성 재생"
|
||||||
|
>
|
||||||
|
{speakingIdx === i ? '🔊' : '🔈'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}>
|
||||||
|
실시간 탐지 탭에서 의심 선박을 클릭하면<br/>해당 업종별 대응 절차가 자동 표시됩니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* ── TAB: 조치 기준 ── */}
|
||||||
|
{tab === 'alert' && (<AlertTable />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ padding: '4px 16px', borderTop: '1px solid #1e293b', fontSize: 8, color: '#475569', flexShrink: 0 }}>
|
||||||
|
GC-KCG-2026-001 기반 | 허가현황 906척 | 수역: Point-in-Polygon | 중국어 경고문 클릭 시 클립보드 복사
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 업종별 대응 절차 컴포넌트 ──
|
||||||
|
const step: React.CSSProperties = { background: '#1e293b', borderRadius: 4, padding: '6px 10px', margin: '4px 0' };
|
||||||
|
const stepN: React.CSSProperties = { display: 'inline-block', background: '#3b82f6', color: '#fff', borderRadius: 3, padding: '0 5px', fontSize: 8, fontWeight: 700, marginRight: 4 };
|
||||||
|
const warn: React.CSSProperties = { background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, padding: '4px 8px', margin: '4px 0', fontSize: 9, color: '#fca5a5' };
|
||||||
|
|
||||||
|
function ProcedureSteps({ type }: { type: string }) {
|
||||||
|
switch (type) {
|
||||||
|
case 'PT': return (<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🔴 2척식 저인망 (PT) 대응 절차</div>
|
||||||
|
<div style={warn}>⚠ 선미(船尾) 방향 접근 절대 금지 — 예인삭 스크루 감김 위험</div>
|
||||||
|
<div style={step}><span style={stepN}>1</span><b>탐지/식별</b> — AIS MMSI → 허가DB 대조. 본선·부속선 쌍 확인, 이격거리 측정</div>
|
||||||
|
<div style={step}><span style={stepN}>2</span><b>접근/경고</b> — 선수 45° 측면 접근. VHF Ch.16 경고 3회. 중국어 방송 병행</div>
|
||||||
|
<div style={step}><span style={stepN}>3</span><b>승선 검사</b> — ①허가증(C21-xxxxx) ②조업일지(할당량 100톤/척) ③망목 실측(54mm)</div>
|
||||||
|
<div style={step}><span style={stepN}>4</span><b>위반 판정</b> — 휴어기(4/16~10/15)→나포 | 할당초과→압수 | 부속선 분리→양선 나포</div>
|
||||||
|
<div style={step}><span style={stepN}>5</span><b>나포/방면</b> — 위반: 목포·여수·제주·태안 입항. 경미: 경고 후 방면. 알람 기록 등록</div>
|
||||||
|
</>);
|
||||||
|
case 'GN': return (<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟡 유자망 (GN) 대응 — 다크베셀 주의</div>
|
||||||
|
<div style={warn}>⚠ 부표 위치 먼저 확인 → 그물 범위 외곽으로 접근 (스크루 감김 방지)</div>
|
||||||
|
<div style={step}><span style={stepN}>1</span><b>다크베셀 탐지</b> — 레이더 탐색 + SAR 요청. 부표 다수 발견 → 1NM 이내 집중 수색</div>
|
||||||
|
<div style={step}><span style={stepN}>2</span><b>그물 확인 후 접근</b> — 부표 배치방향 → 자망 연장선 추정 → 수직 90° 외곽 접근</div>
|
||||||
|
<div style={step}><span style={stepN}>3</span><b>AIS 재가동</b> — "请打开AIS" 경고. 재개 확인 후 MMSI 기록. 거부 시 강제 임검</div>
|
||||||
|
<div style={step}><span style={stepN}>4</span><b>승선 검사</b> — ①허가증(C25-xxxxx) ②수역확인(I발견→위반) ③어획량(28톤/척) ④망목·규모</div>
|
||||||
|
<div style={step}><span style={stepN}>5</span><b>어구 판정</b> — 허가외 자망→수거/절단. 망목미달→전량압수. GPS·사진 기록</div>
|
||||||
|
</>);
|
||||||
|
case 'PS': return (<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟣 위망 (PS) 선단 대응 — 선단 분산 주의</div>
|
||||||
|
<div style={warn}>⚠ 단독 접근 금지 — 조명선 시야교란, 분산도주 전술 대비. 대형 함정 지원 후 동시 제압</div>
|
||||||
|
<div style={step}><span style={stepN}>1</span><b>선단 확인/보고</b> — 원형궤적 + 고속→저속 패턴. 3척+ 클러스터. 즉시 상급 보고</div>
|
||||||
|
<div style={step}><span style={stepN}>2</span><b>집어등 식별</b> — 야간 EO/육안. 조명선 MMSI 기록. 차단은 최후 단계</div>
|
||||||
|
<div style={step}><span style={stepN}>3</span><b>선단 포위</b> — 모선·운반선·조명선 동시 포위. 서방(중국측) 탈주 차단 우선</div>
|
||||||
|
<div style={step}><span style={stepN}>4</span><b>일제 임검</b> — 모선: C23-xxxxx, 1,500톤/척. 운반선/조명선: 0톤→적재 시 불법</div>
|
||||||
|
<div style={step}><span style={stepN}>5</span><b>나포/증거</b> — 어획물·냉동설비 촬영. 宁波海裕 VHF 교신 확보. 목포·여수항 인계</div>
|
||||||
|
</>);
|
||||||
|
case 'FC': return (<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟠 운반선 (FC) 환적 대응</div>
|
||||||
|
<div style={step}><span style={stepN}>1</span><b>환적 알람</b> — FC+조업선 0.5NM + 양쪽 2kn + 30분 → HIGH. 좌표 즉시 이동</div>
|
||||||
|
<div style={step}><span style={stepN}>2</span><b>증거 촬영</b> — 접현/고무보트 확인. 드론 항공촬영. MMSI·선명·접현 흔적 기록</div>
|
||||||
|
<div style={step}><span style={stepN}>3</span><b>양선 임검</b> — 운반선: 화물·출발지·도착지. 조업선: 허가량 대비 실어획량</div>
|
||||||
|
<div style={step}><span style={stepN}>4</span><b>증거/조치</b> — 사진·중량 확보. 필요시 전량 압수. 도주시 경고사격. 최근접 항구 입항</div>
|
||||||
|
</>);
|
||||||
|
case 'GEAR': return (<>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🪤 불법 어구 수거 절차</div>
|
||||||
|
<div style={warn}>⚠ 방치 자망 스크루 감김 주의 — 엔진 정지/저속 상태에서 수동 회수. 야간 수거 원칙적 연기</div>
|
||||||
|
<div style={step}><span style={stepN}>1</span><b>발견/기록</b> — GPS(WGS84), 종류 추정, 사진, 소유자번호, 규모(길이·폭·그물코)</div>
|
||||||
|
<div style={step}><span style={stepN}>2</span><b>중국어구 판단</b> — 중국어 부표, 광폭·장형 구조. 인근 중국어선 확인. 불가→항구 감식</div>
|
||||||
|
<div style={step}><span style={stepN}>3</span><b>수거 실행</b> — RIB/크레인. 어획물→전량 압수. 절단 시 위치·잔존 기록</div>
|
||||||
|
<div style={step}><span style={stepN}>4</span><b>수거 보고</b> — 감시 시스템 등록. 항구 감식·증거 보존. 반복 발견→집중 감시 지정</div>
|
||||||
|
</>);
|
||||||
|
default: return (<div style={{ color: '#64748b', fontSize: 10 }}>선박 유형을 식별할 수 없습니다. 기본 임검 절차를 적용하세요.</div>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTable() {
|
||||||
|
const rows = [
|
||||||
|
{ type: '미등록 선박', criteria: 'MMSI 허가DB 미등록', action: '즉시 정선·나포', level: 'CRITICAL', note: '허가증 불소지 추가 확인' },
|
||||||
|
{ type: '휴어기 조업', criteria: 'C21·C22: 4/16~10/15\nC25: 6/2~8/31', action: '즉시 나포', level: 'CRITICAL', note: '날짜 자동 판별' },
|
||||||
|
{ type: '허가 수역 이탈', criteria: '비허가 수역 진입', action: '경고 후 나포', level: 'HIGH', note: 'PT: I·IV이탈 GN: I이탈' },
|
||||||
|
{ type: 'PT 부속선 분리', criteria: '본선 이격 3NM+', action: '양선 동시 나포', level: 'HIGH→CRIT', note: '311쌍 실시간 모니터링' },
|
||||||
|
{ type: '환적 현장 포착', criteria: 'FC+조업선 0.5NM+2kn+30분', action: '촬영 후 양선 나포', level: 'HIGH', note: '증거 촬영 최우선' },
|
||||||
|
{ type: '불법 어구 발견', criteria: '표지 없음/미허가', action: '즉시 수거·기록', level: '자체판단', note: 'GPS 등록, 반복 요주의' },
|
||||||
|
{ type: '할당량 초과', criteria: '80~100%+ 초과', action: '계량·초과 시 압수', level: 'CRITICAL', note: 'GN 28톤 현장 계량' },
|
||||||
|
{ type: '다크베셀', criteria: 'AIS 공백 6시간+', action: '접근·임검', level: 'HIGH', note: 'SAR 교차 확인' },
|
||||||
|
];
|
||||||
|
const lc = (l: string) => l.includes('CRIT') ? '#ef4444' : l === 'HIGH' ? '#f59e0b' : '#64748b';
|
||||||
|
return (
|
||||||
|
<div style={{ fontSize: 10, color: '#e2e8f0' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: '#60a5fa', marginBottom: 6 }}>🚨 단속 상황별 조치 기준</div>
|
||||||
|
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9 }}>
|
||||||
|
<thead><tr style={{ background: '#1e293b' }}>
|
||||||
|
<th style={th}>위반 유형</th><th style={th}>판정 기준</th><th style={th}>즉시 조치</th><th style={th}>알람</th><th style={th}>비고</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i}><td style={td}>{r.type}</td><td style={{ ...td, whiteSpace: 'pre-line' }}>{r.criteria}</td><td style={td}>{r.action}</td>
|
||||||
|
<td style={{ ...td, color: lc(r.level), fontWeight: 700 }}>{r.level}</td><td style={{ ...td, color: '#64748b', fontSize: 8 }}>{r.note}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style={{ marginTop: 12, fontSize: 11, fontWeight: 700, color: '#60a5fa' }}>📅 감시 강화 시기</div>
|
||||||
|
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9, marginTop: 4 }}>
|
||||||
|
<thead><tr style={{ background: '#1e293b' }}><th style={th}>시기</th><th style={th}>상황</th><th style={th}>대응</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td style={{ ...td, fontWeight: 700 }}>7~8월</td><td style={td}>PS 16척만 허가</td><td style={{ ...td, color: '#ef4444' }}>C21·C22·C25 전원 비허가</td></tr>
|
||||||
|
<tr><td style={{ ...td, fontWeight: 700 }}>5월</td><td style={td}>GN만 허가</td><td style={td}>저인망(C21·C22) 즉시 위반</td></tr>
|
||||||
|
<tr><td style={{ ...td, fontWeight: 700 }}>4월·10월</td><td style={td}>기간 경계</td><td style={td}>4/16, 10/16 집중 모니터링</td></tr>
|
||||||
|
<tr><td style={{ ...td, fontWeight: 700 }}>1~3월</td><td style={td}>전 업종 가능</td><td style={td}>수역이탈·할당초과 중심</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const th: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 6px', textAlign: 'left', color: '#e2e8f0', fontSize: 8, fontWeight: 700 };
|
||||||
|
const td: React.CSSProperties = { border: '1px solid #1e293b', padding: '2px 6px', fontSize: 9 };
|
||||||
258
frontend/src/components/korea/ReportModal.tsx
Normal file
258
frontend/src/components/korea/ReportModal.tsx
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import { useMemo, useRef } from 'react';
|
||||||
|
import type { Ship } from '../../types';
|
||||||
|
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||||
|
import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||||
|
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ships: Ship[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportModal({ ships, onClose }: Props) {
|
||||||
|
const reportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timestamp = useMemo(() => now(), []);
|
||||||
|
|
||||||
|
// Ship statistics
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const kr = ships.filter(s => s.flag === 'KR');
|
||||||
|
const cn = ships.filter(s => s.flag === 'CN');
|
||||||
|
const cnFishing = cn.filter(s => {
|
||||||
|
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
|
return cat === 'fishing' || s.category === 'fishing' || s.typecode === '30';
|
||||||
|
});
|
||||||
|
|
||||||
|
// CN fishing by speed
|
||||||
|
const cnAnchored = cnFishing.filter(s => s.speed < 1);
|
||||||
|
const cnLowSpeed = cnFishing.filter(s => s.speed >= 1 && s.speed < 3);
|
||||||
|
const cnOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 6);
|
||||||
|
const cnSailing = cnFishing.filter(s => s.speed > 6);
|
||||||
|
|
||||||
|
// Gear analysis
|
||||||
|
const fishingStats = aggregateFishingStats(cn);
|
||||||
|
|
||||||
|
// Zone analysis
|
||||||
|
const zoneStats: Record<string, number> = { ZONE_I: 0, ZONE_II: 0, ZONE_III: 0, ZONE_IV: 0, OUTSIDE: 0 };
|
||||||
|
cnFishing.forEach(s => {
|
||||||
|
const z = classifyFishingZone(s.lat, s.lng);
|
||||||
|
zoneStats[z.zone] = (zoneStats[z.zone] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dark vessels (AIS gap)
|
||||||
|
const darkSuspect = cnFishing.filter(s => s.speed === 0 && (!s.heading || s.heading === 0));
|
||||||
|
|
||||||
|
// Ship types
|
||||||
|
const byType: Record<string, number> = {};
|
||||||
|
ships.forEach(s => {
|
||||||
|
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||||
|
byType[cat] = (byType[cat] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// By nationality top 10
|
||||||
|
const byFlag: Record<string, number> = {};
|
||||||
|
ships.forEach(s => { byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1; });
|
||||||
|
const topFlags = Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||||
|
|
||||||
|
return { total: ships.length, kr, cn, cnFishing, cnAnchored, cnLowSpeed, cnOperating, cnSailing, fishingStats, zoneStats, darkSuspect, byType, topFlags };
|
||||||
|
}, [ships]);
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
const content = reportRef.current;
|
||||||
|
if (!content) return;
|
||||||
|
const win = window.open('', '_blank');
|
||||||
|
if (!win) return;
|
||||||
|
win.document.write(`
|
||||||
|
<html><head><title>중국어선 감시현황 보고서 - ${timestamp}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Malgun Gothic', sans-serif; padding: 40px; color: #1a1a1a; line-height: 1.8; font-size: 12px; }
|
||||||
|
h1 { font-size: 20px; border-bottom: 2px solid #1e3a5f; padding-bottom: 8px; color: #1e3a5f; }
|
||||||
|
h2 { font-size: 15px; color: #1e3a5f; margin-top: 24px; border-left: 4px solid #1e3a5f; padding-left: 8px; }
|
||||||
|
h3 { font-size: 13px; color: #333; margin-top: 16px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 11px; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; }
|
||||||
|
th { background: #1e3a5f; color: #fff; font-weight: 700; }
|
||||||
|
tr:nth-child(even) { background: #f5f7fa; }
|
||||||
|
.badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; }
|
||||||
|
.critical { background: #dc2626; color: #fff; }
|
||||||
|
.high { background: #f59e0b; color: #000; }
|
||||||
|
.medium { background: #3b82f6; color: #fff; }
|
||||||
|
.footer { margin-top: 30px; font-size: 9px; color: #888; border-top: 1px solid #ddd; padding-top: 8px; }
|
||||||
|
@media print { body { padding: 20px; } }
|
||||||
|
</style></head><body>${content.innerHTML}</body></html>
|
||||||
|
`);
|
||||||
|
win.document.close();
|
||||||
|
win.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const gearEntries = Object.entries(stats.fishingStats.byGear) as [FishingGearType, number][];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
|
background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}} onClick={onClose}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '90vw', maxWidth: 900, maxHeight: '90vh', overflow: 'auto',
|
||||||
|
background: '#0f172a', borderRadius: 8, border: '1px solid rgba(99,179,237,0.3)',
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
background: 'rgba(30,58,95,0.5)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 14 }}>📋</span>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}>중국어선 감시현황 분석 보고서</span>
|
||||||
|
<span style={{ fontSize: 9, color: '#64748b' }}>{timestamp} 기준</span>
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
||||||
|
<button onClick={handlePrint} style={{
|
||||||
|
background: '#3b82f6', border: 'none', borderRadius: 4,
|
||||||
|
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#fff', cursor: 'pointer',
|
||||||
|
}}>🖨 인쇄 / PDF</button>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
background: '#334155', border: 'none', borderRadius: 4,
|
||||||
|
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#94a3b8', cursor: 'pointer',
|
||||||
|
}}>✕ 닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Report Content */}
|
||||||
|
<div ref={reportRef} style={{ padding: '16px 24px', color: '#cbd5e1', fontSize: 11, lineHeight: 1.7 }}>
|
||||||
|
<h1 style={{ fontSize: 18, color: '#60a5fa', borderBottom: '2px solid #1e3a5f', paddingBottom: 6 }}>
|
||||||
|
한중어업협정 기반 중국어선 감시 현황 분석 보고서
|
||||||
|
</h1>
|
||||||
|
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 12 }}>
|
||||||
|
문서번호: GC-KCG-RPT-AUTO | 생성일시: {timestamp} | 작성: KCG AI 자동분석 시스템 | 【대외비】
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1. 전체 현황 */}
|
||||||
|
<h2 style={{ fontSize: 14, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 16 }}>1. 전체 해양 현황</h2>
|
||||||
|
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 10 }}>
|
||||||
|
<thead><tr style={{ background: '#1e293b' }}>
|
||||||
|
<th style={thStyle}>구분</th><th style={thStyle}>척수</th><th style={thStyle}>비율</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td style={tdStyle}>전체 선박</td><td style={tdBold}>{stats.total.toLocaleString()}척</td><td style={tdStyle}>100%</td></tr>
|
||||||
|
<tr><td style={tdStyle}>🇰🇷 한국 선박</td><td style={tdBold}>{stats.kr.length.toLocaleString()}척</td><td style={tdStyle}>{pct(stats.kr.length, stats.total)}</td></tr>
|
||||||
|
<tr><td style={tdStyle}>🇨🇳 중국 선박</td><td style={tdBold}>{stats.cn.length.toLocaleString()}척</td><td style={tdStyle}>{pct(stats.cn.length, stats.total)}</td></tr>
|
||||||
|
<tr style={{ background: '#1e293b' }}><td style={tdStyle}>🇨🇳 중국어선</td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnFishing.length.toLocaleString()}척</td><td style={tdStyle}>{pct(stats.cnFishing.length, stats.total)}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* 2. 중국어선 상세 */}
|
||||||
|
<h2 style={h2Style}>2. 중국어선 활동 분석</h2>
|
||||||
|
<table style={tableStyle}>
|
||||||
|
<thead><tr style={{ background: '#1e293b' }}>
|
||||||
|
<th style={thStyle}>활동 상태</th><th style={thStyle}>척수</th><th style={thStyle}>비율</th><th style={thStyle}>판단 기준</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td style={tdStyle}>⚓ 정박 (0~1kn)</td><td style={tdBold}>{stats.cnAnchored.length}</td><td style={tdStyle}>{pct(stats.cnAnchored.length, stats.cnFishing.length)}</td><td style={tdDim}>SOG {'<'} 1 knot</td></tr>
|
||||||
|
<tr><td style={tdStyle}>🔵 저속 이동 (1~3kn)</td><td style={tdBold}>{stats.cnLowSpeed.length}</td><td style={tdStyle}>{pct(stats.cnLowSpeed.length, stats.cnFishing.length)}</td><td style={tdDim}>투·양망 또는 이동</td></tr>
|
||||||
|
<tr style={{ background: 'rgba(245,158,11,0.1)' }}><td style={tdStyle}>🟡 조업 추정 (2~6kn)</td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnOperating.length}</td><td style={tdStyle}>{pct(stats.cnOperating.length, stats.cnFishing.length)}</td><td style={tdDim}>트롤/자망 조업 속도</td></tr>
|
||||||
|
<tr><td style={tdStyle}>🟢 항해 중 (6+kn)</td><td style={tdBold}>{stats.cnSailing.length}</td><td style={tdStyle}>{pct(stats.cnSailing.length, stats.cnFishing.length)}</td><td style={tdDim}>이동/귀항</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* 3. 어구별 분석 */}
|
||||||
|
<h2 style={h2Style}>3. 어구/어망 유형별 분석</h2>
|
||||||
|
<table style={tableStyle}>
|
||||||
|
<thead><tr style={{ background: '#1e293b' }}>
|
||||||
|
<th style={thStyle}>어구 유형</th><th style={thStyle}>추정 척수</th><th style={thStyle}>위험도</th><th style={thStyle}>탐지 신뢰도</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{gearEntries.map(([gear, count]) => {
|
||||||
|
const meta = GEAR_LABELS[gear];
|
||||||
|
return (
|
||||||
|
<tr key={gear}>
|
||||||
|
<td style={tdStyle}><span style={{ color: meta?.color || '#888' }}>{meta?.icon || '🎣'}</span> {meta?.label || gear}</td>
|
||||||
|
<td style={tdBold}>{count}척</td>
|
||||||
|
<td style={tdStyle}>{meta?.riskLevel === 'CRITICAL' ? '◉ CRITICAL' : meta?.riskLevel === 'HIGH' ? '⚠ HIGH' : '△ MED'}</td>
|
||||||
|
<td style={tdStyle}>{meta?.confidence || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* 4. 수역별 분포 */}
|
||||||
|
<h2 style={h2Style}>4. 특정어업수역별 분포</h2>
|
||||||
|
<table style={tableStyle}>
|
||||||
|
<thead><tr style={{ background: '#1e293b' }}>
|
||||||
|
<th style={thStyle}>수역</th><th style={thStyle}>어선 수</th><th style={thStyle}>허가 업종 (3월)</th><th style={thStyle}>비고</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td style={tdStyle}>수역 I (동해)</td><td style={tdBold}>{stats.zoneStats.ZONE_I}</td><td style={tdDim}>PS, FC만</td><td style={tdDim}>PT/OT/GN 발견 시 위반</td></tr>
|
||||||
|
<tr><td style={tdStyle}>수역 II (남해)</td><td style={tdBold}>{stats.zoneStats.ZONE_II}</td><td style={tdDim}>전 업종</td><td style={tdDim}>-</td></tr>
|
||||||
|
<tr><td style={tdStyle}>수역 III (서남해)</td><td style={tdBold}>{stats.zoneStats.ZONE_III}</td><td style={tdDim}>전 업종</td><td style={tdDim}>이어도 해역</td></tr>
|
||||||
|
<tr><td style={tdStyle}>수역 IV (서해)</td><td style={tdBold}>{stats.zoneStats.ZONE_IV}</td><td style={tdDim}>GN, PS, FC</td><td style={tdDim}>PT/OT 발견 시 위반</td></tr>
|
||||||
|
<tr style={{ background: 'rgba(239,68,68,0.1)' }}><td style={tdStyle}>수역 외</td><td style={{ ...tdBold, color: '#ef4444' }}>{stats.zoneStats.OUTSIDE}</td><td style={tdDim}>-</td><td style={{ ...tdDim, color: '#ef4444' }}>비허가 구역</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* 5. 위험 분석 */}
|
||||||
|
<h2 style={h2Style}>5. 위험 평가</h2>
|
||||||
|
<table style={tableStyle}>
|
||||||
|
<thead><tr style={{ background: '#1e293b' }}>
|
||||||
|
<th style={thStyle}>위험 유형</th><th style={thStyle}>현재 상태</th><th style={thStyle}>등급</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td style={tdStyle}>다크베셀 의심</td><td style={tdBold}>{stats.darkSuspect.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.darkSuspect.length > 10 ? '#dc2626' : '#f59e0b' }}>{stats.darkSuspect.length > 10 ? 'HIGH' : 'MEDIUM'}</span></td></tr>
|
||||||
|
<tr><td style={tdStyle}>수역 외 어선</td><td style={tdBold}>{stats.zoneStats.OUTSIDE}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.zoneStats.OUTSIDE > 0 ? '#dc2626' : '#22c55e' }}>{stats.zoneStats.OUTSIDE > 0 ? 'CRITICAL' : 'NORMAL'}</span></td></tr>
|
||||||
|
<tr><td style={tdStyle}>조업 중 어선</td><td style={tdBold}>{stats.cnOperating.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: '#3b82f6' }}>MONITOR</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* 6. 국적별 현황 */}
|
||||||
|
<h2 style={h2Style}>6. 국적별 선박 현황 (TOP 10)</h2>
|
||||||
|
<table style={tableStyle}>
|
||||||
|
<thead><tr style={{ background: '#1e293b' }}>
|
||||||
|
<th style={thStyle}>순위</th><th style={thStyle}>국적</th><th style={thStyle}>척수</th><th style={thStyle}>비율</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.topFlags.map(([flag, count], i) => (
|
||||||
|
<tr key={flag}><td style={tdStyle}>{i + 1}</td><td style={tdStyle}>{flag}</td><td style={tdBold}>{count.toLocaleString()}</td><td style={tdStyle}>{pct(count, stats.total)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* 7. 건의사항 */}
|
||||||
|
<h2 style={h2Style}>7. 건의사항</h2>
|
||||||
|
<div style={{ fontSize: 10, color: '#94a3b8', paddingLeft: 12 }}>
|
||||||
|
<p>1. 현재 3월은 전 업종 조업 가능 기간으로, <strong style={{ color: '#f59e0b' }}>수역 이탈 및 본선-부속선 분리</strong> 중심 감시 권고</p>
|
||||||
|
<p>2. 다크베셀 의심 {stats.darkSuspect.length}척에 대해 <strong style={{ color: '#ef4444' }}>SAR 위성 집중 탐색</strong> 요청</p>
|
||||||
|
<p>3. 수역 외 어선 {stats.zoneStats.OUTSIDE}척에 대해 <strong style={{ color: '#ef4444' }}>즉시 현장 확인</strong> 필요</p>
|
||||||
|
<p>4. 4/16 저인망 휴어기 진입 대비 <strong>감시 강화 계획 수립</strong> 권고</p>
|
||||||
|
<p>5. 宁波海裕 위망 선단 16척 <strong>그룹 위치 상시 추적</strong> 유지</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ marginTop: 24, paddingTop: 8, borderTop: '1px solid rgba(255,255,255,0.1)', fontSize: 8, color: '#475569' }}>
|
||||||
|
본 보고서는 KCG 해양감시 시스템에서 자동 생성된 내부 참고자료입니다. | 생성: {timestamp} | 데이터: 실시간 AIS | 분석: AI 자동분석 엔진 | 【대외비】
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const h2Style: React.CSSProperties = { fontSize: 13, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 20 };
|
||||||
|
const tableStyle: React.CSSProperties = { borderCollapse: 'collapse', width: '100%', fontSize: 10, marginTop: 6 };
|
||||||
|
const thStyle: React.CSSProperties = { border: '1px solid #334155', padding: '4px 8px', textAlign: 'left', color: '#e2e8f0', fontSize: 9, fontWeight: 700 };
|
||||||
|
const tdStyle: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 8px', fontSize: 10 };
|
||||||
|
const tdBold: React.CSSProperties = { ...tdStyle, fontWeight: 700, color: '#e2e8f0' };
|
||||||
|
const tdDim: React.CSSProperties = { ...tdStyle, color: '#64748b', fontSize: 9 };
|
||||||
|
const badgeStyle: React.CSSProperties = { display: 'inline-block', padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700, color: '#fff' };
|
||||||
|
|
||||||
|
function pct(n: number, total: number): string {
|
||||||
|
if (!total) return '-';
|
||||||
|
return `${((n / total) * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
@ -115,6 +115,16 @@ export default defineConfig(({ mode }): UserConfig => ({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/ollama/, ''),
|
rewrite: (path) => path.replace(/^\/ollama/, ''),
|
||||||
},
|
},
|
||||||
|
'/api/gtts': {
|
||||||
|
target: 'https://translate.google.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/gtts/, '/translate_tts'),
|
||||||
|
secure: true,
|
||||||
|
headers: {
|
||||||
|
'Referer': 'https://translate.google.com/',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user