Merge pull request 'release: 2026-03-25.1 (5건 커밋)' (#198) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m55s

This commit is contained in:
htlee 2026-03-25 10:47:37 +09:00
커밋 f1f965fcd4
5개의 변경된 파일127개의 추가작업 그리고 103개의 파일을 삭제

파일 보기

@ -4,6 +4,15 @@
## [Unreleased] ## [Unreleased]
## [2026-03-25.1]
### 변경
- 현장분석: AI 파이프라인 더미 애니메이션 → analysisMap 기반 ON/OFF 실상태 표시
- 현장분석: BD-09 변환 STANDBY → bd09OffsetM 실측 탐지 수 표시
- 보고서: 수역별 허가업종 하드코딩 → ZONE_ALLOWED 상수 동적 참조
- 보고서: 건의사항 월/최대 어구 선단 실데이터 연동
- 보고서 버튼: 상단 헤더 → 현장분석 내부 닫기 버튼 좌측으로 이동
## [2026-03-25] ## [2026-03-25]
### 추가 ### 추가
@ -22,79 +31,31 @@
- fishing_pattern.py: 마지막 조업 세그먼트 누락 버그 수정 - fishing_pattern.py: 마지막 조업 세그먼트 누락 버그 수정
- 히스토리 모드 시 현재 강조 레이어 (deck.gl + MapLibre) 정상 숨김 - 히스토리 모드 시 현재 강조 레이어 (deck.gl + MapLibre) 정상 숨김
## [2026-03-24.4]
### 추가
- 경비함정 작전가이드 모달: 3탭 구성 (실시간탐지/대응절차/조치기준) + 임검침로 해상 루트 시각화
- 중국어선 감시현황 보고서 자동 생성 모달
- 중국어 경고문 TTS 음성 재생 (Google Translate TTS + Vite CORS 프록시)
- KoreaMap 임검침로 점선 시각화 (buildSeaRoute 육지 우회 알고리즘)
### 변경
- feature/korea-layers-enhancement 브랜치 기능 → develop 아키텍처(KoreaDashboard 분리 구조)에 이식
- 어구 그룹 지도 클릭 시 좌측 패널 해당 섹션 자동 오픈 + 스크롤 연동
## [2026-03-24.3]
### 추가
- 가상 선박 마커: 선단/어구 그룹 멤버를 ship-triangle 아이콘으로 표시 (COG 회전 + zoom interpolate)
- 어구 겹침 해결: queryRenderedFeatures → 다중 선택 팝업 + 호버 하이라이트
- AI 분석 통계 서버사이드 전환: dark/spoofing/risk/cluster/gear 집계를 Backend에서 계산
### 변경
- cnFishingSuspects에 모선 MMSI 포함 (어구 패턴에서 모선명 추출 → 동일명 선박 추가)
- AI 분석 패널: 클라이언트 사이드 stats 계산 로직 완전 제거 (14K+ 선박 순회 useMemo 삭제)
- Backend /api/vessel-analysis 응답에 stats 필드 추가 (집계 통계 서버 제공)
- GroupPolygonService에 어구 집계 SQL 추가 (gearGroups/gearCount)
- FleetClusterLayer: 패널 아코디언 전환 (하나만 열림), 높이 제한 min(45vh, 400px)
- vessel_store.py: COG bearing 계산 (마지막 2점 좌표 기반 atan2)
### 수정
- 어구 줌인 최대 제한 (maxZoom: 12)
## [2026-03-24.2]
### 추가
- 선단/어구그룹 폴리곤 서버사이드 생성: Shapely convex hull + buffer → PostGIS 저장
- DB migration 009: group_polygon_snapshots 테이블 (5분 APPEND, 7일 보존)
- Backend API: GET /api/vessel-analysis/groups (목록/상세/히스토리)
- useGroupPolygons 훅: 5분 폴링 (fleet/gearInZone/gearOutZone)
### 변경
- FleetClusterLayer: 클라이언트 convexHull/padPolygon 제거 → API GeoJSON 직접 렌더링
- 프론트 어구그룹 탐지(regex+거리 클러스터링) Python 이관
### 수정
- 불법어선 탭 복원 (임시 숨김 해제)
## [2026-03-24.1]
### 추가
- 웹폰트 내장: @fontsource-variable Inter, Noto Sans KR, Fira Code 자체 호스팅
- 폰트 상수 파일 (FONT_MONO, FONT_SANS) + 타입 선언
### 변경
- 전체 font-family 통일: CSS 55곳 + deck.gl TextLayer 30곳 + 인라인 스타일 8곳
- 이란 시설물 색상 사막 대비 고채도 팔레트 교체 (amber/orange/yellow → rose/sky/cyan/lime)
- 이란 시설 라벨 fontWeight 600→700, alpha 200→255 (가독성 개선)
- 접힘 패널 상하 패딩 균일화 (area-ship-header :last-child)
## [2026-03-24] ## [2026-03-24]
### 추가 ### 추가
- LayerPanel 공통 트리 구조: LayerTreeNode 재귀 렌더러 (한국/이란 양쪽 적용) - 선단/어구그룹 폴리곤 서버사이드 생성: Shapely convex hull + buffer → PostGIS 저장 (DB migration 009, 5분 APPEND, 7일 보존)
- 위험시설/해외시설 emoji→SVG IconLayer 전환 (12 SVG 함수, hazard/CN/JP 3개 IconLayer) - Backend API: groups 목록/상세/히스토리 + vessel-analysis stats 필드 (집계 통계 서버 제공)
- S&P Global 피격 선박 27척 데이터 (damagedShips.ts) - 가상 선박 마커: ship-triangle 아이콘 (COG 회전 + zoom interpolate) + 어구 겹침 다중 선택 팝업
- 이란 리플레이 실데이터 전환: Backend 시점 조회 API + Events CRUD - AI 분석 통계 서버사이드 전환: dark/spoofing/risk/cluster/gear 집계를 Backend에서 계산
- GeoEvent `sea_attack` 타입 + SEA ATK 배지 (피격 선박 이벤트 로그 통합) - 경비함정 작전가이드 모달: 3탭 + 임검침로 해상 루트 시각화 + 중국어 TTS
- 더미↔API 토글 UI (리플레이 배속 우측) - 중국어선 감시현황 보고서 자동 생성 모달
- 대시보드 탭 localStorage 영속화 - 웹폰트 내장: @fontsource-variable Inter/Noto Sans KR/Fira Code + 폰트 상수
- 지도 글꼴 크기 커스텀: 시설/선박/분석/지역 4그룹 슬라이더 (0.5~2.0x, LAYERS 하단) - LayerPanel 공통 트리 구조: 재귀 렌더러 + 부모 캐스케이드 ON/OFF
- 위험시설/해외시설 SVG IconLayer 전환 (12 SVG 함수)
- 이란 리플레이 실데이터 전환: Events CRUD + 시점 조회 API + 피격 선박 27척
- 지도 글꼴 크기 커스텀: 4그룹 슬라이더 (0.5~2.0x)
- useGroupPolygons 훅 (5분 폴링) + useIranData dataSource 분기
### 변경 ### 변경
- 부모 노드 토글→하위 전체 ON/OFF 캐스케이드 + 카운트 합산 - FleetClusterLayer: 클라이언트 convexHull 제거 → API GeoJSON 렌더링 + 패널 아코디언 전환
- useIranData dataSource 분기 (dummy=sampleData, api=Backend DB 3월1일~오늘) - AI 분석 패널: 클라이언트 stats 계산 제거 → 서버 제공 (14K+ 순회 useMemo 삭제)
- fetchAircraftByRange, fetchOsintByRange, fetchEventsByRange 서비스 함수 - 프론트 어구그룹 탐지 Python 이관 + 어구 클릭 시 좌측 패널 섹션 자동 전환
- 전체 font-family 통일 (CSS 55곳 + deck.gl 30곳) + 이란 시설물 사막 대비 고채도 팔레트
- feature/korea-layers-enhancement 브랜치 기능 → develop 아키텍처에 이식
### 수정
- 불법어선 탭 복원 + 어구 줌인 최대 제한 (maxZoom: 12)
## [2026-03-23] ## [2026-03-23]

파일 보기

@ -110,6 +110,7 @@ interface Props {
ships: Ship[]; ships: Ship[];
vesselAnalysis?: UseVesselAnalysisResult; vesselAnalysis?: UseVesselAnalysisResult;
onClose: () => void; onClose: () => void;
onShowReport?: () => void;
} }
const PIPE_STEPS = [ const PIPE_STEPS = [
@ -124,14 +125,14 @@ const PIPE_STEPS = [
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 }; const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) { export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onShowReport }: Props) {
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []); const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap; const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
const [activeFilter, setActiveFilter] = useState('ALL'); const [activeFilter, setActiveFilter] = useState('ALL');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null); const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [pipeStep, setPipeStep] = useState(0); // pipeStep 제거 — 파이프라인 상태는 analysisMap 존재 여부로 판단
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
// 중국 어선만 필터 // 중국 어선만 필터
@ -189,9 +190,13 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
// 통계 — Python 분석 결과 기반 // 통계 — Python 분석 결과 기반
const stats = useMemo(() => { const stats = useMemo(() => {
let gpsAnomaly = 0; let gpsAnomaly = 0;
let bd09Detected = 0;
for (const v of processed) { for (const v of processed) {
const dto = analysisMap.get(v.ship.mmsi); const dto = analysisMap.get(v.ship.mmsi);
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++; if (dto) {
if (dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
if (dto.algorithms.gpsSpoofing.bd09OffsetM > 100) bd09Detected++;
}
} }
return { return {
total: processed.length, total: processed.length,
@ -199,6 +204,7 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
fishing: processed.filter(v => v.state === 'FISHING').length, fishing: processed.filter(v => v.state === 'FISHING').length,
aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length, aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length,
gpsAnomaly, gpsAnomaly,
bd09Detected,
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size, clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
trawl: processed.filter(v => v.vtype === 'TRAWL').length, trawl: processed.filter(v => v.vtype === 'TRAWL').length,
purse: processed.filter(v => v.vtype === 'PURSE').length, purse: processed.filter(v => v.vtype === 'PURSE').length,
@ -231,12 +237,6 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// AI 파이프라인 애니메이션
useEffect(() => {
const t = setInterval(() => setPipeStep(s => s + 1), 1200);
return () => clearInterval(t);
}, []);
// 시계 tick // 시계 tick
useEffect(() => { useEffect(() => {
const t = setInterval(() => setTick(s => s + 1), 1000); const t = setInterval(() => setTick(s => s + 1), 1000);
@ -349,6 +349,19 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
LIVE LIVE
</span> </span>
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span> <span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
{onShowReport && (
<button
type="button"
onClick={onShowReport}
style={{
background: 'rgba(99,179,237,0.1)', border: '1px solid rgba(99,179,237,0.4)',
color: '#63b3ed', padding: '4px 14px', cursor: 'pointer',
fontSize: 11, borderRadius: 2, fontFamily: 'inherit',
}}
>
📋
</button>
)}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@ -430,38 +443,46 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}> <div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
AI AI
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span> <span style={{ float: 'right', color: analysisMap.size > 0 ? C.green : C.red, fontSize: 8 }}></span>
</div> </div>
{PIPE_STEPS.map((step, idx) => { {PIPE_STEPS.map((step) => {
const isRunning = idx === pipeStep % PIPE_STEPS.length; const connected = analysisMap.size > 0;
return ( return (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}> <div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span> <span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span> <span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{ <span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2, fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: isRunning ? 'rgba(0,230,118,0.15)' : 'rgba(0,230,118,0.06)', background: connected ? 'rgba(0,230,118,0.1)' : 'rgba(255,82,82,0.1)',
border: `1px solid ${isRunning ? C.green : C.border}`, border: `1px solid ${connected ? C.green : C.red}`,
color: isRunning ? C.green : C.ink3, color: connected ? C.green : C.red,
fontWeight: isRunning ? 700 : 400, fontWeight: 400,
}}> }}>
{isRunning ? 'PROC' : 'OK'} {connected ? 'ON' : 'OFF'}
</span> </span>
</div> </div>
); );
})} })}
{[ {[
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 }, {
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 }, num: 'GPS', name: 'BD-09 변환',
status: stats.bd09Detected > 0 ? `${stats.bd09Detected}척 탐지` : 'CLEAR',
color: stats.bd09Detected > 0 ? C.amber : C.green,
active: stats.bd09Detected > 0,
},
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3, active: false },
].map(step => ( ].map(step => (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}> <div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span> <span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span> <span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{ <span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2, fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: step.color, background: step.active ? 'rgba(255,215,64,0.12)' : 'rgba(24,255,255,0.08)',
border: `1px solid ${step.active ? C.amber : C.border}`,
color: step.color,
fontWeight: step.active ? 700 : 400,
}}> }}>
{step.status} {step.status}
</span> </span>

파일 보기

@ -171,6 +171,13 @@ export const KoreaDashboard = ({
const vesselAnalysis = useVesselAnalysis(true); const vesselAnalysis = useVesselAnalysis(true);
const groupPolygons = useGroupPolygons(true); const groupPolygons = useGroupPolygons(true);
const largestGearGroup = useMemo(() => {
const gears = groupPolygons.allGroups.filter(g => g.groupType !== 'FLEET');
if (gears.length === 0) return undefined;
const max = gears.reduce((a, b) => a.memberCount > b.memberCount ? a : b);
return { name: max.groupLabel, count: max.memberCount };
}, [groupPolygons.allGroups]);
const koreaFiltersResult = useKoreaFilters( const koreaFiltersResult = useKoreaFilters(
koreaData.ships, koreaData.ships,
koreaData.visibleShips, koreaData.visibleShips,
@ -300,10 +307,6 @@ 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 ${showReport ? 'active' : ''}`}
onClick={() => setShowReport(v => !v)} title="감시현황 보고서">
<span className="text-[11px]">📋</span>
</button>
<button type="button" className={`mode-btn ${showOpsGuide ? 'active' : ''}`} <button type="button" className={`mode-btn ${showOpsGuide ? 'active' : ''}`}
onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드"> onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드">
<span className="text-[11px]"></span> <span className="text-[11px]"></span>
@ -327,10 +330,11 @@ export const KoreaDashboard = ({
ships={koreaData.ships} ships={koreaData.ships}
vesselAnalysis={vesselAnalysis} vesselAnalysis={vesselAnalysis}
onClose={() => setShowFieldAnalysis(false)} onClose={() => setShowFieldAnalysis(false)}
onShowReport={() => setShowReport(v => !v)}
/> />
)} )}
{showReport && ( {showReport && (
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} /> <ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} largestGearGroup={largestGearGroup} />
)} )}
{showOpsGuide && ( {showOpsGuide && (
<OpsGuideModal <OpsGuideModal

파일 보기

@ -1,12 +1,42 @@
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import type { Ship } from '../../types'; import type { Ship } from '../../types';
import { getMarineTrafficCategory } from '../../utils/marineTraffic'; import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone } from '../../utils/fishingAnalysis'; import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone, ZONE_ALLOWED } from '../../utils/fishingAnalysis';
import type { FishingGearType } from '../../utils/fishingAnalysis'; import type { FishingGearType } from '../../utils/fishingAnalysis';
interface Props { interface Props {
ships: Ship[]; ships: Ship[];
onClose: () => void; onClose: () => void;
largestGearGroup?: { name: string; count: number };
}
const ALL_GEAR_TYPES = ['PT', 'OT', 'GN', 'PS', 'FC'];
const ZONE_LABELS: Record<string, string> = {
ZONE_I: '수역 I (동해)',
ZONE_II: '수역 II (남해)',
ZONE_III: '수역 III (서남해)',
ZONE_IV: '수역 IV (서해)',
};
const ZONE_EXTRA_NOTES: Record<string, string> = {
ZONE_III: '이어도 해역',
};
function zoneAllowedText(zone: string): string {
const allowed = ZONE_ALLOWED[zone];
if (!allowed || allowed.length === 0) return '-';
if (allowed.length >= ALL_GEAR_TYPES.length) return '전 업종';
return allowed.join(', ') + (allowed.length <= 2 ? '만' : '');
}
function zoneViolationText(zone: string): string {
const allowed = ZONE_ALLOWED[zone];
if (!allowed) return '-';
const violations = ALL_GEAR_TYPES.filter(t => !allowed.includes(t));
if (violations.length === 0) return ZONE_EXTRA_NOTES[zone] || '-';
const extra = ZONE_EXTRA_NOTES[zone] ? ` (${ZONE_EXTRA_NOTES[zone]})` : '';
return `${violations.join('/')} 발견 시 위반${extra}`;
} }
function now() { function now() {
@ -14,7 +44,7 @@ function now() {
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')}`; 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) { export function ReportModal({ ships, onClose, largestGearGroup }: Props) {
const reportRef = useRef<HTMLDivElement>(null); const reportRef = useRef<HTMLDivElement>(null);
const timestamp = useMemo(() => now(), []); const timestamp = useMemo(() => now(), []);
@ -186,13 +216,17 @@ export function ReportModal({ ships, onClose }: Props) {
<h2 style={h2Style}>4. </h2> <h2 style={h2Style}>4. </h2>
<table style={tableStyle}> <table style={tableStyle}>
<thead><tr style={{ background: '#1e293b' }}> <thead><tr style={{ background: '#1e293b' }}>
<th style={thStyle}></th><th style={thStyle}> </th><th style={thStyle}> (3)</th><th style={thStyle}></th> <th style={thStyle}></th><th style={thStyle}> </th><th style={thStyle}> ({new Date().getMonth() + 1})</th><th style={thStyle}></th>
</tr></thead> </tr></thead>
<tbody> <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> {(['ZONE_I', 'ZONE_II', 'ZONE_III', 'ZONE_IV'] as const).map(zone => (
<tr><td style={tdStyle}> II ()</td><td style={tdBold}>{stats.zoneStats.ZONE_II}</td><td style={tdDim}> </td><td style={tdDim}>-</td></tr> <tr key={zone}>
<tr><td style={tdStyle}> III ()</td><td style={tdBold}>{stats.zoneStats.ZONE_III}</td><td style={tdDim}> </td><td style={tdDim}> </td></tr> <td style={tdStyle}>{ZONE_LABELS[zone]}</td>
<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> <td style={tdBold}>{stats.zoneStats[zone]}</td>
<td style={tdDim}>{zoneAllowedText(zone)}</td>
<td style={tdDim}>{zoneViolationText(zone)}</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> <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> </tbody>
</table> </table>
@ -226,11 +260,15 @@ export function ReportModal({ ships, onClose }: Props) {
{/* 7. 건의사항 */} {/* 7. 건의사항 */}
<h2 style={h2Style}>7. </h2> <h2 style={h2Style}>7. </h2>
<div style={{ fontSize: 10, color: '#94a3b8', paddingLeft: 12 }}> <div style={{ fontSize: 10, color: '#94a3b8', paddingLeft: 12 }}>
<p>1. 3 , <strong style={{ color: '#f59e0b' }}> - </strong> </p> <p>1. {new Date().getMonth() + 1} , <strong style={{ color: '#f59e0b' }}> - </strong> </p>
<p>2. {stats.darkSuspect.length} <strong style={{ color: '#ef4444' }}>SAR </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>3. {stats.zoneStats.OUTSIDE} <strong style={{ color: '#ef4444' }}> </strong> </p>
<p>4. 4/16 <strong> </strong> </p> <p>4. 4/16 <strong> </strong> </p>
<p>5. 16 <strong> </strong> </p> {largestGearGroup ? (
<p>5. {largestGearGroup.name} {largestGearGroup.count} <strong> </strong> </p>
) : (
<p>5. <strong> </strong> </p>
)}
</div> </div>
{/* Footer */} {/* Footer */}

파일 보기

@ -56,7 +56,7 @@ export interface FishingZoneInfo {
} }
/** 수역별 허가 업종 */ /** 수역별 허가 업종 */
const ZONE_ALLOWED: Record<string, string[]> = { export const ZONE_ALLOWED: Record<string, string[]> = {
ZONE_I: ['PS', 'FC'], ZONE_I: ['PS', 'FC'],
ZONE_II: ['PT', 'OT', 'GN', 'PS', 'FC'], ZONE_II: ['PT', 'OT', 'GN', 'PS', 'FC'],
ZONE_III: ['PT', 'OT', 'GN', 'PS', 'FC'], ZONE_III: ['PT', 'OT', 'GN', 'PS', 'FC'],