2742 lines
87 KiB
TypeScript
Executable File
2742 lines
87 KiB
TypeScript
Executable File
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import maplibregl from 'maplibre-gl';
|
||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||
import { saveReport } from '../services/reportsApi';
|
||
import {
|
||
fetchSensitiveResourcesGeojson,
|
||
fetchPredictionParticlesGeojson,
|
||
fetchSensitivityEvaluationGeojson,
|
||
} from '@tabs/prediction/services/predictionApi';
|
||
|
||
// ─── Data Types ─────────────────────────────────────────────
|
||
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고';
|
||
export type ReportStatus = '완료' | '수행중' | '테스트';
|
||
export type Jurisdiction = '남해청' | '서해청' | '중부청' | '동해청' | '제주청';
|
||
export type AnalysisCategory = '유출유 확산예측' | 'HNS 대기확산' | '긴급구난' | '';
|
||
|
||
export interface OilSpillReportData {
|
||
id: string;
|
||
title: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
author: string;
|
||
reportType: ReportType;
|
||
analysisCategory: AnalysisCategory;
|
||
jurisdiction: Jurisdiction;
|
||
status: ReportStatus;
|
||
incident: {
|
||
name: string;
|
||
writeTime: string;
|
||
shipName: string;
|
||
agent: string;
|
||
location: string;
|
||
lat: string;
|
||
lon: string;
|
||
occurTime: string;
|
||
accidentType: string;
|
||
pollutant: string;
|
||
spillAmount: string;
|
||
depth: string;
|
||
seabed: string;
|
||
};
|
||
tide: {
|
||
date: string;
|
||
tideType: string;
|
||
lowTide1: string;
|
||
highTide1: string;
|
||
lowTide2: string;
|
||
highTide2: string;
|
||
}[];
|
||
weather: {
|
||
time: string;
|
||
sunrise: string;
|
||
sunset: string;
|
||
windDir: string;
|
||
windSpeed: string;
|
||
currentDir: string;
|
||
currentSpeed: string;
|
||
waveHeight: string;
|
||
}[];
|
||
spread: {
|
||
elapsed: string;
|
||
weathered: string;
|
||
seaRemain: string;
|
||
coastAttach: string;
|
||
area: string;
|
||
}[];
|
||
analysis: string;
|
||
aquaculture: { type: string; area: string; distance: string }[];
|
||
beaches: { name: string; distance: string }[];
|
||
markets: { name: string; distance: string }[];
|
||
esi: { code: string; type: string; length: string }[];
|
||
species: { category: string; species: string }[];
|
||
habitat: { type: string; area: string }[];
|
||
sensitivity: { level: string; area: string; color: string }[];
|
||
vessels: {
|
||
name: string;
|
||
org: string;
|
||
dist: string;
|
||
speed: string;
|
||
ton: string;
|
||
collectorType: string;
|
||
collectorCap: string;
|
||
boomType: string;
|
||
boomLength: string;
|
||
}[];
|
||
etcEquipment: string;
|
||
recovery: { shipName: string; period: string }[];
|
||
result: {
|
||
spillTotal: string;
|
||
weatheredTotal: string;
|
||
recoveredTotal: string;
|
||
seaRemainTotal: string;
|
||
coastAttachTotal: string;
|
||
};
|
||
capturedMapImage?: string;
|
||
step3MapImage?: string;
|
||
step6MapImage?: string;
|
||
hasMapCapture?: boolean;
|
||
acdntSn?: number;
|
||
sensitiveMapImage?: string;
|
||
sensitivityMapImage?: string;
|
||
}
|
||
|
||
// eslint-disable-next-line react-refresh/only-export-components
|
||
export function createEmptyReport(jurisdiction?: Jurisdiction): OilSpillReportData {
|
||
const now = new Date();
|
||
const ts = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||
return {
|
||
id: `RPT-${Date.now()}`,
|
||
title: '',
|
||
createdAt: now.toISOString(),
|
||
updatedAt: now.toISOString(),
|
||
author: '',
|
||
reportType: '예측보고서',
|
||
analysisCategory: '',
|
||
jurisdiction: jurisdiction || '',
|
||
status: '수행중',
|
||
incident: {
|
||
name: '',
|
||
writeTime: ts,
|
||
shipName: '',
|
||
agent: '',
|
||
location: '',
|
||
lat: '',
|
||
lon: '',
|
||
occurTime: '',
|
||
accidentType: '',
|
||
pollutant: '',
|
||
spillAmount: '',
|
||
depth: '',
|
||
seabed: '',
|
||
},
|
||
tide: [{ date: '', tideType: '', lowTide1: '', highTide1: '', lowTide2: '', highTide2: '' }],
|
||
weather: [
|
||
{
|
||
time: '',
|
||
sunrise: '',
|
||
sunset: '',
|
||
windDir: '',
|
||
windSpeed: '',
|
||
currentDir: '',
|
||
currentSpeed: '',
|
||
waveHeight: '',
|
||
},
|
||
],
|
||
spread: [
|
||
{ elapsed: '3시간', weathered: '', seaRemain: '', coastAttach: '', area: '' },
|
||
{ elapsed: '6시간', weathered: '', seaRemain: '', coastAttach: '', area: '' },
|
||
],
|
||
analysis: '',
|
||
aquaculture: [{ type: '', area: '', distance: '' }],
|
||
beaches: [{ name: '', distance: '' }],
|
||
markets: [{ name: '', distance: '' }],
|
||
esi: [
|
||
{ code: 'ESI 1', type: '수직암반', length: '' },
|
||
{ code: 'ESI 2', type: '수평암반', length: '' },
|
||
{ code: 'ESI 3', type: '세립질 모래', length: '' },
|
||
{ code: 'ESI 4', type: '조립질 모래', length: '' },
|
||
{ code: 'ESI 5', type: '모래·자갈', length: '' },
|
||
{ code: 'ESI 6A', type: '자갈', length: '' },
|
||
{ code: 'ESI 6B', type: '투과성 사석', length: '' },
|
||
{ code: 'ESI 7', type: '반폐쇄성 해안', length: '' },
|
||
{ code: 'ESI 8A', type: '갯벌', length: '' },
|
||
{ code: 'ESI 8B', type: '염습지', length: '' },
|
||
],
|
||
species: [
|
||
{ category: '양서파충류', species: '' },
|
||
{ category: '조류', species: '' },
|
||
{ category: '포유류', species: '' },
|
||
],
|
||
habitat: [{ type: '갯벌', area: '' }],
|
||
sensitivity: [
|
||
{ level: '매우 높음', area: '', color: '#ef4444' },
|
||
{ level: '높음', area: '', color: '#f97316' },
|
||
{ level: '보통', area: '', color: '#eab308' },
|
||
{ level: '낮음', area: '', color: '#22c55e' },
|
||
],
|
||
vessels: [
|
||
{
|
||
name: '',
|
||
org: '',
|
||
dist: '',
|
||
speed: '',
|
||
ton: '',
|
||
collectorType: '',
|
||
collectorCap: '',
|
||
boomType: '',
|
||
boomLength: '',
|
||
},
|
||
],
|
||
etcEquipment: '',
|
||
recovery: [{ shipName: '', period: '' }],
|
||
result: {
|
||
spillTotal: '',
|
||
weatheredTotal: '',
|
||
recoveredTotal: '',
|
||
seaRemainTotal: '',
|
||
coastAttachTotal: '',
|
||
},
|
||
};
|
||
}
|
||
|
||
// eslint-disable-next-line react-refresh/only-export-components
|
||
export function createSampleReport(): OilSpillReportData {
|
||
return {
|
||
id: `RPT-${Date.now()}`,
|
||
title: '여수 수변공원 Diesel 유출사고 대응지원',
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
author: '남해청_방제과',
|
||
reportType: '초기보고서',
|
||
analysisCategory: '유출유 확산예측',
|
||
jurisdiction: '남해청',
|
||
status: '완료',
|
||
incident: {
|
||
name: '여수 수변공원 유출사고',
|
||
writeTime: '2025.07.16 13:48',
|
||
shipName: '수변공원',
|
||
agent: '',
|
||
location: '여수 돌산 남방',
|
||
lat: '34° 43′ 37.6″',
|
||
lon: '127° 43′ 32.6″',
|
||
occurTime: '2025. 07. 08. 17:55',
|
||
accidentType: '시설 파손',
|
||
pollutant: 'Diesel',
|
||
spillAmount: '1㎘',
|
||
depth: '12m',
|
||
seabed: '모래',
|
||
},
|
||
tide: [
|
||
{
|
||
date: '2025-07-08 20:55',
|
||
tideType: '배꼽사리',
|
||
lowTide1: '01:53 (146)',
|
||
highTide1: '13:19 (104)',
|
||
lowTide2: '07:17 (253)',
|
||
highTide2: '20:24 (310)',
|
||
},
|
||
{
|
||
date: '2025-07-08 23:55',
|
||
tideType: '배꼽사리',
|
||
lowTide1: '01:53 (146)',
|
||
highTide1: '13:19 (104)',
|
||
lowTide2: '07:17 (253)',
|
||
highTide2: '20:24 (310)',
|
||
},
|
||
],
|
||
weather: [
|
||
{
|
||
time: '2025-07-08 20:55',
|
||
sunrise: '05:22',
|
||
sunset: '19:46',
|
||
windDir: '북동',
|
||
windSpeed: '0.0',
|
||
currentDir: '북',
|
||
currentSpeed: '0.0 / 0.0',
|
||
waveHeight: '0.0',
|
||
},
|
||
{
|
||
time: '2025-07-08 23:55',
|
||
sunrise: '05:22',
|
||
sunset: '19:46',
|
||
windDir: '북동',
|
||
windSpeed: '0.0',
|
||
currentDir: '북',
|
||
currentSpeed: '0.0 / 0.0',
|
||
waveHeight: '0.0',
|
||
},
|
||
],
|
||
spread: [
|
||
{ elapsed: '3시간', weathered: '0.48', seaRemain: '0.52', coastAttach: '0.12', area: '1.75' },
|
||
{ elapsed: '6시간', weathered: '0.64', seaRemain: '0.36', coastAttach: '0.24', area: '3.49' },
|
||
],
|
||
analysis: '',
|
||
aquaculture: [
|
||
{ type: '', area: '3.19', distance: '0.56' },
|
||
{ type: '', area: '20.15', distance: '0.83' },
|
||
{ type: '어류', area: '3.84', distance: '1.14' },
|
||
{ type: '', area: '2.89', distance: '1.40' },
|
||
{ type: '어류', area: '0.90', distance: '1.42' },
|
||
],
|
||
beaches: [
|
||
{ name: '검은모래해수욕장', distance: '5.86' },
|
||
{ name: '무슬목해수욕장', distance: '6.69' },
|
||
{ name: '모사금해수욕장', distance: '8.18' },
|
||
],
|
||
markets: [{ name: '돌산대교 회타운', distance: '1.09' }],
|
||
esi: [
|
||
{ code: 'ESI 1', type: '수직암반', length: '29.05 km' },
|
||
{ code: 'ESI 2', type: '수평암반', length: '13.76 km' },
|
||
{ code: 'ESI 3', type: '세립질 모래', length: '1.03 km' },
|
||
{ code: 'ESI 4', type: '조립질 모래', length: '0.47 km' },
|
||
{ code: 'ESI 5', type: '모래·자갈', length: '21.10 km' },
|
||
{ code: 'ESI 6A', type: '자갈', length: '15.29 km' },
|
||
{ code: 'ESI 6B', type: '투과성 사석', length: '26.60 km' },
|
||
{ code: 'ESI 7', type: '반폐쇄성 해안', length: '9.39 km' },
|
||
{ code: 'ESI 8A', type: '갯벌', length: '0.00 km' },
|
||
{ code: 'ESI 8B', type: '염습지', length: '3.43 km' },
|
||
],
|
||
species: [
|
||
{ category: '양서파충류', species: '' },
|
||
{ category: '조류', species: '뿔논병아리(겨울철새), 꾀꼬리(여름철새)' },
|
||
{ category: '포유류', species: '수달' },
|
||
],
|
||
habitat: [{ type: '갯벌', area: '4.32 km²' }],
|
||
sensitivity: [
|
||
{ level: '매우 높음', area: '18.54', color: '#ef4444' },
|
||
{ level: '높음', area: '31.32', color: '#f97316' },
|
||
{ level: '보통', area: '59.22', color: '#eab308' },
|
||
{ level: '낮음', area: '32.13', color: '#22c55e' },
|
||
],
|
||
vessels: [
|
||
{
|
||
name: '방제15호',
|
||
org: '서해청',
|
||
dist: '0.41',
|
||
speed: '7.0',
|
||
ton: '450.0',
|
||
collectorType: '흡착',
|
||
collectorCap: '140.0',
|
||
boomType: 'B',
|
||
boomLength: '60',
|
||
},
|
||
{
|
||
name: '방제26호',
|
||
org: '서해청',
|
||
dist: '0.41',
|
||
speed: '13.0',
|
||
ton: '360.0',
|
||
collectorType: '흡착',
|
||
collectorCap: '100.0',
|
||
boomType: 'B',
|
||
boomLength: '300',
|
||
},
|
||
{
|
||
name: '방제1001호',
|
||
org: '서해청',
|
||
dist: '0.74',
|
||
speed: '10.0',
|
||
ton: '564.0',
|
||
collectorType: '흡착',
|
||
collectorCap: '30.0',
|
||
boomType: 'B',
|
||
boomLength: '20',
|
||
},
|
||
{
|
||
name: '전남939호',
|
||
org: '서해청',
|
||
dist: '0.74',
|
||
speed: '10.0',
|
||
ton: '149.0',
|
||
collectorType: '흡착',
|
||
collectorCap: '30.0',
|
||
boomType: 'B',
|
||
boomLength: '300',
|
||
},
|
||
{
|
||
name: '동양방제호',
|
||
org: '서해청',
|
||
dist: '1.78',
|
||
speed: '10.0',
|
||
ton: '179.0',
|
||
collectorType: '위어,흡착',
|
||
collectorCap: '66.0',
|
||
boomType: 'C',
|
||
boomLength: '300',
|
||
},
|
||
{
|
||
name: '화학방제2함',
|
||
org: '서해청',
|
||
dist: '3.00',
|
||
speed: '13.0',
|
||
ton: '501.0',
|
||
collectorType: '위어,브러쉬',
|
||
collectorCap: '150.0',
|
||
boomType: '특수',
|
||
boomLength: '60',
|
||
},
|
||
{
|
||
name: '우진방제호',
|
||
org: '서해청',
|
||
dist: '13.22',
|
||
speed: '9.0',
|
||
ton: '79.0',
|
||
collectorType: '위어',
|
||
collectorCap: '20.0',
|
||
boomType: 'C',
|
||
boomLength: '300',
|
||
},
|
||
],
|
||
etcEquipment: '',
|
||
recovery: [{ shipName: '방제15호', period: '0' }],
|
||
result: {
|
||
spillTotal: '1',
|
||
weatheredTotal: '0.64',
|
||
recoveredTotal: '0',
|
||
seaRemainTotal: '0.36',
|
||
coastAttachTotal: '0.24',
|
||
},
|
||
};
|
||
}
|
||
|
||
// ─── localStorage helpers 제거됨 — reportsApi.ts 사용 ────────
|
||
|
||
// ─── Styles ─────────────────────────────────────────────────
|
||
const S = {
|
||
page: {
|
||
background: 'var(--bg-surface)',
|
||
padding: '32px 40px',
|
||
marginBottom: '24px',
|
||
borderRadius: '6px',
|
||
border: '1px solid var(--stroke-default)',
|
||
fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif",
|
||
fontSize: '12px',
|
||
lineHeight: '1.6',
|
||
position: 'relative' as const,
|
||
width: '100%',
|
||
boxSizing: 'border-box' as const,
|
||
},
|
||
sectionTitle: {
|
||
background: 'rgba(6,182,212,0.12)',
|
||
color: 'var(--color-accent)',
|
||
padding: '8px 16px',
|
||
fontSize: '13px',
|
||
fontWeight: 700,
|
||
marginBottom: '12px',
|
||
borderRadius: '4px',
|
||
border: '1px solid rgba(6,182,212,0.2)',
|
||
},
|
||
subHeader: {
|
||
fontSize: '14px',
|
||
fontWeight: 700,
|
||
color: 'var(--color-accent)',
|
||
marginBottom: '12px',
|
||
borderBottom: '2px solid var(--stroke-default)',
|
||
paddingBottom: '6px',
|
||
},
|
||
table: {
|
||
width: '100%',
|
||
tableLayout: 'fixed' as const,
|
||
borderCollapse: 'collapse' as const,
|
||
fontSize: '11px',
|
||
marginBottom: '16px',
|
||
},
|
||
th: {
|
||
background: 'var(--bg-card)',
|
||
border: '1px solid var(--stroke-default)',
|
||
padding: '6px 10px',
|
||
fontWeight: 600,
|
||
color: 'var(--fg-sub)',
|
||
textAlign: 'center' as const,
|
||
fontSize: '10px',
|
||
},
|
||
td: {
|
||
border: '1px solid var(--stroke-default)',
|
||
padding: '5px 10px',
|
||
textAlign: 'center' as const,
|
||
fontSize: '11px',
|
||
color: 'var(--fg-sub)',
|
||
},
|
||
tdLeft: {
|
||
border: '1px solid var(--stroke-default)',
|
||
padding: '5px 10px',
|
||
textAlign: 'left' as const,
|
||
fontSize: '11px',
|
||
color: 'var(--fg-sub)',
|
||
},
|
||
thLabel: {
|
||
background: 'var(--bg-card)',
|
||
border: '1px solid var(--stroke-default)',
|
||
padding: '6px 10px',
|
||
fontWeight: 600,
|
||
color: 'var(--fg-sub)',
|
||
textAlign: 'left' as const,
|
||
fontSize: '11px',
|
||
width: '120px',
|
||
},
|
||
mapPlaceholder: {
|
||
width: '100%',
|
||
height: '240px',
|
||
background: 'var(--bg-base)',
|
||
border: '2px dashed var(--stroke-default)',
|
||
borderRadius: '4px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--fg-disabled)',
|
||
fontSize: '13px',
|
||
fontWeight: 600,
|
||
marginBottom: '16px',
|
||
},
|
||
};
|
||
|
||
// ─── Editable cell ──────────────────────────────────────────
|
||
const inputStyle: React.CSSProperties = {
|
||
width: '100%',
|
||
background: 'var(--bg-base)',
|
||
border: '1px solid var(--stroke-light)',
|
||
borderRadius: '3px',
|
||
padding: '4px 8px',
|
||
fontSize: '11px',
|
||
outline: 'none',
|
||
textAlign: 'center',
|
||
};
|
||
|
||
function ECell({
|
||
value,
|
||
editing,
|
||
onChange,
|
||
align,
|
||
placeholder,
|
||
}: {
|
||
value: string;
|
||
editing: boolean;
|
||
onChange?: (v: string) => void;
|
||
align?: 'left' | 'center';
|
||
placeholder?: string;
|
||
}) {
|
||
const style = align === 'left' ? S.tdLeft : S.td;
|
||
if (!editing) {
|
||
return (
|
||
<td
|
||
style={{
|
||
...style,
|
||
color: value ? 'var(--fg-default)' : 'var(--fg-disabled)',
|
||
fontStyle: value ? 'normal' : 'italic',
|
||
}}
|
||
>
|
||
{value || placeholder || '-'}
|
||
</td>
|
||
);
|
||
}
|
||
return (
|
||
<td style={style}>
|
||
<input
|
||
style={{ ...inputStyle, textAlign: align || 'center' }}
|
||
value={value}
|
||
onChange={(e) => onChange?.(e.target.value)}
|
||
placeholder={placeholder}
|
||
/>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
function AddRowBtn({ onClick, label }: { onClick: () => void; label?: string }) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className="px-3 py-1 text-[10px] font-semibold text-color-accent bg-[rgba(6,182,212,0.08)] border border-dashed border-color-accent rounded-sm cursor-pointer mb-3"
|
||
>
|
||
+ {label || '행 추가'}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ─── Page components ────────────────────────────────────────
|
||
function Page1({
|
||
data,
|
||
editing,
|
||
onChange,
|
||
}: {
|
||
data: OilSpillReportData;
|
||
editing: boolean;
|
||
onChange: (d: OilSpillReportData) => void;
|
||
}) {
|
||
const inc = data.incident;
|
||
const set = (k: keyof typeof inc, v: string) =>
|
||
onChange({ ...data, incident: { ...inc, [k]: v } });
|
||
|
||
return (
|
||
<div style={S.page}>
|
||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||
해양오염방제지원시스템
|
||
</div>
|
||
<div
|
||
className="text-color-accent text-[18px] font-bold mb-5 rounded px-5 py-3 text-center tracking-wide border"
|
||
style={{ background: 'rgba(6,182,212,0.1)', border: '1px solid rgba(6,182,212,0.2)' }}
|
||
>
|
||
유류오염사고 대응지원 상황도
|
||
</div>
|
||
<div style={S.sectionTitle}>1. 사고 정보</div>
|
||
<table style={S.table}>
|
||
<colgroup>
|
||
<col style={{ width: '14%' }} />
|
||
<col style={{ width: '36%' }} />
|
||
<col style={{ width: '14%' }} />
|
||
<col style={{ width: '36%' }} />
|
||
</colgroup>
|
||
<tbody>
|
||
<tr>
|
||
<td style={S.thLabel}>사고명</td>
|
||
<ECell
|
||
value={inc.name}
|
||
editing={editing}
|
||
onChange={(v) => set('name', v)}
|
||
placeholder="사고명 입력"
|
||
/>
|
||
<td style={S.thLabel}>작성시간</td>
|
||
<ECell value={inc.writeTime} editing={editing} onChange={(v) => set('writeTime', v)} />
|
||
</tr>
|
||
<tr>
|
||
<td style={S.thLabel}>선명(시설명)</td>
|
||
<ECell
|
||
value={inc.shipName}
|
||
editing={editing}
|
||
onChange={(v) => set('shipName', v)}
|
||
placeholder="선명"
|
||
/>
|
||
<td style={S.thLabel}>제원</td>
|
||
<ECell
|
||
value={inc.agent}
|
||
editing={editing}
|
||
onChange={(v) => set('agent', v)}
|
||
placeholder="제원"
|
||
/>
|
||
</tr>
|
||
<tr>
|
||
<td style={S.thLabel}>사고위치</td>
|
||
<ECell
|
||
value={inc.location}
|
||
editing={editing}
|
||
onChange={(v) => set('location', v)}
|
||
placeholder="위치"
|
||
/>
|
||
<td style={S.thLabel}>좌표</td>
|
||
<ECell value={`위도: ${inc.lat} 경도: ${inc.lon}`} editing={false} />
|
||
</tr>
|
||
{editing && (
|
||
<tr>
|
||
<td style={S.thLabel}>위도</td>
|
||
<ECell
|
||
value={inc.lat}
|
||
editing
|
||
onChange={(v) => set('lat', v)}
|
||
placeholder="34° 43′ 37.6″"
|
||
/>
|
||
<td style={S.thLabel}>경도</td>
|
||
<ECell
|
||
value={inc.lon}
|
||
editing
|
||
onChange={(v) => set('lon', v)}
|
||
placeholder="127° 43′ 32.6″"
|
||
/>
|
||
</tr>
|
||
)}
|
||
<tr>
|
||
<td style={S.thLabel}>발생시각</td>
|
||
<ECell value={inc.occurTime} editing={editing} onChange={(v) => set('occurTime', v)} />
|
||
<td style={S.thLabel}>사고유형</td>
|
||
<ECell
|
||
value={inc.accidentType}
|
||
editing={editing}
|
||
onChange={(v) => set('accidentType', v)}
|
||
placeholder="사고유형"
|
||
/>
|
||
</tr>
|
||
<tr>
|
||
<td style={S.thLabel}>오염물질</td>
|
||
<ECell value={inc.pollutant} editing={editing} onChange={(v) => set('pollutant', v)} />
|
||
<td style={S.thLabel}>유출 추정량</td>
|
||
<ECell
|
||
value={inc.spillAmount}
|
||
editing={editing}
|
||
onChange={(v) => set('spillAmount', v)}
|
||
/>
|
||
</tr>
|
||
<tr>
|
||
<td style={S.thLabel}>수심</td>
|
||
<ECell
|
||
value={inc.depth}
|
||
editing={editing}
|
||
onChange={(v) => set('depth', v)}
|
||
placeholder="수심"
|
||
/>
|
||
<td style={S.thLabel}>저질</td>
|
||
<ECell
|
||
value={inc.seabed}
|
||
editing={editing}
|
||
onChange={(v) => set('seabed', v)}
|
||
placeholder="저질"
|
||
/>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Page2({
|
||
data,
|
||
editing,
|
||
onChange,
|
||
}: {
|
||
data: OilSpillReportData;
|
||
editing: boolean;
|
||
onChange: (d: OilSpillReportData) => void;
|
||
}) {
|
||
const setTide = (i: number, k: string, v: string) => {
|
||
const t = [...data.tide];
|
||
t[i] = { ...t[i], [k]: v };
|
||
onChange({ ...data, tide: t });
|
||
};
|
||
const setWeather = (i: number, k: string, v: string) => {
|
||
const w = [...data.weather];
|
||
w[i] = { ...w[i], [k]: v };
|
||
onChange({ ...data, weather: w });
|
||
};
|
||
const setSpread = (i: number, k: string, v: string) => {
|
||
const s = [...data.spread];
|
||
s[i] = { ...s[i], [k]: v };
|
||
onChange({ ...data, spread: s });
|
||
};
|
||
|
||
return (
|
||
<div style={S.page}>
|
||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||
해양오염방제지원시스템
|
||
</div>
|
||
<div style={S.sectionTitle}>2. 해양기상정보</div>
|
||
<div style={S.subHeader}>조석 정보</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>일자</th>
|
||
<th style={S.th}>물때</th>
|
||
<th style={S.th} colSpan={2}>
|
||
저조
|
||
</th>
|
||
<th style={S.th} colSpan={2}>
|
||
고조
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.tide.map((t, i) => (
|
||
<tr key={i}>
|
||
<ECell value={t.date} editing={editing} onChange={(v) => setTide(i, 'date', v)} />
|
||
<ECell
|
||
value={t.tideType}
|
||
editing={editing}
|
||
onChange={(v) => setTide(i, 'tideType', v)}
|
||
/>
|
||
<ECell
|
||
value={t.lowTide1}
|
||
editing={editing}
|
||
onChange={(v) => setTide(i, 'lowTide1', v)}
|
||
/>
|
||
<ECell
|
||
value={t.lowTide2}
|
||
editing={editing}
|
||
onChange={(v) => setTide(i, 'lowTide2', v)}
|
||
/>
|
||
<ECell
|
||
value={t.highTide1}
|
||
editing={editing}
|
||
onChange={(v) => setTide(i, 'highTide1', v)}
|
||
/>
|
||
<ECell
|
||
value={t.highTide2}
|
||
editing={editing}
|
||
onChange={(v) => setTide(i, 'highTide2', v)}
|
||
/>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{editing && (
|
||
<AddRowBtn
|
||
onClick={() =>
|
||
onChange({
|
||
...data,
|
||
tide: [
|
||
...data.tide,
|
||
{
|
||
date: '',
|
||
tideType: '',
|
||
lowTide1: '',
|
||
highTide1: '',
|
||
lowTide2: '',
|
||
highTide2: '',
|
||
},
|
||
],
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
|
||
<div style={S.subHeader}>기상 정보</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>기상 예측시간</th>
|
||
<th style={S.th}>일출</th>
|
||
<th style={S.th}>일몰</th>
|
||
<th style={S.th}>풍향</th>
|
||
<th style={S.th}>풍속(m/s)</th>
|
||
<th style={S.th}>유향</th>
|
||
<th style={S.th}>유속(knot/m/s)</th>
|
||
<th style={S.th}>파고(m)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.weather.map((w, i) => (
|
||
<tr key={i}>
|
||
<ECell value={w.time} editing={editing} onChange={(v) => setWeather(i, 'time', v)} />
|
||
<ECell
|
||
value={w.sunrise}
|
||
editing={editing}
|
||
onChange={(v) => setWeather(i, 'sunrise', v)}
|
||
/>
|
||
<ECell
|
||
value={w.sunset}
|
||
editing={editing}
|
||
onChange={(v) => setWeather(i, 'sunset', v)}
|
||
/>
|
||
<ECell
|
||
value={w.windDir}
|
||
editing={editing}
|
||
onChange={(v) => setWeather(i, 'windDir', v)}
|
||
/>
|
||
<ECell
|
||
value={w.windSpeed}
|
||
editing={editing}
|
||
onChange={(v) => setWeather(i, 'windSpeed', v)}
|
||
/>
|
||
<ECell
|
||
value={w.currentDir}
|
||
editing={editing}
|
||
onChange={(v) => setWeather(i, 'currentDir', v)}
|
||
/>
|
||
<ECell
|
||
value={w.currentSpeed}
|
||
editing={editing}
|
||
onChange={(v) => setWeather(i, 'currentSpeed', v)}
|
||
/>
|
||
<ECell
|
||
value={w.waveHeight}
|
||
editing={editing}
|
||
onChange={(v) => setWeather(i, 'waveHeight', v)}
|
||
/>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{editing && (
|
||
<AddRowBtn
|
||
onClick={() =>
|
||
onChange({
|
||
...data,
|
||
weather: [
|
||
...data.weather,
|
||
{
|
||
time: '',
|
||
sunrise: '',
|
||
sunset: '',
|
||
windDir: '',
|
||
windSpeed: '',
|
||
currentDir: '',
|
||
currentSpeed: '',
|
||
waveHeight: '',
|
||
},
|
||
],
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
|
||
<div style={S.sectionTitle}>3. 유출유 확산예측</div>
|
||
<div className="flex gap-4 mb-4">
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{data.step3MapImage ? (
|
||
<img
|
||
src={data.step3MapImage}
|
||
alt="확산예측 3시간 지도"
|
||
style={{
|
||
width: '100%',
|
||
height: 'auto',
|
||
display: 'block',
|
||
borderRadius: '4px',
|
||
border: '1px solid var(--stroke-default)',
|
||
}}
|
||
/>
|
||
) : (
|
||
<div style={S.mapPlaceholder}>확산예측 3시간 지도</div>
|
||
)}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{data.step6MapImage ? (
|
||
<img
|
||
src={data.step6MapImage}
|
||
alt="확산예측 6시간 지도"
|
||
style={{
|
||
width: '100%',
|
||
height: 'auto',
|
||
display: 'block',
|
||
borderRadius: '4px',
|
||
border: '1px solid var(--stroke-default)',
|
||
}}
|
||
/>
|
||
) : (
|
||
<div style={S.mapPlaceholder}>확산예측 6시간 지도</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div style={S.subHeader}>시간별 상세정보</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>경과시간</th>
|
||
<th style={S.th}>풍화량(kl)</th>
|
||
<th style={S.th}>해상잔존량(kl)</th>
|
||
<th style={S.th}>연안부착량(kl)</th>
|
||
<th style={S.th}>오염해역면적(km²)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.spread.map((s, i) => (
|
||
<tr key={i}>
|
||
<ECell
|
||
value={s.elapsed}
|
||
editing={editing}
|
||
onChange={(v) => setSpread(i, 'elapsed', v)}
|
||
/>
|
||
<ECell
|
||
value={s.weathered}
|
||
editing={editing}
|
||
onChange={(v) => setSpread(i, 'weathered', v)}
|
||
/>
|
||
<ECell
|
||
value={s.seaRemain}
|
||
editing={editing}
|
||
onChange={(v) => setSpread(i, 'seaRemain', v)}
|
||
/>
|
||
<ECell
|
||
value={s.coastAttach}
|
||
editing={editing}
|
||
onChange={(v) => setSpread(i, 'coastAttach', v)}
|
||
/>
|
||
<ECell value={s.area} editing={editing} onChange={(v) => setSpread(i, 'area', v)} />
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{editing && (
|
||
<AddRowBtn
|
||
onClick={() =>
|
||
onChange({
|
||
...data,
|
||
spread: [
|
||
...data.spread,
|
||
{ elapsed: '', weathered: '', seaRemain: '', coastAttach: '', area: '' },
|
||
],
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Page3({
|
||
data,
|
||
editing,
|
||
onChange,
|
||
}: {
|
||
data: OilSpillReportData;
|
||
editing: boolean;
|
||
onChange: (d: OilSpillReportData) => void;
|
||
}) {
|
||
return (
|
||
<div style={S.page}>
|
||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||
해양오염방제지원시스템
|
||
</div>
|
||
<div style={S.sectionTitle}>분석</div>
|
||
{editing ? (
|
||
<textarea
|
||
value={data.analysis}
|
||
onChange={(e) => onChange({ ...data, analysis: e.target.value })}
|
||
placeholder="분석 내용을 작성하세요..."
|
||
style={{
|
||
width: '100%',
|
||
minHeight: '300px',
|
||
background: 'var(--bg-base)',
|
||
border: '1px solid var(--stroke-light)',
|
||
borderRadius: '4px',
|
||
padding: '16px',
|
||
fontSize: '13px',
|
||
outline: 'none',
|
||
resize: 'vertical',
|
||
lineHeight: '1.8',
|
||
}}
|
||
/>
|
||
) : (
|
||
<div
|
||
style={{
|
||
minHeight: '300px',
|
||
border: data.analysis
|
||
? '1px solid var(--stroke-default)'
|
||
: '2px dashed var(--stroke-default)',
|
||
borderRadius: '4px',
|
||
padding: '16px',
|
||
color: data.analysis ? 'var(--fg-default)' : 'var(--fg-disabled)',
|
||
fontStyle: data.analysis ? 'normal' : 'italic',
|
||
fontSize: '13px',
|
||
whiteSpace: 'pre-wrap',
|
||
lineHeight: '1.8',
|
||
}}
|
||
>
|
||
{data.analysis || '분석 내용이 없습니다'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const getSeasonKey = (occurTime: string): 'SP_G' | 'SM_G' | 'FA_G' | 'WT_G' => {
|
||
const m = occurTime.match(/\d{4}[.\-\s]+(\d{1,2})[.\-\s]/);
|
||
const month = m ? parseInt(m[1]) : 0;
|
||
if (month >= 3 && month <= 5) return 'SP_G';
|
||
if (month >= 6 && month <= 8) return 'SM_G';
|
||
if (month >= 9 && month <= 11) return 'FA_G';
|
||
return 'WT_G';
|
||
};
|
||
|
||
const parseCoord = (s: string): number | null => {
|
||
const d = parseFloat(s);
|
||
if (!isNaN(d)) return d;
|
||
const m = s.match(/(\d+)[°]\s*(\d+)[′']\s*([\d.]+)[″"]/);
|
||
if (m) return Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600;
|
||
return null;
|
||
};
|
||
|
||
const haversineKm = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
|
||
const R = 6371;
|
||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||
const a =
|
||
Math.sin(dLat / 2) ** 2 +
|
||
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
|
||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
};
|
||
|
||
const getGeomCentroid = (geom: { type: string; coordinates: unknown }): [number, number] | null => {
|
||
if (geom.type === 'Point') return geom.coordinates as [number, number];
|
||
if (geom.type === 'Polygon') {
|
||
const pts = (geom.coordinates as [number, number][][])[0];
|
||
const avg = pts.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0]);
|
||
return [avg[0] / pts.length, avg[1] / pts.length];
|
||
}
|
||
if (geom.type === 'MultiPolygon') {
|
||
const all = (geom.coordinates as [number, number][][][]).flatMap((p) => p[0]);
|
||
const avg = all.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0]);
|
||
return [avg[0] / all.length, avg[1] / all.length];
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const CATEGORY_COLORS = [
|
||
{ keywords: ['어장', '양식'], color: '#f97316', label: '어장/양식장' },
|
||
{ keywords: ['해수욕'], color: '#3b82f6', label: '해수욕장' },
|
||
{ keywords: ['수산시장', '어시장'], color: '#a855f7', label: '수산시장' },
|
||
{ keywords: ['갯벌'], color: '#92400e', label: '갯벌' },
|
||
{ keywords: ['서식지'], color: '#16a34a', label: '서식지' },
|
||
{
|
||
keywords: ['보호종', '생물', '조류', '포유', '파충', '양서'],
|
||
color: '#ec4899',
|
||
label: '보호종/생물종',
|
||
},
|
||
] as const;
|
||
|
||
const getCategoryColor = (category: string): string => {
|
||
for (const { keywords, color } of CATEGORY_COLORS) {
|
||
if ((keywords as readonly string[]).some((kw) => category.includes(kw))) return color;
|
||
}
|
||
return '#06b6d4';
|
||
};
|
||
|
||
function SensitiveResourceMapSection({
|
||
data,
|
||
editing,
|
||
onChange,
|
||
}: {
|
||
data: OilSpillReportData;
|
||
editing: boolean;
|
||
onChange: (d: OilSpillReportData) => void;
|
||
}) {
|
||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||
const [mapVisible, setMapVisible] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [capturing, setCapturing] = useState(false);
|
||
const [legendLabels, setLegendLabels] = useState<string[]>([]);
|
||
const acdntSn = data.acdntSn;
|
||
|
||
const RELEVANT_KEYWORDS = [
|
||
'양식',
|
||
'어장',
|
||
'해수욕',
|
||
'수산시장',
|
||
'어시장',
|
||
'갯벌',
|
||
'서식지',
|
||
'보호종',
|
||
'생물',
|
||
'조류',
|
||
'포유',
|
||
'파충',
|
||
];
|
||
|
||
const handleLoad = async () => {
|
||
if (!acdntSn) return;
|
||
setLoading(true);
|
||
try {
|
||
const [geojson, particlesGeojson] = await Promise.all([
|
||
fetchSensitiveResourcesGeojson(acdntSn),
|
||
fetchPredictionParticlesGeojson(acdntSn),
|
||
]);
|
||
// 관련 카테고리만 필터링 + 카테고리별 색상 추가
|
||
const filteredGeojson = {
|
||
...geojson,
|
||
features: geojson.features
|
||
.filter((f) => {
|
||
const cat = (f.properties as { category?: string })?.category ?? '';
|
||
return RELEVANT_KEYWORDS.some((kw) => cat.includes(kw));
|
||
})
|
||
.map((f) => ({
|
||
...f,
|
||
properties: {
|
||
...f.properties,
|
||
_color: getCategoryColor((f.properties as { category?: string })?.category ?? ''),
|
||
},
|
||
})),
|
||
};
|
||
// 실제 존재하는 카테고리 라벨만 범례에 표시
|
||
const presentColors = new Set(
|
||
filteredGeojson.features.map((f) => (f.properties as { _color: string })._color),
|
||
);
|
||
setLegendLabels(
|
||
CATEGORY_COLORS.filter((c) => presentColors.has(c.color)).map((c) => c.label),
|
||
);
|
||
// 민감자원 GeoJSON → 6개 섹션 자동 채우기
|
||
const HABITAT_TYPES = [
|
||
'갯벌',
|
||
'해조류서식지',
|
||
'바다목장',
|
||
'바다숲',
|
||
'산호류서식지',
|
||
'인공어초',
|
||
'해초류서식지',
|
||
];
|
||
const incLat = parseCoord(data.incident.lat);
|
||
const incLon = parseCoord(data.incident.lon);
|
||
const calcDist = (geom: { type: string; coordinates: unknown }) => {
|
||
if (incLat == null || incLon == null) return '';
|
||
const centroid = getGeomCentroid(geom);
|
||
return centroid ? haversineKm(incLat, incLon, centroid[1], centroid[0]).toFixed(2) : '';
|
||
};
|
||
|
||
// aquaculture (어장)
|
||
const aquacultureRows = geojson.features
|
||
.filter((f) => ((f.properties as { category?: string })?.category ?? '').includes('어장'))
|
||
.sort((a, b) => {
|
||
const aNo = String((a.properties as Record<string, unknown>)['lcns_no'] ?? '');
|
||
const bNo = String((b.properties as Record<string, unknown>)['lcns_no'] ?? '');
|
||
return aNo.localeCompare(bNo, 'ko', { numeric: true });
|
||
})
|
||
.map((f) => {
|
||
const p = f.properties as Record<string, unknown>;
|
||
const lcnsNo = String(p['lcns_no'] ?? '');
|
||
const fidsKnd = String(p['fids_knd'] ?? '');
|
||
const farmKnd = String(p['farm_knd'] ?? '');
|
||
const parts = [lcnsNo, fidsKnd, farmKnd].filter(Boolean);
|
||
return {
|
||
type: parts.join('_'),
|
||
area: p['area'] != null ? Number(p['area']).toFixed(2) : '',
|
||
distance: calcDist(f.geometry as { type: string; coordinates: unknown }),
|
||
};
|
||
});
|
||
|
||
// beaches (해수욕장)
|
||
const beachRows = geojson.features
|
||
.filter((f) => ((f.properties as { category?: string })?.category ?? '').includes('해수욕'))
|
||
.map((f) => {
|
||
const p = f.properties as Record<string, unknown>;
|
||
return {
|
||
name: String(p['beach_nm'] ?? p['name'] ?? p['nm'] ?? ''),
|
||
distance: calcDist(f.geometry as { type: string; coordinates: unknown }),
|
||
};
|
||
});
|
||
|
||
// markets (수산시장·어시장)
|
||
const marketRows = geojson.features
|
||
.filter((f) => {
|
||
const cat = (f.properties as { category?: string })?.category ?? '';
|
||
return cat.includes('수산시장') || cat.includes('어시장');
|
||
})
|
||
.map((f) => {
|
||
const p = f.properties as Record<string, unknown>;
|
||
return {
|
||
name: String(p['name'] ?? p['market_nm'] ?? p['nm'] ?? ''),
|
||
distance: calcDist(f.geometry as { type: string; coordinates: unknown }),
|
||
};
|
||
});
|
||
|
||
// esi (해안선) — 기존 10개 행의 length만 갱신
|
||
const esiFeatures = geojson.features.filter((f) => {
|
||
const cat = (f.properties as { category?: string })?.category ?? '';
|
||
return cat.includes('해안선') || cat.includes('ESI');
|
||
});
|
||
const esiLengthMap: Record<string, string> = {};
|
||
esiFeatures.forEach((f) => {
|
||
const p = f.properties as Record<string, unknown>;
|
||
const code = String(p['esi_cd'] ?? '');
|
||
const len = p['length_km'] ?? p['len_km'];
|
||
if (code && len != null) esiLengthMap[code] = `${Number(len).toFixed(2)} km`;
|
||
});
|
||
const esiRows =
|
||
esiFeatures.length > 0
|
||
? data.esi.map((row) => ({
|
||
...row,
|
||
length:
|
||
esiLengthMap[row.code.replace('ESI ', '')] ?? esiLengthMap[row.code] ?? row.length,
|
||
}))
|
||
: data.esi;
|
||
|
||
// species (보호종·생물종) — 3개 고정 행 유지, species 컬럼만 갱신
|
||
const SPECIES_MAP: Record<string, string[]> = {
|
||
양서파충류: ['파충', '양서'],
|
||
조류: ['조류'],
|
||
포유류: ['포유'],
|
||
};
|
||
const speciesCollected: Record<string, string[]> = { 양서파충류: [], 조류: [], 포유류: [] };
|
||
geojson.features.forEach((f) => {
|
||
const cat = (f.properties as { category?: string })?.category ?? '';
|
||
const p = f.properties as Record<string, unknown>;
|
||
const nm = String(p['name'] ?? p['species_nm'] ?? '');
|
||
if (!nm) return;
|
||
for (const [row, kws] of Object.entries(SPECIES_MAP)) {
|
||
if (kws.some((kw) => cat.includes(kw))) {
|
||
speciesCollected[row].push(nm);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
const hasSpecies = Object.values(speciesCollected).some((arr) => arr.length > 0);
|
||
const speciesRows = hasSpecies
|
||
? data.species.map((row) => ({
|
||
...row,
|
||
species: speciesCollected[row.category]?.join(', ') ?? row.species,
|
||
}))
|
||
: data.species;
|
||
|
||
// habitat (서식지) — 타입별 면적 합산
|
||
const habitatFeatures = geojson.features.filter((f) =>
|
||
HABITAT_TYPES.some((t) =>
|
||
((f.properties as { category?: string })?.category ?? '').includes(t),
|
||
),
|
||
);
|
||
const habitatMap: Record<string, number> = {};
|
||
habitatFeatures.forEach((f) => {
|
||
const cat = (f.properties as { category?: string })?.category ?? '';
|
||
const p = f.properties as Record<string, unknown>;
|
||
habitatMap[cat] = (habitatMap[cat] ?? 0) + (p['area'] != null ? Number(p['area']) : 0);
|
||
});
|
||
const habitatRows =
|
||
habitatFeatures.length > 0
|
||
? Object.entries(habitatMap).map(([type, area]) => ({
|
||
type,
|
||
area: area > 0 ? area.toFixed(2) : '',
|
||
}))
|
||
: data.habitat;
|
||
|
||
// 단일 onChange 일괄 업데이트
|
||
const updates: Partial<OilSpillReportData> = {};
|
||
if (aquacultureRows.length > 0) updates.aquaculture = aquacultureRows;
|
||
if (beachRows.length > 0) updates.beaches = beachRows;
|
||
if (marketRows.length > 0) updates.markets = marketRows;
|
||
if (esiFeatures.length > 0) updates.esi = esiRows;
|
||
if (hasSpecies) updates.species = speciesRows;
|
||
if (habitatFeatures.length > 0) updates.habitat = habitatRows;
|
||
if (Object.keys(updates).length > 0) onChange({ ...data, ...updates });
|
||
|
||
setMapVisible(true);
|
||
// 다음 렌더 사이클 후 지도 초기화
|
||
setTimeout(() => {
|
||
if (!mapContainerRef.current) return;
|
||
const map = new maplibregl.Map({
|
||
container: mapContainerRef.current,
|
||
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||
center: [127.5, 35.5],
|
||
zoom: 8,
|
||
preserveDrawingBuffer: true,
|
||
});
|
||
mapRef.current = map;
|
||
map.on('load', () => {
|
||
// 확산 파티클 — sensitive 레이어 아래 (예측 탭과 동일한 색상 로직)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
map.addSource('particles', { type: 'geojson', data: particlesGeojson as any });
|
||
// 과거 스텝: 회색 반투명
|
||
map.addLayer({
|
||
id: 'particles-past',
|
||
type: 'circle',
|
||
source: 'particles',
|
||
filter: ['==', ['get', 'isLastStep'], false],
|
||
paint: { 'circle-radius': 2.5, 'circle-color': '#828282', 'circle-opacity': 0.4 },
|
||
});
|
||
// 최신 스텝: 모델별 색상 (OpenDrift=파랑, POSEIDON=빨강)
|
||
map.addLayer({
|
||
id: 'particles-current',
|
||
type: 'circle',
|
||
source: 'particles',
|
||
filter: ['==', ['get', 'isLastStep'], true],
|
||
paint: {
|
||
'circle-radius': 3,
|
||
'circle-color': [
|
||
'case',
|
||
['==', ['get', 'model'], 'OpenDrift'],
|
||
'#3b82f6',
|
||
['==', ['get', 'model'], 'POSEIDON'],
|
||
'#ef4444',
|
||
'#06b6d4',
|
||
],
|
||
'circle-opacity': 0.85,
|
||
},
|
||
});
|
||
// 민감자원 레이어
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
map.addSource('sensitive', { type: 'geojson', data: filteredGeojson as any });
|
||
map.addLayer({
|
||
id: 'sensitive-fill',
|
||
type: 'fill',
|
||
source: 'sensitive',
|
||
filter: ['==', '$type', 'Polygon'],
|
||
paint: { 'fill-color': ['get', '_color'], 'fill-opacity': 0.4 },
|
||
});
|
||
map.addLayer({
|
||
id: 'sensitive-line',
|
||
type: 'line',
|
||
source: 'sensitive',
|
||
filter: ['==', '$type', 'Polygon'],
|
||
paint: { 'line-color': ['get', '_color'], 'line-width': 1.5 },
|
||
});
|
||
map.addLayer({
|
||
id: 'sensitive-circle',
|
||
type: 'circle',
|
||
source: 'sensitive',
|
||
filter: ['==', '$type', 'Point'],
|
||
paint: {
|
||
'circle-radius': 6,
|
||
'circle-color': ['get', '_color'],
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': '#fff',
|
||
},
|
||
});
|
||
// fit bounds — spread + sensitive 합산
|
||
const coords: [number, number][] = [];
|
||
const collectCoords = (geom: { type: string; coordinates: unknown }) => {
|
||
if (geom.type === 'Point') {
|
||
coords.push(geom.coordinates as [number, number]);
|
||
} else if (geom.type === 'Polygon') {
|
||
const rings = geom.coordinates as [number, number][][];
|
||
rings[0]?.forEach((c) => coords.push(c));
|
||
} else if (geom.type === 'MultiPolygon') {
|
||
const polys = geom.coordinates as [number, number][][][];
|
||
polys.forEach((rings) => rings[0]?.forEach((c) => coords.push(c)));
|
||
}
|
||
};
|
||
filteredGeojson.features.forEach((f) =>
|
||
collectCoords(f.geometry as { type: string; coordinates: unknown }),
|
||
);
|
||
particlesGeojson.features.forEach((f) => {
|
||
coords.push(f.geometry.coordinates as [number, number]);
|
||
});
|
||
if (coords.length > 0) {
|
||
const lngs = coords.map((c) => c[0]);
|
||
const lats = coords.map((c) => c[1]);
|
||
map.fitBounds(
|
||
[
|
||
[Math.min(...lngs), Math.min(...lats)],
|
||
[Math.max(...lngs), Math.max(...lats)],
|
||
],
|
||
{ padding: 60, maxZoom: 13 },
|
||
);
|
||
}
|
||
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
|
||
if (incLat != null && incLon != null) {
|
||
map.addSource('incident-point', {
|
||
type: 'geojson',
|
||
data: {
|
||
type: 'Feature',
|
||
geometry: { type: 'Point', coordinates: [incLon, incLat] },
|
||
properties: {},
|
||
},
|
||
});
|
||
map.addLayer({
|
||
id: 'incident-circle',
|
||
type: 'circle',
|
||
source: 'incident-point',
|
||
paint: {
|
||
'circle-radius': 7,
|
||
'circle-color': '#ef4444',
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': '#ffffff',
|
||
},
|
||
});
|
||
map.addLayer({
|
||
id: 'incident-label',
|
||
type: 'symbol',
|
||
source: 'incident-point',
|
||
layout: {
|
||
'text-field': '사고위치',
|
||
'text-size': 11,
|
||
'text-offset': [0, 1.6],
|
||
'text-anchor': 'top',
|
||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||
},
|
||
paint: {
|
||
'text-color': '#ffffff',
|
||
'text-halo-color': '#ef4444',
|
||
'text-halo-width': 3,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
}, 100);
|
||
} catch {
|
||
alert('민감자원 지도 데이터를 불러오지 못했습니다.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCapture = () => {
|
||
const map = mapRef.current;
|
||
if (!map) return;
|
||
setCapturing(true);
|
||
map.once('idle', () => {
|
||
const mapCanvas = map.getCanvas();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const W = mapCanvas.width;
|
||
const H = mapCanvas.height;
|
||
|
||
const composite = document.createElement('canvas');
|
||
composite.width = W;
|
||
composite.height = H;
|
||
const ctx = composite.getContext('2d')!;
|
||
|
||
// 지도 그리기
|
||
ctx.drawImage(mapCanvas, 0, 0);
|
||
|
||
// 범례 그리기
|
||
const items = CATEGORY_COLORS.filter((c) => legendLabels.includes(c.label));
|
||
if (items.length > 0) {
|
||
const pad = 8 * dpr;
|
||
const swSize = 12 * dpr;
|
||
const lineH = 18 * dpr;
|
||
const fontSize = 11 * dpr;
|
||
const boxW = 130 * dpr;
|
||
const boxH = pad * 2 + items.length * lineH;
|
||
const lx = pad,
|
||
ly = pad;
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.88)';
|
||
ctx.fillRect(lx, ly, boxW, boxH);
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
|
||
ctx.lineWidth = dpr;
|
||
ctx.strokeRect(lx, ly, boxW, boxH);
|
||
|
||
items.forEach(({ color, label }, i) => {
|
||
const iy = ly + pad + i * lineH;
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(lx + pad, iy + (lineH - swSize) / 2, swSize, swSize);
|
||
ctx.fillStyle = '#1f2937';
|
||
ctx.font = `${fontSize}px sans-serif`;
|
||
ctx.fillText(label, lx + pad + swSize + 4 * dpr, iy + lineH / 2 + fontSize * 0.35);
|
||
});
|
||
}
|
||
|
||
onChange({ ...data, sensitiveMapImage: composite.toDataURL('image/png') });
|
||
setCapturing(false);
|
||
});
|
||
map.triggerRepaint();
|
||
};
|
||
|
||
const handleReset = () => {
|
||
if (mapRef.current) {
|
||
mapRef.current.remove();
|
||
mapRef.current = null;
|
||
}
|
||
setMapVisible(false);
|
||
setLegendLabels([]);
|
||
onChange({ ...data, sensitiveMapImage: undefined });
|
||
};
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
mapRef.current?.remove();
|
||
};
|
||
}, []);
|
||
|
||
// 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더
|
||
if (!editing) {
|
||
if (data.sensitiveMapImage) {
|
||
return (
|
||
<img
|
||
src={data.sensitiveMapImage}
|
||
alt="민감자원 분포 지도"
|
||
style={{
|
||
width: '100%',
|
||
height: 'auto',
|
||
display: 'block',
|
||
border: '1px solid #ddd',
|
||
borderRadius: 4,
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return <div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>;
|
||
}
|
||
|
||
// 편집 모드: acdntSn 없음
|
||
if (!acdntSn) {
|
||
return <div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>;
|
||
}
|
||
|
||
// 편집 모드: 캡처 이미지 있음
|
||
if (data.sensitiveMapImage) {
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
<img
|
||
src={data.sensitiveMapImage}
|
||
alt="민감자원 분포 지도"
|
||
style={{
|
||
width: '100%',
|
||
height: 'auto',
|
||
display: 'block',
|
||
border: '1px solid #ddd',
|
||
borderRadius: 4,
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={handleReset}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 8,
|
||
right: 8,
|
||
background: 'rgba(0,0,0,0.6)',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
padding: '3px 8px',
|
||
fontSize: 11,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
재캡처
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 편집 모드: 지도 로드/캡처
|
||
return (
|
||
<div>
|
||
{!mapVisible ? (
|
||
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
|
||
<span>민감자원 분포(10km 내) 지도</span>
|
||
<button
|
||
onClick={handleLoad}
|
||
disabled={loading}
|
||
style={{
|
||
background: '#0891b2',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
padding: '5px 14px',
|
||
fontSize: 12,
|
||
cursor: loading ? 'not-allowed' : 'pointer',
|
||
opacity: loading ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{loading ? '불러오는 중...' : '지도 불러오기'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ position: 'relative' }}>
|
||
<div
|
||
ref={mapContainerRef}
|
||
style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }}
|
||
/>
|
||
{legendLabels.length > 0 && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: 8,
|
||
left: 8,
|
||
background: 'rgba(255,255,255,0.88)',
|
||
border: '1px solid rgba(0,0,0,0.15)',
|
||
borderRadius: 4,
|
||
padding: '6px 10px',
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
{CATEGORY_COLORS.filter((c) => legendLabels.includes(c.label)).map(
|
||
({ color, label }) => (
|
||
<div
|
||
key={label}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
marginBottom: 3,
|
||
fontSize: 11,
|
||
color: '#1f2937',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
<div style={{ width: 12, height: 12, background: color, flexShrink: 0 }} />
|
||
{label}
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
|
||
<button
|
||
onClick={handleReset}
|
||
style={{
|
||
background: 'transparent',
|
||
color: '#6b7280',
|
||
border: '1px solid #374151',
|
||
borderRadius: 4,
|
||
padding: '3px 10px',
|
||
fontSize: 11,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
취소
|
||
</button>
|
||
<button
|
||
onClick={handleCapture}
|
||
disabled={capturing}
|
||
style={{
|
||
background: '#0891b2',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
padding: '3px 12px',
|
||
fontSize: 11,
|
||
cursor: capturing ? 'not-allowed' : 'pointer',
|
||
opacity: capturing ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Page4({
|
||
data,
|
||
editing,
|
||
onChange,
|
||
}: {
|
||
data: OilSpillReportData;
|
||
editing: boolean;
|
||
onChange: (d: OilSpillReportData) => void;
|
||
}) {
|
||
const setArr = <T extends Record<string, string>>(
|
||
key: keyof OilSpillReportData,
|
||
arr: T[],
|
||
i: number,
|
||
k: string,
|
||
v: string,
|
||
) => {
|
||
const copy = [...arr];
|
||
copy[i] = { ...copy[i], [k]: v };
|
||
onChange({ ...data, [key]: copy });
|
||
};
|
||
|
||
return (
|
||
<div style={S.page}>
|
||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||
해양오염방제지원시스템
|
||
</div>
|
||
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
||
<SensitiveResourceMapSection data={data} editing={editing} onChange={onChange} />
|
||
|
||
<div style={{ ...S.subHeader, marginTop: '16px' }}>양식장 분포</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>구분</th>
|
||
<th style={S.th}>면적(ha)</th>
|
||
<th style={S.th}>사고지점과의 거리(km)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.aquaculture.map((a, i) => (
|
||
<tr key={i}>
|
||
<ECell
|
||
value={a.type}
|
||
editing={editing}
|
||
onChange={(v) => setArr('aquaculture', data.aquaculture, i, 'type', v)}
|
||
placeholder="-"
|
||
/>
|
||
<ECell
|
||
value={a.area}
|
||
editing={editing}
|
||
onChange={(v) => setArr('aquaculture', data.aquaculture, i, 'area', v)}
|
||
/>
|
||
<ECell
|
||
value={a.distance}
|
||
editing={editing}
|
||
onChange={(v) => setArr('aquaculture', data.aquaculture, i, 'distance', v)}
|
||
/>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{editing && (
|
||
<AddRowBtn
|
||
onClick={() =>
|
||
onChange({
|
||
...data,
|
||
aquaculture: [...data.aquaculture, { type: '', area: '', distance: '' }],
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-5">
|
||
<div>
|
||
<div style={S.subHeader}>해수욕장 분포</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>구분</th>
|
||
<th style={S.th}>이격거리(km)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.beaches.map((b, i) => (
|
||
<tr key={i}>
|
||
<ECell
|
||
value={b.name}
|
||
editing={editing}
|
||
onChange={(v) => setArr('beaches', data.beaches, i, 'name', v)}
|
||
align="left"
|
||
/>
|
||
<ECell
|
||
value={b.distance}
|
||
editing={editing}
|
||
onChange={(v) => setArr('beaches', data.beaches, i, 'distance', v)}
|
||
/>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{editing && (
|
||
<AddRowBtn
|
||
onClick={() =>
|
||
onChange({ ...data, beaches: [...data.beaches, { name: '', distance: '' }] })
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<div style={S.subHeader}>수산시장 분포</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>구분</th>
|
||
<th style={S.th}>이격거리(km)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.markets.map((m, i) => (
|
||
<tr key={i}>
|
||
<ECell
|
||
value={m.name}
|
||
editing={editing}
|
||
onChange={(v) => setArr('markets', data.markets, i, 'name', v)}
|
||
align="left"
|
||
/>
|
||
<ECell
|
||
value={m.distance}
|
||
editing={editing}
|
||
onChange={(v) => setArr('markets', data.markets, i, 'distance', v)}
|
||
/>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{editing && (
|
||
<AddRowBtn
|
||
onClick={() =>
|
||
onChange({ ...data, markets: [...data.markets, { name: '', distance: '' }] })
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-5 mt-2">
|
||
<div>
|
||
<div style={S.subHeader}>해안선(ESI) 분포</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>구분</th>
|
||
<th style={S.th}>유형</th>
|
||
<th style={S.th}>분포 길이</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.esi.map((e, i) => (
|
||
<tr key={i}>
|
||
<td style={{ ...S.td, fontWeight: 600, fontSize: '10px' }}>{e.code}</td>
|
||
<td style={S.tdLeft}>{e.type}</td>
|
||
<ECell
|
||
value={e.length}
|
||
editing={editing}
|
||
onChange={(v) => setArr('esi', data.esi, i, 'length', v)}
|
||
/>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div>
|
||
<div style={S.subHeader}>생물종(보호종) 분포</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>구분</th>
|
||
<th style={S.th}>종</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.species.map((s, i) => (
|
||
<tr key={i}>
|
||
<td style={{ ...S.td, fontWeight: 600 }}>{s.category}</td>
|
||
<ECell
|
||
value={s.species}
|
||
editing={editing}
|
||
onChange={(v) => setArr('species', data.species, i, 'species', v)}
|
||
align="left"
|
||
placeholder="-"
|
||
/>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<div style={{ ...S.subHeader, marginTop: '16px' }}>서식지 분포</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>구분</th>
|
||
<th style={S.th}>분포넓이</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.habitat.map((h, i) => (
|
||
<tr key={i}>
|
||
<ECell
|
||
value={h.type}
|
||
editing={editing}
|
||
onChange={(v) => setArr('habitat', data.habitat, i, 'type', v)}
|
||
/>
|
||
<ECell
|
||
value={h.area}
|
||
editing={editing}
|
||
onChange={(v) => setArr('habitat', data.habitat, i, 'area', v)}
|
||
/>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const SENS_LEVELS = [
|
||
{ key: 5, level: '매우 높음', color: '#ef4444' },
|
||
{ key: 4, level: '높음', color: '#f97316' },
|
||
{ key: 3, level: '보통', color: '#eab308' },
|
||
{ key: 2, level: '낮음', color: '#22c55e' },
|
||
];
|
||
|
||
function SensitivityMapSection({
|
||
data,
|
||
editing,
|
||
onChange,
|
||
}: {
|
||
data: OilSpillReportData;
|
||
editing: boolean;
|
||
onChange: (d: OilSpillReportData) => void;
|
||
}) {
|
||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||
const [mapVisible, setMapVisible] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [capturing, setCapturing] = useState(false);
|
||
const acdntSn = data.acdntSn;
|
||
|
||
const handleLoad = async () => {
|
||
if (!acdntSn) return;
|
||
setLoading(true);
|
||
try {
|
||
const geojson = await fetchSensitivityEvaluationGeojson(acdntSn);
|
||
const seasonKey = getSeasonKey(data.incident.occurTime);
|
||
|
||
// 레벨 계산 및 면적 집계 (레벨 1 제외)
|
||
const areaByLevel: Record<number, number> = {};
|
||
geojson.features.forEach((f) => {
|
||
const p = (f as { properties: Record<string, unknown> }).properties;
|
||
const lvl = Number(p[seasonKey] ?? 1);
|
||
if (lvl >= 2) areaByLevel[lvl] = (areaByLevel[lvl] ?? 0) + Number(p['area_km2'] ?? 0);
|
||
});
|
||
const sensitivityRows = SENS_LEVELS.map((s) => ({
|
||
level: s.level,
|
||
color: s.color,
|
||
area: areaByLevel[s.key] != null ? areaByLevel[s.key].toFixed(2) : '',
|
||
}));
|
||
onChange({ ...data, sensitivity: sensitivityRows });
|
||
|
||
// 레벨 속성 추가 + 레벨 1 제외한 표시용 GeoJSON
|
||
const displayGeojson = {
|
||
...geojson,
|
||
features: geojson.features
|
||
.map((f) => {
|
||
const p = (f as { properties: Record<string, unknown> }).properties;
|
||
return { ...(f as object), properties: { ...p, level: Number(p[seasonKey] ?? 1) } };
|
||
})
|
||
.filter((f) => (f as { properties: { level: number } }).properties.level >= 2),
|
||
};
|
||
|
||
setMapVisible(true);
|
||
setTimeout(() => {
|
||
if (!mapContainerRef.current) return;
|
||
const map = new maplibregl.Map({
|
||
container: mapContainerRef.current,
|
||
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||
center: [127.5, 35.5],
|
||
zoom: 8,
|
||
preserveDrawingBuffer: true,
|
||
});
|
||
mapRef.current = map;
|
||
map.on('load', () => {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
map.addSource('sensitivity', { type: 'geojson', data: displayGeojson as any });
|
||
const colorExpr: maplibregl.ExpressionSpecification = [
|
||
'case',
|
||
['==', ['get', 'level'], 5],
|
||
'#ef4444',
|
||
['==', ['get', 'level'], 4],
|
||
'#f97316',
|
||
['==', ['get', 'level'], 3],
|
||
'#eab308',
|
||
['==', ['get', 'level'], 2],
|
||
'#22c55e',
|
||
'transparent',
|
||
];
|
||
map.addLayer({
|
||
id: 'sensitivity-fill',
|
||
type: 'fill',
|
||
source: 'sensitivity',
|
||
filter: ['==', '$type', 'Polygon'],
|
||
paint: { 'fill-color': colorExpr, 'fill-opacity': 0.6 },
|
||
});
|
||
map.addLayer({
|
||
id: 'sensitivity-line',
|
||
type: 'line',
|
||
source: 'sensitivity',
|
||
filter: ['==', '$type', 'Polygon'],
|
||
paint: { 'line-color': colorExpr, 'line-width': 0.5, 'line-opacity': 0.4 },
|
||
});
|
||
map.addLayer({
|
||
id: 'sensitivity-circle',
|
||
type: 'circle',
|
||
source: 'sensitivity',
|
||
filter: ['==', '$type', 'Point'],
|
||
paint: { 'circle-radius': 5, 'circle-color': colorExpr },
|
||
});
|
||
|
||
// fitBounds
|
||
const coords: [number, number][] = [];
|
||
displayGeojson.features.forEach((f) => {
|
||
const geom = (f as { geometry: { type: string; coordinates: unknown } }).geometry;
|
||
if (geom.type === 'Point') coords.push(geom.coordinates as [number, number]);
|
||
else if (geom.type === 'Polygon')
|
||
(geom.coordinates as [number, number][][])[0]?.forEach((c) => coords.push(c));
|
||
else if (geom.type === 'MultiPolygon')
|
||
(geom.coordinates as [number, number][][][]).forEach((rings) =>
|
||
rings[0]?.forEach((c) => coords.push(c)),
|
||
);
|
||
});
|
||
if (coords.length > 0) {
|
||
const lngs = coords.map((c) => c[0]);
|
||
const lats = coords.map((c) => c[1]);
|
||
map.fitBounds(
|
||
[
|
||
[Math.min(...lngs), Math.min(...lats)],
|
||
[Math.max(...lngs), Math.max(...lats)],
|
||
],
|
||
{ padding: 60, maxZoom: 13 },
|
||
);
|
||
}
|
||
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
|
||
const incLat = parseCoord(data.incident.lat);
|
||
const incLon = parseCoord(data.incident.lon);
|
||
if (incLat != null && incLon != null) {
|
||
map.addSource('incident-point', {
|
||
type: 'geojson',
|
||
data: {
|
||
type: 'Feature',
|
||
geometry: { type: 'Point', coordinates: [incLon, incLat] },
|
||
properties: {},
|
||
},
|
||
});
|
||
map.addLayer({
|
||
id: 'incident-circle',
|
||
type: 'circle',
|
||
source: 'incident-point',
|
||
paint: {
|
||
'circle-radius': 7,
|
||
'circle-color': '#ef4444',
|
||
'circle-stroke-width': 2,
|
||
'circle-stroke-color': '#ffffff',
|
||
},
|
||
});
|
||
map.addLayer({
|
||
id: 'incident-label',
|
||
type: 'symbol',
|
||
source: 'incident-point',
|
||
layout: {
|
||
'text-field': '사고위치',
|
||
'text-size': 11,
|
||
'text-offset': [0, 1.6],
|
||
'text-anchor': 'top',
|
||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||
},
|
||
paint: {
|
||
'text-color': '#ffffff',
|
||
'text-halo-color': '#ef4444',
|
||
'text-halo-width': 3,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
}, 100);
|
||
} catch {
|
||
alert('통합민감도 평가 데이터를 불러오지 못했습니다.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCapture = () => {
|
||
const map = mapRef.current;
|
||
if (!map) return;
|
||
setCapturing(true);
|
||
map.once('idle', () => {
|
||
const dataUrl = map.getCanvas().toDataURL('image/png');
|
||
onChange({ ...data, sensitivityMapImage: dataUrl });
|
||
setCapturing(false);
|
||
});
|
||
map.triggerRepaint();
|
||
};
|
||
|
||
const handleReset = () => {
|
||
if (mapRef.current) {
|
||
mapRef.current.remove();
|
||
mapRef.current = null;
|
||
}
|
||
setMapVisible(false);
|
||
onChange({ ...data, sensitivityMapImage: undefined });
|
||
};
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
mapRef.current?.remove();
|
||
};
|
||
}, []);
|
||
|
||
if (!editing) {
|
||
if (data.sensitivityMapImage) {
|
||
return (
|
||
<img
|
||
src={data.sensitivityMapImage}
|
||
alt="통합민감도 평가 지도"
|
||
style={{
|
||
width: '100%',
|
||
height: 'auto',
|
||
display: 'block',
|
||
border: '1px solid #ddd',
|
||
borderRadius: 4,
|
||
marginBottom: 16,
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return <div style={S.mapPlaceholder}>통합민감도 평가 지도</div>;
|
||
}
|
||
|
||
if (!acdntSn) return <div style={S.mapPlaceholder}>통합민감도 평가 지도</div>;
|
||
|
||
if (data.sensitivityMapImage) {
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
<img
|
||
src={data.sensitivityMapImage}
|
||
alt="통합민감도 평가 지도"
|
||
style={{
|
||
width: '100%',
|
||
height: 'auto',
|
||
display: 'block',
|
||
border: '1px solid #ddd',
|
||
borderRadius: 4,
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={handleReset}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 8,
|
||
right: 8,
|
||
background: 'rgba(0,0,0,0.6)',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
padding: '3px 8px',
|
||
fontSize: 11,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
재캡처
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
{!mapVisible ? (
|
||
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
|
||
<span>민감도 분포(10km내) 지도</span>
|
||
<button
|
||
onClick={handleLoad}
|
||
disabled={loading}
|
||
style={{
|
||
background: '#0891b2',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
padding: '5px 14px',
|
||
fontSize: 12,
|
||
cursor: loading ? 'not-allowed' : 'pointer',
|
||
opacity: loading ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{loading ? '불러오는 중...' : '지도 불러오기'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ position: 'relative' }}>
|
||
<div
|
||
ref={mapContainerRef}
|
||
style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
|
||
<button
|
||
onClick={handleReset}
|
||
style={{
|
||
background: 'transparent',
|
||
color: '#6b7280',
|
||
border: '1px solid #374151',
|
||
borderRadius: 4,
|
||
padding: '3px 10px',
|
||
fontSize: 11,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
취소
|
||
</button>
|
||
<button
|
||
onClick={handleCapture}
|
||
disabled={capturing}
|
||
style={{
|
||
background: '#0891b2',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
padding: '3px 12px',
|
||
fontSize: 11,
|
||
cursor: capturing ? 'not-allowed' : 'pointer',
|
||
opacity: capturing ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Page5({
|
||
data,
|
||
editing,
|
||
onChange,
|
||
}: {
|
||
data: OilSpillReportData;
|
||
editing: boolean;
|
||
onChange: (d: OilSpillReportData) => void;
|
||
}) {
|
||
const setSens = (i: number, v: string) => {
|
||
const s = [...data.sensitivity];
|
||
s[i] = { ...s[i], area: v };
|
||
onChange({ ...data, sensitivity: s });
|
||
};
|
||
return (
|
||
<div style={S.page}>
|
||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||
해양오염방제지원시스템
|
||
</div>
|
||
<div style={S.sectionTitle}>통합민감도 평가 (해당 계절)</div>
|
||
<SensitivityMapSection data={data} editing={editing} onChange={onChange} />
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>민감도</th>
|
||
<th style={S.th}>분포 면적(km²)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.sensitivity.map((s, i) => (
|
||
<tr key={i}>
|
||
<td style={{ ...S.td, fontWeight: 700 }}>
|
||
<span
|
||
style={{
|
||
display: 'inline-block',
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: 2,
|
||
background: s.color,
|
||
marginRight: 8,
|
||
verticalAlign: 'middle',
|
||
}}
|
||
/>
|
||
{s.level}
|
||
</td>
|
||
<ECell value={s.area} editing={editing} onChange={(v) => setSens(i, v)} />
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Page6({
|
||
data,
|
||
editing,
|
||
onChange,
|
||
}: {
|
||
data: OilSpillReportData;
|
||
editing: boolean;
|
||
onChange: (d: OilSpillReportData) => void;
|
||
}) {
|
||
const setVessel = (i: number, k: string, v: string) => {
|
||
const vs = [...data.vessels];
|
||
vs[i] = { ...vs[i], [k]: v };
|
||
onChange({ ...data, vessels: vs });
|
||
};
|
||
return (
|
||
<div style={S.page}>
|
||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||
해양오염방제지원시스템
|
||
</div>
|
||
<div style={S.sectionTitle}>5. 방제전략 수립·실행</div>
|
||
<div style={S.subHeader}>방제자원 배치 현황 (반경 30km내)</div>
|
||
<div className="overflow-x-auto">
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th} rowSpan={2}>
|
||
#
|
||
</th>
|
||
<th style={S.th} rowSpan={2}>
|
||
선명
|
||
</th>
|
||
<th style={S.th} rowSpan={2}>
|
||
소속
|
||
</th>
|
||
<th style={S.th} rowSpan={2}>
|
||
거리(km)
|
||
</th>
|
||
<th style={S.th} rowSpan={2}>
|
||
속력(knots)
|
||
</th>
|
||
<th style={S.th} rowSpan={2}>
|
||
크기(총톤수)
|
||
</th>
|
||
<th style={S.th} colSpan={2}>
|
||
유회수기
|
||
</th>
|
||
<th style={S.th} colSpan={2}>
|
||
오일펜스
|
||
</th>
|
||
</tr>
|
||
<tr>
|
||
<th style={S.th}>종류</th>
|
||
<th style={S.th}>용량(kl)</th>
|
||
<th style={S.th}>종류</th>
|
||
<th style={S.th}>길이(m)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.vessels.map((v, i) => (
|
||
<tr key={i}>
|
||
<td style={{ ...S.td, fontWeight: 600 }}>{i + 1}</td>
|
||
<ECell
|
||
value={v.name}
|
||
editing={editing}
|
||
onChange={(val) => setVessel(i, 'name', val)}
|
||
/>
|
||
<ECell
|
||
value={v.org}
|
||
editing={editing}
|
||
onChange={(val) => setVessel(i, 'org', val)}
|
||
/>
|
||
<ECell
|
||
value={v.dist}
|
||
editing={editing}
|
||
onChange={(val) => setVessel(i, 'dist', val)}
|
||
/>
|
||
<ECell
|
||
value={v.speed}
|
||
editing={editing}
|
||
onChange={(val) => setVessel(i, 'speed', val)}
|
||
/>
|
||
<ECell
|
||
value={v.ton}
|
||
editing={editing}
|
||
onChange={(val) => setVessel(i, 'ton', val)}
|
||
/>
|
||
<ECell
|
||
value={v.collectorType}
|
||
editing={editing}
|
||
onChange={(val) => setVessel(i, 'collectorType', val)}
|
||
/>
|
||
<ECell
|
||
value={v.collectorCap}
|
||
editing={editing}
|
||
onChange={(val) => setVessel(i, 'collectorCap', val)}
|
||
/>
|
||
<ECell
|
||
value={v.boomType}
|
||
editing={editing}
|
||
onChange={(val) => setVessel(i, 'boomType', val)}
|
||
/>
|
||
<ECell
|
||
value={v.boomLength}
|
||
editing={editing}
|
||
onChange={(val) => setVessel(i, 'boomLength', val)}
|
||
/>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{editing && (
|
||
<AddRowBtn
|
||
onClick={() =>
|
||
onChange({
|
||
...data,
|
||
vessels: [
|
||
...data.vessels,
|
||
{
|
||
name: '',
|
||
org: '',
|
||
dist: '',
|
||
speed: '',
|
||
ton: '',
|
||
collectorType: '',
|
||
collectorCap: '',
|
||
boomType: '',
|
||
boomLength: '',
|
||
},
|
||
],
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
<div style={S.subHeader}>기타 장비</div>
|
||
{editing ? (
|
||
<textarea
|
||
value={data.etcEquipment}
|
||
onChange={(e) => onChange({ ...data, etcEquipment: e.target.value })}
|
||
placeholder="기타 장비 입력"
|
||
style={{
|
||
width: '100%',
|
||
minHeight: '60px',
|
||
background: 'var(--bg-base)',
|
||
border: '1px solid var(--stroke-light)',
|
||
borderRadius: '4px',
|
||
padding: '12px',
|
||
fontSize: '12px',
|
||
outline: 'none',
|
||
resize: 'vertical',
|
||
}}
|
||
/>
|
||
) : (
|
||
<div
|
||
style={{
|
||
minHeight: '60px',
|
||
border: data.etcEquipment
|
||
? '1px solid var(--stroke-default)'
|
||
: '2px dashed var(--stroke-default)',
|
||
borderRadius: '4px',
|
||
padding: '12px',
|
||
color: data.etcEquipment ? 'var(--fg-default)' : 'var(--fg-disabled)',
|
||
fontStyle: data.etcEquipment ? 'normal' : 'italic',
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
{data.etcEquipment || '-'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Page7({
|
||
data,
|
||
editing,
|
||
onChange,
|
||
}: {
|
||
data: OilSpillReportData;
|
||
editing: boolean;
|
||
onChange: (d: OilSpillReportData) => void;
|
||
}) {
|
||
const setRec = (i: number, k: string, v: string) => {
|
||
const r = [...data.recovery];
|
||
r[i] = { ...r[i], [k]: v };
|
||
onChange({ ...data, recovery: r });
|
||
};
|
||
const setRes = (k: string, v: string) =>
|
||
onChange({ ...data, result: { ...data.result, [k]: v } });
|
||
return (
|
||
<div style={S.page}>
|
||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||
해양오염방제지원시스템
|
||
</div>
|
||
<div style={S.sectionTitle}>방제선/자원 동원 결과</div>
|
||
<div style={S.mapPlaceholder}>방제선/자원 동원 결과 지도</div>
|
||
<div style={S.subHeader}>기름회수량</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>선명</th>
|
||
<th style={S.th}>작업기간(회수작업 시작시간+시간)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.recovery.map((r, i) => (
|
||
<tr key={i}>
|
||
<ECell
|
||
value={r.shipName}
|
||
editing={editing}
|
||
onChange={(v) => setRec(i, 'shipName', v)}
|
||
/>
|
||
<ECell value={r.period} editing={editing} onChange={(v) => setRec(i, 'period', v)} />
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{editing && (
|
||
<AddRowBtn
|
||
onClick={() =>
|
||
onChange({ ...data, recovery: [...data.recovery, { shipName: '', period: '' }] })
|
||
}
|
||
/>
|
||
)}
|
||
<div style={S.subHeader}>동원 방제선 내역</div>
|
||
<table style={S.table}>
|
||
<thead>
|
||
<tr>
|
||
<th style={S.th}>유출량(kl)</th>
|
||
<th style={S.th}>누적풍화량(kl)</th>
|
||
<th style={S.th}>누적회수량(kl)</th>
|
||
<th style={S.th}>누적해상잔존량(kl)</th>
|
||
<th style={S.th}>누적연안부착량(kl)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<ECell
|
||
value={data.result.spillTotal}
|
||
editing={editing}
|
||
onChange={(v) => setRes('spillTotal', v)}
|
||
/>
|
||
<ECell
|
||
value={data.result.weatheredTotal}
|
||
editing={editing}
|
||
onChange={(v) => setRes('weatheredTotal', v)}
|
||
/>
|
||
<ECell
|
||
value={data.result.recoveredTotal}
|
||
editing={editing}
|
||
onChange={(v) => setRes('recoveredTotal', v)}
|
||
/>
|
||
<ECell
|
||
value={data.result.seaRemainTotal}
|
||
editing={editing}
|
||
onChange={(v) => setRes('seaRemainTotal', v)}
|
||
/>
|
||
<ECell
|
||
value={data.result.coastAttachTotal}
|
||
editing={editing}
|
||
onChange={(v) => setRes('coastAttachTotal', v)}
|
||
/>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Main Template Component ────────────────────────────────
|
||
interface Props {
|
||
mode: 'preview' | 'edit' | 'view';
|
||
initialData?: OilSpillReportData;
|
||
onSave?: (data: OilSpillReportData) => void;
|
||
onBack?: () => void;
|
||
}
|
||
|
||
export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Props) {
|
||
const [data, setData] = useState<OilSpillReportData>(() => initialData || createSampleReport());
|
||
const [currentPage, setCurrentPage] = useState(0);
|
||
const [viewMode, setViewMode] = useState<'page' | 'all'>('all');
|
||
const editing = mode === 'edit';
|
||
|
||
useEffect(() => {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
if (initialData) setData(initialData);
|
||
}, [initialData]);
|
||
|
||
const handleSave = useCallback(async () => {
|
||
let reportData = data;
|
||
if (!data.title) {
|
||
const title = data.incident.name || `보고서 ${new Date().toLocaleDateString('ko-KR')}`;
|
||
reportData = { ...data, title };
|
||
}
|
||
try {
|
||
await saveReport(reportData);
|
||
onSave?.(reportData);
|
||
} catch (err) {
|
||
console.error('[reports] 저장 오류:', err);
|
||
alert('보고서 저장 중 오류가 발생했습니다.');
|
||
}
|
||
}, [data, onSave]);
|
||
|
||
const pages = [
|
||
{ label: '1. 사고 정보', node: <Page1 data={data} editing={editing} onChange={setData} /> },
|
||
{
|
||
label: '2. 해양기상 + 확산예측',
|
||
node: <Page2 data={data} editing={editing} onChange={setData} />,
|
||
},
|
||
{ label: '3. 분석', node: <Page3 data={data} editing={editing} onChange={setData} /> },
|
||
{ label: '4. 민감자원', node: <Page4 data={data} editing={editing} onChange={setData} /> },
|
||
{ label: '5. 통합민감도', node: <Page5 data={data} editing={editing} onChange={setData} /> },
|
||
{ label: '6. 방제전략', node: <Page6 data={data} editing={editing} onChange={setData} /> },
|
||
{ label: '7. 동원 결과', node: <Page7 data={data} editing={editing} onChange={setData} /> },
|
||
];
|
||
|
||
return (
|
||
<div className="w-full">
|
||
{/* Toolbar */}
|
||
<div className="flex items-center justify-between mb-5 flex-wrap gap-3">
|
||
<div className="flex items-center gap-2.5">
|
||
{onBack && (
|
||
<button
|
||
onClick={onBack}
|
||
className="px-3 py-1.5 text-[12px] font-semibold text-fg-sub bg-transparent border-none cursor-pointer"
|
||
>
|
||
← 돌아가기
|
||
</button>
|
||
)}
|
||
<h2 className="text-[18px] font-bold">
|
||
{editing ? (
|
||
<input
|
||
value={data.title}
|
||
onChange={(e) => setData({ ...data, title: e.target.value })}
|
||
placeholder="보고서 제목 입력"
|
||
className="text-[18px] font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-full max-w-[600px]"
|
||
/>
|
||
) : (
|
||
data.title || '유류오염사고 대응지원 상황도'
|
||
)}
|
||
</h2>
|
||
<span
|
||
className="px-2.5 py-[3px] text-[10px] font-semibold rounded border"
|
||
style={
|
||
editing
|
||
? {
|
||
background: 'rgba(251,191,36,0.15)',
|
||
color: '#f59e0b',
|
||
borderColor: 'rgba(251,191,36,0.3)',
|
||
}
|
||
: {
|
||
background: 'rgba(6,182,212,0.15)',
|
||
color: 'var(--color-accent)',
|
||
borderColor: 'rgba(6,182,212,0.3)',
|
||
}
|
||
}
|
||
>
|
||
{editing ? '편집 중' : mode === 'preview' ? '샘플' : '보기'}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setViewMode('all')}
|
||
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
|
||
style={{
|
||
border:
|
||
viewMode === 'all'
|
||
? '1px solid var(--color-accent)'
|
||
: '1px solid var(--stroke-default)',
|
||
background: viewMode === 'all' ? 'rgba(6,182,212,0.1)' : 'var(--bg-elevated)',
|
||
color: viewMode === 'all' ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||
}}
|
||
>
|
||
전체 보기
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('page')}
|
||
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
|
||
style={{
|
||
border:
|
||
viewMode === 'page'
|
||
? '1px solid var(--color-accent)'
|
||
: '1px solid var(--stroke-default)',
|
||
background: viewMode === 'page' ? 'rgba(6,182,212,0.1)' : 'var(--bg-elevated)',
|
||
color: viewMode === 'page' ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||
}}
|
||
>
|
||
페이지별
|
||
</button>
|
||
{editing && (
|
||
<button
|
||
onClick={handleSave}
|
||
className="px-4 py-1.5 text-[11px] font-bold rounded cursor-pointer border border-[#22c55e] bg-[rgba(34,197,94,0.15)] text-color-success"
|
||
>
|
||
저장
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => window.print()}
|
||
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[rgba(239,68,68,0.1)] text-color-danger"
|
||
>
|
||
인쇄 / PDF
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Page tabs */}
|
||
{viewMode === 'page' && (
|
||
<div className="flex gap-1 mb-4 flex-wrap">
|
||
{pages.map((p, i) => (
|
||
<button
|
||
key={i}
|
||
onClick={() => setCurrentPage(i)}
|
||
className="px-3 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
|
||
style={{
|
||
border:
|
||
currentPage === i
|
||
? '1px solid var(--color-accent)'
|
||
: '1px solid var(--stroke-default)',
|
||
background: currentPage === i ? 'rgba(6,182,212,0.15)' : 'transparent',
|
||
color: currentPage === i ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||
}}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Pages */}
|
||
<div id="report-print-area" className="w-full">
|
||
{viewMode === 'all' ? (
|
||
pages.map((p, i) => <div key={i}>{p.node}</div>)
|
||
) : (
|
||
<div>
|
||
{pages[currentPage].node}
|
||
<div className="flex justify-center gap-2 mt-4">
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||
disabled={currentPage === 0}
|
||
className="px-5 py-2 text-[12px] font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
|
||
style={{
|
||
color: currentPage === 0 ? 'var(--fg-disabled)' : 'var(--fg-default)',
|
||
opacity: currentPage === 0 ? 0.4 : 1,
|
||
}}
|
||
>
|
||
이전
|
||
</button>
|
||
<span className="px-4 py-2 text-[12px] text-fg-sub">
|
||
{currentPage + 1} / {pages.length}
|
||
</span>
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.min(pages.length - 1, p + 1))}
|
||
disabled={currentPage === pages.length - 1}
|
||
className="px-5 py-2 text-[12px] font-semibold rounded cursor-pointer"
|
||
style={{
|
||
border: '1px solid var(--color-accent)',
|
||
background: 'rgba(6,182,212,0.1)',
|
||
color:
|
||
currentPage === pages.length - 1 ? 'var(--fg-disabled)' : 'var(--color-accent)',
|
||
opacity: currentPage === pages.length - 1 ? 0.4 : 1,
|
||
}}
|
||
>
|
||
다음
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|