Merge pull request 'release: 2026-03-25 (177�� Ŀ��)' (#125) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s

This commit is contained in:
jhkang 2026-03-25 18:29:39 +09:00
커밋 bc7e966cb1
20개의 변경된 파일737개의 추가작업 그리고 204개의 파일을 삭제

파일 보기

@ -83,5 +83,6 @@
]
}
]
}
},
"allow": []
}

파일 보기

@ -1,6 +1,6 @@
import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js';
import { listOrganizations, getOrganization, listUploadLogs, listInsurance } from './assetsService.js';
import { listOrganizations, getOrganization, listUploadLogs, listInsurance, listNearbyOrganizations } from './assetsService.js';
const router = Router();
@ -22,6 +22,26 @@ router.get('/orgs', requireAuth, async (req, res) => {
}
});
// ============================================================
// GET /api/assets/orgs/nearby — 근처 기관 목록 (PostGIS 반경 검색)
// ============================================================
router.get('/orgs/nearby', requireAuth, async (req, res) => {
try {
const lat = parseFloat(req.query.lat as string);
const lng = parseFloat(req.query.lng as string);
const radius = parseFloat(req.query.radius as string);
if (isNaN(lat) || isNaN(lng) || isNaN(radius) || radius <= 0) {
res.status(400).json({ error: '유효하지 않은 좌표 또는 반경입니다.' });
return;
}
const orgs = await listNearbyOrganizations(lat, lng, radius);
res.json(orgs);
} catch (err) {
console.error('[assets] 근처 기관 조회 오류:', err);
res.status(500).json({ error: '근처 기관 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/assets/orgs/:sn — 기관 상세 (장비 + 담당자)
// ============================================================

파일 보기

@ -162,6 +162,54 @@ export async function getOrganization(orgSn: number): Promise<OrgDetail | null>
};
}
// ============================================================
// 근처 기관 조회 (PostGIS ST_DWithin)
// ============================================================
export interface NearbyOrgItem extends OrgListItem {
distanceNm: number;
}
export async function listNearbyOrganizations(
lat: number,
lng: number,
radiusNm: number,
): Promise<NearbyOrgItem[]> {
const radiusMeters = radiusNm * 1852;
const sql = `
SELECT ORG_SN, ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL,
LAT, LNG, PIN_SIZE,
VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS,
ST_Distance(GEOM::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography) / 1852.0 AS distance_nm
FROM wing.ASSET_ORG
WHERE USE_YN = 'Y'
AND GEOM IS NOT NULL
AND ST_DWithin(GEOM::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography, $3)
ORDER BY distance_nm
`;
const { rows } = await wingPool.query(sql, [lat, lng, radiusMeters]);
return rows.map((r: Record<string, unknown>) => ({
orgSn: r.org_sn as number,
orgTp: r.org_tp as string,
jrsdNm: r.jrsd_nm as string,
areaNm: r.area_nm as string,
orgNm: r.org_nm as string,
addr: r.addr as string,
tel: r.tel as string,
lat: parseFloat(r.lat as string),
lng: parseFloat(r.lng as string),
pinSize: r.pin_size as string,
vesselCnt: r.vessel_cnt as number,
skimmerCnt: r.skimmer_cnt as number,
pumpCnt: r.pump_cnt as number,
vehicleCnt: r.vehicle_cnt as number,
sprayerCnt: r.sprayer_cnt as number,
totalAssets: r.total_assets as number,
distanceNm: parseFloat(r.distance_nm as string),
}));
}
// ============================================================
// 선박보험(유류오염보장계약) 조회
// ============================================================

파일 보기

@ -17,8 +17,11 @@ const router = express.Router();
// GET /api/prediction/analyses — 분석 목록
router.get('/analyses', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
try {
const { search } = req.query;
const items = await listAnalyses({ search: search as string | undefined });
const { search, acdntSn } = req.query;
const items = await listAnalyses({
search: search as string | undefined,
acdntSn: acdntSn ? parseInt(acdntSn as string, 10) : undefined,
});
res.json(items);
} catch (err) {
console.error('[prediction] 분석 목록 오류:', err);

파일 보기

@ -115,12 +115,18 @@ interface BoomLineItem {
interface ListAnalysesInput {
search?: string;
acdntSn?: number;
}
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
const params: unknown[] = [];
const conditions: string[] = ["A.USE_YN = 'Y'"];
if (input.acdntSn) {
params.push(input.acdntSn);
conditions.push(`A.ACDNT_SN = $${params.length}`);
}
if (input.search) {
params.push(`%${input.search}%`);
conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`);
@ -149,7 +155,9 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
P.KOSPS_STATUS,
P.POSEIDON_STATUS,
P.OPENDRIFT_STATUS,
B.BACKTRACK_STATUS
B.BACKTRACK_STATUS,
COALESCE(U.USER_NM, A.ANALYST_NM) AS RESOLVED_ANALYST,
COALESCE(O.ORG_NM, A.OFFICE_NM) AS RESOLVED_OFFICE
FROM ACDNT A
INNER JOIN (
SELECT
@ -157,6 +165,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
PRED_RUN_SN,
MIN(BGNG_DTM) AS RUN_DTM,
MIN(SPIL_DATA_SN) AS SPIL_DATA_SN,
MIN(EXEC_USER_ID::TEXT)::UUID AS EXEC_USER_ID,
MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS,
MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS,
MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS
@ -164,6 +173,8 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
GROUP BY ACDNT_SN, PRED_RUN_SN
) P ON P.ACDNT_SN = A.ACDNT_SN
LEFT JOIN SPIL_DATA S ON S.SPIL_DATA_SN = P.SPIL_DATA_SN
LEFT JOIN AUTH_USER U ON U.USER_ID = P.EXEC_USER_ID
LEFT JOIN AUTH_ORG O ON O.ORG_SN = U.ORG_SN
LEFT JOIN (
SELECT
ACDNT_SN,
@ -193,8 +204,8 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
analyst: String(row['analyst_nm'] ?? ''),
officeName: String(row['office_nm'] ?? ''),
analyst: String(row['resolved_analyst'] ?? ''),
officeName: String(row['resolved_office'] ?? ''),
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
predRunSn: row['pred_run_sn'] != null ? Number(row['pred_run_sn']) : null,
runDtm: row['run_dtm'] ? String(row['run_dtm']) : null,

파일 보기

@ -242,10 +242,10 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
try {
const kospsExecNm = `${execNmBase}_KOSPS`
const insertRes = await wingPool.query(
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM)
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, NOW())
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, EXEC_USER_ID, BGNG_DTM)
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm]
[resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, req.user!.sub]
)
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
} catch (dbErr) {
@ -267,10 +267,10 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
let predExecSn: number
try {
const insertRes = await wingPool.query(
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM)
VALUES ($1, $2, $3, 'PENDING', $4, NOW())
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, EXEC_USER_ID, BGNG_DTM)
VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm]
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, req.user!.sub]
)
predExecSn = insertRes.rows[0].pred_exec_sn as number
} catch (dbErr) {
@ -589,10 +589,10 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
try {
const kospsExecNm = `${execNmBase}_KOSPS`
const insertRes = await wingPool.query(
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM)
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW())
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, EXEC_USER_ID, BGNG_DTM)
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, $5, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, predRunSn]
[resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, predRunSn, req.user!.sub]
)
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
} catch (dbErr) {
@ -626,10 +626,10 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
let predExecSn: number
try {
const insertRes = await wingPool.query(
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM)
VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW())
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, EXEC_USER_ID, BGNG_DTM)
VALUES ($1, $2, $3, 'PENDING', $4, $5, $6, NOW())
RETURNING PRED_EXEC_SN`,
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, predRunSn]
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, predRunSn, req.user!.sub]
)
predExecSn = insertRes.rows[0].pred_exec_sn as number
} catch (dbErr) {

파일 보기

@ -307,7 +307,6 @@ export async function listOrgs(): Promise<OrgItem[]> {
const { rows } = await authPool.query(
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
FROM AUTH_ORG
WHERE USE_YN = 'Y'
ORDER BY ORG_SN`
)
return rows.map((r: Record<string, unknown>) => ({

파일 보기

@ -0,0 +1,3 @@
-- 029_pred_exec_user.sql
-- PRED_EXEC 테이블에 예측 실행자 ID 컬럼 추가
ALTER TABLE wing.PRED_EXEC ADD COLUMN IF NOT EXISTS EXEC_USER_ID UUID;

파일 보기

@ -4,6 +4,17 @@
## [Unreleased]
## [2026-03-25.2]
### 추가
- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑)
- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin)
- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션)
### 변경
- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용
- 사고: 선택된 항목 재클릭 시 선택 해제 지원
## [2026-03-25]
### 추가

파일 보기

@ -20,7 +20,7 @@ export interface Incident {
interface IncidentsLeftPanelProps {
incidents: Incident[]
selectedIncidentId: string | null
onIncidentSelect: (id: string) => void
onIncidentSelect: (id: string | null) => void
}
const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const
@ -290,7 +290,7 @@ export function IncidentsLeftPanel({
active: '대응중', investigating: '조사중', closed: '종료',
}
return (
<div key={inc.id} onClick={() => onIncidentSelect(inc.id)}
<div key={inc.id} onClick={() => onIncidentSelect(isSel ? null : inc.id)}
className="px-4 py-3 border-b border-border cursor-pointer"
style={{
background: isSel ? 'rgba(6,182,212,0.04)' : undefined,

파일 보기

@ -1,5 +1,9 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import type { Incident } from './IncidentsLeftPanel'
import { fetchPredictionAnalyses, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '@tabs/prediction/services/predictionApi'
import type { PredictionAnalysis, SensitiveResourceCategory, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
import { fetchNearbyOrgs } from '../services/incidentsApi'
import type { NearbyOrgItem } from '../services/incidentsApi'
export type ViewMode = 'overlay' | 'split2' | 'split3'
@ -20,10 +24,11 @@ interface IncidentsRightPanelProps {
onRunAnalysis: (sections: AnalysisSection[], sensitiveCount: number) => void
analysisActive: boolean
onCloseAnalysis: () => void
onCheckedPredsChange?: (checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>) => void
onSensitiveDataChange?: (geojson: SensitiveResourceFeatureCollection | null, checkedCategories: Set<string>, categoryOrder: string[]) => void
selectedVessel: { lat: number; lng: number; name?: string } | null
}
/* ── Analysis section data ───────────────────────── */
interface AnalysisItem {
id: string
name: string
@ -31,95 +36,184 @@ interface AnalysisItem {
checked: boolean
}
const SECTION_DATA: {
key: string
icon: string
title: string
color: string
colorRgb: string
totalLabel: string
items: AnalysisItem[]
}[] = [
{
key: 'oil',
icon: '🛢',
title: '유출유 확산예측',
color: '#f97316',
colorRgb: '249,115,22',
totalLabel: '전체 8건',
items: [
{ id: 'o1', name: '여수항 BUNKER-C 확산', sub: 'KOSPS+OpenDrift · 150kL', checked: true },
{ id: 'o2', name: '여수항 DIESEL 확산', sub: 'KOSPS · 30kL', checked: false },
],
},
{
key: 'hns',
icon: '🧪',
title: 'HNS 대기확산',
color: '#a855f7',
colorRgb: '168,85,247',
totalLabel: '전체 6건',
items: [
{ id: 'h1', name: '울산 톨루엔 대기확산', sub: 'ALOHA · IDLH 1.2km', checked: true },
],
},
{
key: 'rsc',
icon: '🚨',
title: '긴급구난',
color: '#06b6d4',
colorRgb: '6,182,212',
totalLabel: '전체 5건',
items: [
{ id: 'r1', name: '여수 해상구난 SAR #12', sub: 'SAROPS · 확률맵', checked: true },
],
},
/* ── 카테고리별 고유 색상 (목록 순서 인덱스 기반 — 중복 없음) ── */
const CATEGORY_PALETTE: [number, number, number][] = [
[239, 68, 68 ], // red
[249, 115, 22 ], // orange
[234, 179, 8 ], // yellow
[132, 204, 22 ], // lime
[20, 184, 166], // teal
[6, 182, 212], // cyan
[59, 130, 246], // blue
[99, 102, 241], // indigo
[168, 85, 247], // purple
[236, 72, 153], // pink
[244, 63, 94 ], // rose
[16, 185, 129], // emerald
[14, 165, 233], // sky
[139, 92, 246], // violet
[217, 119, 6 ], // amber
[45, 212, 191], // turquoise
]
interface SensitiveResource {
id: string
name: string
area: string
checked: boolean
function getCategoryColor(index: number): [number, number, number] {
return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length]
}
const MOCK_SENSITIVE: SensitiveResource[] = [
{ id: 's1', name: '돌산 어장 (김·전복)', area: '131ha', checked: true },
{ id: 's2', name: '여수 갯벌 생태계', area: '4,013ha', checked: true },
{ id: 's3', name: '여수 해수욕장 3개소', area: '', checked: false },
{ id: 's4', name: '오동도 해상공원', area: '125ha', checked: false },
{ id: 's5', name: '여수 취수시설', area: '2개소', checked: false },
/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반) ── */
const CATEGORY_ICON: Record<string, string> = {
'어장정보': '🐟', '양식장': '🦪', '양식어업': '🦪', '어류양식장': '🐟',
'패류양식장': '🦪', '해조류양식장': '🌿', '가두리양식장': '🔲', '갑각류양식장': '🦐',
'기타양식장': '📦', '영세어업': '🎣', '유어장': '🎣', '수산시장': '🐟',
'인공어초': '🪸', '암초': '🪨', '침선': '🚢',
'해수욕장': '🏖', '갯바위낚시': '🪨', '선상낚시': '🚤', '마리나항': '⛵',
'무역항': '🚢', '연안항': '⛵', '국가어항': '⚓', '지방어항': '⚓',
'어항': '⚓', '항만구역': '⚓', '항로': '🚢', '정박지': '⛵',
'항로표지': '🔴', '해수취수시설': '💧', '취수구·배수구': '🚰',
'LNG': '⚡', '발전소': '🔌', '발전소·산단': '🏭', '임해공단': '🏭',
'저유시설': '🛢', '해저케이블·배관': '🔌',
'갯벌': '🪨', '해안선_ESI': '🏖', '보호지역': '🛡', '해양보호구역': '🌿',
'철새도래지': '🐦', '습지보호구역': '🏖', '보호종서식지': '🐢', '보호종 서식지': '🐢',
}
/* ── 헬퍼: 활성 모델 문자열 ─────────────────────── */
function getActiveModels(p: PredictionAnalysis): string {
const models = [
p.kospsStatus && p.kospsStatus !== 'pending' && p.kospsStatus !== 'none' ? 'KOSPS' : null,
p.poseidonStatus && p.poseidonStatus !== 'pending' && p.poseidonStatus !== 'none' ? 'POSEIDON' : null,
p.opendriftStatus && p.opendriftStatus !== 'pending' && p.opendriftStatus !== 'none' ? 'OpenDrift' : null,
].filter(Boolean).join('+')
return models || '분석중'
}
/* ── HNS/구난 섹션 (미개발, 고정 구조만 유지) ────── */
const STATIC_SECTIONS = [
{ key: 'hns', icon: '🧪', title: 'HNS 대기확산', color: '#a855f7', colorRgb: '168,85,247' },
{ key: 'rsc', icon: '🚨', title: '긴급구난', color: '#06b6d4', colorRgb: '6,182,212' },
]
/* ── Component ───────────────────────────────────── */
export function IncidentsRightPanel({
incident, viewMode, onViewModeChange, onRunAnalysis, analysisActive, onCloseAnalysis
incident, viewMode, onViewModeChange, onRunAnalysis, analysisActive, onCloseAnalysis,
onCheckedPredsChange, onSensitiveDataChange, selectedVessel,
}: IncidentsRightPanelProps) {
const [sections, setSections] = useState(SECTION_DATA)
const [sensitive, setSensitive] = useState(MOCK_SENSITIVE)
const [predItems, setPredItems] = useState<PredictionAnalysis[]>([])
const [checkedPredIds, setCheckedPredIds] = useState<Set<string>>(new Set())
const [sensCategories, setSensCategories] = useState<SensitiveResourceCategory[]>([])
const [checkedSensCategories, setCheckedSensCategories] = useState<Set<string>>(new Set())
const [sensitiveGeojson, setSensitiveGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
const [nearbyRadius, setNearbyRadius] = useState(50)
const [nearbyOrgs, setNearbyOrgs] = useState<NearbyOrgItem[]>([])
const [nearbyLoading, setNearbyLoading] = useState(false)
const toggleItem = (sectionKey: string, itemId: string) => {
setSections(prev =>
prev.map(s =>
s.key === sectionKey
? { ...s, items: s.items.map(it => (it.id === itemId ? { ...it, checked: !it.checked } : it)) }
: s
)
)
useEffect(() => {
if (!incident) {
void Promise.resolve().then(() => {
setPredItems([])
setSensCategories([])
setSensitiveGeojson(null)
onCheckedPredsChange?.([])
onSensitiveDataChange?.(null, new Set(), [])
})
return
}
const acdntSn = parseInt(incident.id, 10)
fetchPredictionAnalyses({ acdntSn })
.then(items => {
setPredItems(items)
const allIds = new Set(items.map(i => String(i.predRunSn ?? i.acdntSn)))
setCheckedPredIds(allIds)
onCheckedPredsChange?.(
items.map(p => ({
id: String(p.predRunSn ?? p.acdntSn),
acdntSn: p.acdntSn,
predRunSn: p.predRunSn,
occurredAt: p.occurredAt,
}))
)
})
.catch(() => setPredItems([]))
Promise.all([
fetchSensitiveResources(acdntSn),
fetchSensitiveResourcesGeojson(acdntSn),
]).then(([cats, geojson]) => {
const allCategories = new Set(cats.map(c => c.category))
setSensCategories(cats)
setCheckedSensCategories(allCategories)
setSensitiveGeojson(geojson)
onSensitiveDataChange?.(geojson, allCategories, cats.map(c => c.category))
}).catch(() => {
setSensCategories([])
setSensitiveGeojson(null)
})
}, [incident?.id]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!selectedVessel) {
void Promise.resolve().then(() => { setNearbyOrgs([]); setNearbyLoading(false) })
return
}
void Promise.resolve().then(() => setNearbyLoading(true))
fetchNearbyOrgs(selectedVessel.lat, selectedVessel.lng, nearbyRadius)
.then(setNearbyOrgs)
.catch(() => setNearbyOrgs([]))
.finally(() => setNearbyLoading(false))
}, [selectedVessel, nearbyRadius])
const togglePredItem = (id: string) => {
setCheckedPredIds(prev => {
const next = new Set(prev)
if (next.has(id)) { next.delete(id) } else { next.add(id) }
const newChecked = predItems
.filter(p => next.has(String(p.predRunSn ?? p.acdntSn)))
.map(p => ({ id: String(p.predRunSn ?? p.acdntSn), acdntSn: p.acdntSn, predRunSn: p.predRunSn, occurredAt: p.occurredAt }))
onCheckedPredsChange?.(newChecked)
return next
})
}
const removeItem = (sectionKey: string, itemId: string) => {
setSections(prev =>
prev.map(s =>
s.key === sectionKey ? { ...s, items: s.items.filter(it => it.id !== itemId) } : s
const removePredItem = (id: string) => {
setPredItems(prev => {
const next = prev.filter(p => String(p.predRunSn ?? p.acdntSn) !== id)
onCheckedPredsChange?.(
next
.filter(p => checkedPredIds.has(String(p.predRunSn ?? p.acdntSn)))
.map(p => ({ id: String(p.predRunSn ?? p.acdntSn), acdntSn: p.acdntSn, predRunSn: p.predRunSn, occurredAt: p.occurredAt }))
)
)
return next
})
setCheckedPredIds(prev => { const next = new Set(prev); next.delete(id); return next })
}
const toggleSensitive = (id: string) => {
setSensitive(prev => prev.map(s => (s.id === id ? { ...s, checked: !s.checked } : s)))
const toggleSensCategory = (category: string) => {
setCheckedSensCategories(prev => {
const next = new Set(prev)
if (next.has(category)) { next.delete(category) } else { next.add(category) }
onSensitiveDataChange?.(sensitiveGeojson, next, sensCategories.map(c => c.category))
return next
})
}
/* 유출유 섹션을 AnalysisSection 형태로 변환 (통합 분석 실행 콜백용) */
const oilSection: AnalysisSection = {
key: 'oil',
icon: '🛢',
title: '유출유 확산예측',
color: '#f97316',
colorRgb: '249,115,22',
totalLabel: `전체 ${predItems.length}`,
items: predItems.map(p => {
const id = String(p.predRunSn ?? p.acdntSn)
const dateStr = p.runDtm ? p.runDtm.slice(0, 10) : ''
const oilLabel = p.oilType || '유출유'
return {
id,
name: `${dateStr} ${oilLabel} 확산예측`.trim(),
sub: `${getActiveModels(p)}${p.volume != null ? ` · ${p.volume}kL` : ''}`,
checked: checkedPredIds.has(id),
}
}),
}
if (!incident) {
@ -147,18 +241,17 @@ export function IncidentsRightPanel({
{/* Scrollable Content */}
<div className="flex-1 h-0 overflow-y-auto flex flex-col gap-2 p-2" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{/* Analysis Sections (oil / hns / rsc) */}
{sections.map(sec => {
{/* 유출유 확산예측 섹션 */}
{(() => {
const sec = oilSection
const checkedCount = sec.items.filter(it => it.checked).length
return (
<div key={sec.key} className="bg-bg-2 border border-border rounded-md p-2.5">
{/* Section Header */}
<div className="bg-bg-2 border border-border rounded-md p-2.5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">{sec.icon}</span>
<span className="text-xs font-bold" style={{ color: sec.color }}>
{sec.title}
</span>
<span className="text-xs font-bold" style={{ color: sec.color }}>{sec.title}</span>
</div>
<button className="text-[10px] font-semibold cursor-pointer"
style={{
@ -170,77 +263,108 @@ export function IncidentsRightPanel({
📋
</button>
</div>
{/* Items */}
<div className="flex flex-col gap-1">
{sec.items.map(item => (
<div key={item.id} className="flex items-center gap-1.5"
style={{
padding: '5px 8px',
background: `rgba(${sec.colorRgb},0.06)`,
border: `1px solid rgba(${sec.colorRgb},0.15)`,
borderRadius: '4px',
}}>
<input
type="checkbox"
checked={item.checked}
onChange={() => toggleItem(sec.key, item.id)}
className="shrink-0"
style={{ accentColor: sec.color }}
/>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
{item.name}
</div>
<div className="text-text-3 font-mono text-[8px]">
{item.sub}
{sec.items.length === 0 ? (
<div className="text-[9px] text-text-3 text-center py-1.5"> </div>
) : (
sec.items.map(item => (
<div key={item.id} className="flex items-center gap-1.5"
style={{
padding: '5px 8px',
background: `rgba(${sec.colorRgb},0.06)`,
border: `1px solid rgba(${sec.colorRgb},0.15)`,
borderRadius: '4px',
}}>
<input
type="checkbox"
checked={item.checked}
onChange={() => togglePredItem(item.id)}
className="shrink-0"
style={{ accentColor: sec.color }}
/>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
{item.name}
</div>
<div className="text-text-3 font-mono text-[8px]">{item.sub}</div>
</div>
<span
onClick={() => removePredItem(item.id)}
title="제거"
className="text-[10px] cursor-pointer text-text-3 shrink-0"
>
</span>
</div>
<span
onClick={() => removeItem(sec.key, item.id)}
title="제거"
className="text-[10px] cursor-pointer text-text-3 shrink-0"
>
</span>
</div>
))}
))
)}
</div>
{/* Status */}
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-text-3">
: <b style={{ color: sec.color }}>{checkedCount}</b> · {sec.totalLabel}
</div>
</div>
)
})}
})()}
{/* HNS 대기확산 / 긴급구난 섹션 (미개발 - 구조 유지) */}
{STATIC_SECTIONS.map(sec => (
<div key={sec.key} className="bg-bg-2 border border-border rounded-md p-2.5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-sm">{sec.icon}</span>
<span className="text-xs font-bold" style={{ color: sec.color }}>{sec.title}</span>
</div>
<button className="text-[10px] font-semibold cursor-pointer"
style={{
padding: '3px 10px', borderRadius: '4px',
background: `rgba(${sec.colorRgb},0.1)`,
border: `1px solid rgba(${sec.colorRgb},0.25)`,
color: sec.color,
}}>
📋
</button>
</div>
<div className="text-[9px] text-text-3 text-center py-1.5"> </div>
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-text-3">
: <b style={{ color: sec.color }}>0</b> · 0
</div>
</div>
))}
{/* 민감자원 */}
<div className="bg-bg-2 border border-border rounded-md p-2.5">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-sm">🐟</span>
<span className="text-xs font-bold text-[#22c55e]">
</span>
<span className="text-xs font-bold text-[#22c55e]"></span>
</div>
<div className="flex flex-col gap-[3px]">
{sensitive.map(res => (
<label key={res.id} className="flex items-center cursor-pointer text-[9px] gap-[5px] rounded-[3px]"
style={{
padding: '4px 6px', background: 'rgba(34,197,94,0.06)',
}}>
<input
type="checkbox"
checked={res.checked}
onChange={() => toggleSensitive(res.id)}
style={{ accentColor: 'var(--green)' }}
/>
{res.name}
{res.area && (
<span className="text-text-3 font-mono">({res.area})</span>
)}
</label>
))}
{sensCategories.length === 0 ? (
<div className="text-[9px] text-text-3 text-center py-1.5"> </div>
) : (
sensCategories.map((cat, i) => {
const icon = CATEGORY_ICON[cat.category] ?? '🌊'
const areaLabel = cat.totalArea != null
? `${cat.totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}ha`
: `${cat.count}개소`
const [r, g, b] = getCategoryColor(i)
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
return (
<label key={cat.category} className="flex items-center cursor-pointer text-[9px] gap-[5px] rounded-[3px]"
style={{ padding: '4px 6px', background: `rgba(${r},${g},${b},0.06)` }}>
<input
type="checkbox"
checked={checkedSensCategories.has(cat.category)}
onChange={() => toggleSensCategory(cat.category)}
style={{ accentColor: hex }}
/>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: hex, flexShrink: 0, display: 'inline-block', border: `1px solid rgba(${r},${g},${b},0.45)` }} />
<span>{icon}</span>
<span className="flex-1">{cat.category}</span>
<span className="text-text-3 font-mono shrink-0">({areaLabel})</span>
</label>
)
})
)}
</div>
</div>
@ -251,13 +375,44 @@ export function IncidentsRightPanel({
<span className="text-xs font-bold text-[#f59e0b]">
</span>
{nearbyOrgs.length > 0 && (
<span className="ml-auto text-[9px] font-mono text-[#f59e0b]">{nearbyOrgs.length}</span>
)}
</div>
{/* Empty state */}
<div className="py-2.5 text-center text-text-3 text-[10px] leading-[1.7]">
<div className="text-xl mb-1 opacity-40">🚢</div>
<br />
</div>
{!selectedVessel ? (
<div className="py-2.5 text-center text-text-3 text-[10px] leading-[1.7]">
<div className="text-xl mb-1 opacity-40">🚢</div>
<br />
</div>
) : nearbyLoading ? (
<div className="py-2.5 text-center text-text-3 text-[10px]"> ...</div>
) : nearbyOrgs.length === 0 ? (
<div className="py-2.5 text-center text-text-3 text-[10px]"> </div>
) : (
<div className="flex flex-col gap-[3px] max-h-[200px] overflow-y-auto">
{nearbyOrgs.map(org => (
<div key={org.orgSn} className="flex items-start gap-1.5 rounded-[3px] px-[6px] py-[5px]"
style={{ background: 'rgba(245,158,11,0.05)', border: '1px solid rgba(245,158,11,0.08)' }}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 mb-[2px]">
<span className="text-[8px] px-[4px] py-[1px] rounded-[2px] font-bold shrink-0"
style={{ background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }}>
{org.orgTp}
</span>
<span className="text-[10px] font-bold text-text-1 truncate">{org.orgNm}</span>
</div>
<div className="text-[9px] text-text-3">
{org.areaNm}{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}` : ''}
</div>
</div>
<span className="text-[9px] font-mono text-[#f59e0b] shrink-0">
{org.distanceNm.toFixed(1)} nm
</span>
</div>
))}
</div>
)}
{/* Radius slider */}
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(245,158,11,0.1)' }}>
@ -313,8 +468,11 @@ export function IncidentsRightPanel({
{/* Execute */}
<button onClick={() => {
if (analysisActive) { onCloseAnalysis(); return }
const checkedSections = sections.filter(s => s.items.some(it => it.checked))
const sensChecked = sensitive.filter(s => s.checked).length
const checkedOilItems = oilSection.items.filter(it => it.checked)
const checkedSections = checkedOilItems.length > 0
? [{ ...oilSection, items: checkedOilItems }]
: []
const sensChecked = checkedSensCategories.size
onRunAnalysis(checkedSections, sensChecked)
}} className="w-full text-[11px] font-bold cursor-pointer"
style={{

파일 보기

@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react'
import { Map, Popup, useControl } from '@vis.gl/react-maplibre'
import { useState, useEffect, useMemo, useRef } from 'react'
import { Map as MapLibre, Popup, useControl, useMap } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import { ScatterplotLayer, IconLayer, PathLayer } from '@deck.gl/layers'
import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'
import { PathStyleExtension } from '@deck.gl/extensions'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
@ -10,6 +10,8 @@ import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './Inci
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
import { fetchIncidents } from '../services/incidentsApi'
import type { IncidentCompat } from '../services/incidentsApi'
import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi'
import type { TrajectoryResponse, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
import { DischargeZonePanel } from './DischargeZonePanel'
import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData'
import { useMapStore } from '@common/store/mapStore'
@ -17,6 +19,30 @@ import { useMeasureTool } from '@common/hooks/useMeasureTool'
import { buildMeasureLayers } from '@common/components/map/measureLayers'
import { MeasureOverlay } from '@common/components/map/MeasureOverlay'
// ── 민감자원 카테고리별 색상 (목록 순서 인덱스 기반 — 중복 없음) ────────────
const CATEGORY_PALETTE: [number, number, number][] = [
[239, 68, 68 ], // red
[249, 115, 22 ], // orange
[234, 179, 8 ], // yellow
[132, 204, 22 ], // lime
[20, 184, 166], // teal
[6, 182, 212], // cyan
[59, 130, 246], // blue
[99, 102, 241], // indigo
[168, 85, 247], // purple
[236, 72, 153], // pink
[244, 63, 94 ], // rose
[16, 185, 129], // emerald
[14, 165, 233], // sky
[139, 92, 246], // violet
[217, 119, 6 ], // amber
[45, 212, 191], // turquoise
]
function getCategoryColor(index: number): [number, number, number] {
return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length]
}
// ── CartoDB Positron 베이스맵 (밝은 테마) ────────────────
const BASE_STYLE: StyleSpecification = {
version: 8,
@ -43,6 +69,25 @@ function DeckGLOverlay({ layers }: { layers: any[] }) {
return null
}
// ── FlyToController: 사고 선택 시 지도 이동 ──────────
function FlyToController({ incident }: { incident: IncidentCompat | null }) {
const { current: map } = useMap()
const prevIdRef = useRef<string | null>(null)
useEffect(() => {
if (!map || !incident) return
if (prevIdRef.current === incident.id) return
prevIdRef.current = incident.id
map.flyTo({
center: [incident.location.lon, incident.location.lat],
zoom: 10,
duration: 800,
})
}, [map, incident])
return null
}
// ── 사고 상태 색상 ──────────────────────────────────────
function getMarkerColor(s: string): [number, number, number, number] {
if (s === 'active') return [239, 68, 68, 204]
@ -111,15 +156,26 @@ export function IncidentsView() {
const [analysisActive, setAnalysisActive] = useState(false)
const [analysisTags, setAnalysisTags] = useState<{ icon: string; label: string; color: string }[]>([])
// 예측 trajectory & 민감자원 지도 표출
const [trajectoryEntries, setTrajectoryEntries] = useState<Record<string, { data: TrajectoryResponse; occurredAt: string }>>({})
const [sensitiveGeojson, setSensitiveGeojson] = useState<SensitiveResourceFeatureCollection | null>(null)
const [sensCheckedCategories, setSensCheckedCategories] = useState<Set<string>>(new Set())
const [sensColorMap, setSensColorMap] = useState<Map<string, [number, number, number]>>(new Map())
useEffect(() => {
fetchIncidents().then(data => {
setIncidents(data)
if (data.length > 0) {
setSelectedIncidentId(data[0].id)
}
})
}, [])
// 사고 전환 시 지도 레이어 즉시 초기화
useEffect(() => {
setTrajectoryEntries({})
setSensitiveGeojson(null)
setSensCheckedCategories(new Set())
setSensColorMap(new Map())
}, [selectedIncidentId])
const selectedIncident = incidents.find(i => i.id === selectedIncidentId) ?? null
const handleRunAnalysis = (sections: AnalysisSection[], sensitiveCount: number) => {
@ -140,6 +196,35 @@ export function IncidentsView() {
setAnalysisTags([])
}
const handleCheckedPredsChange = async (
checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>
) => {
const newEntries: Record<string, { data: TrajectoryResponse; occurredAt: string }> = {}
await Promise.all(
checked.map(async ({ id, acdntSn, predRunSn, occurredAt }) => {
const existing = trajectoryEntries[id]
if (existing) { newEntries[id] = existing; return }
try {
const data = await fetchAnalysisTrajectory(acdntSn, predRunSn ?? undefined)
newEntries[id] = { data, occurredAt }
} catch { /* 조용히 실패 */ }
})
)
setTrajectoryEntries(newEntries)
}
const handleSensitiveDataChange = (
geojson: SensitiveResourceFeatureCollection | null,
checkedCategories: Set<string>,
categoryOrder: string[]
) => {
setSensitiveGeojson(geojson)
setSensCheckedCategories(checkedCategories)
const colorMap = new Map<string, [number, number, number]>()
categoryOrder.forEach((cat, i) => colorMap.set(cat, getCategoryColor(i)))
setSensColorMap(colorMap)
}
// ── 사고 마커 (ScatterplotLayer) ──────────────────────
const incidentLayer = useMemo(
() =>
@ -159,12 +244,17 @@ export function IncidentsView() {
pickable: true,
onClick: (info: { object?: IncidentCompat; coordinate?: number[] }) => {
if (info.object && info.coordinate) {
setSelectedIncidentId(info.object.id)
setIncidentPopup({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
incident: info.object,
})
const newId = selectedIncidentId === info.object.id ? null : info.object.id
setSelectedIncidentId(newId)
if (newId) {
setIncidentPopup({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
incident: info.object,
})
} else {
setIncidentPopup(null)
}
setVesselPopup(null)
}
},
@ -264,10 +354,176 @@ export function IncidentsView() {
[measureInProgress, measureMode, measurements],
)
// ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ──────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const trajectoryLayers: any[] = useMemo(() => {
const layers: unknown[] = []
// 모델별 색상 (prediction 탭과 동일)
const MODEL_COLORS: Record<string, [number, number, number]> = {
'KOSPS': [6, 182, 212], // cyan
'POSEIDON': [239, 68, 68], // red
'OpenDrift': [59, 130, 246], // blue
'default': [249, 115, 22], // orange
}
const pad = (n: number) => String(n).padStart(2, '0')
let runIdx = 0
for (const [runId, entry] of Object.entries(trajectoryEntries)) {
const { data: traj, occurredAt } = entry
const { trajectory, centerPoints } = traj
const startDt = new Date(occurredAt)
runIdx++
if (trajectory && trajectory.length > 0) {
const maxTime = Math.max(...trajectory.map(p => p.time))
// 최종 스텝 부유 입자: 모델별로 그룹핑하여 각각 다른 색
const lastStepByModel: Record<string, typeof trajectory> = {}
trajectory.forEach(p => {
if (p.time === maxTime && p.stranded !== 1) {
const m = p.model ?? 'default'
if (!lastStepByModel[m]) lastStepByModel[m] = []
lastStepByModel[m].push(p)
}
})
Object.entries(lastStepByModel).forEach(([model, particles]) => {
const color = MODEL_COLORS[model] ?? MODEL_COLORS['default']
layers.push(new ScatterplotLayer({
id: `traj-particles-${runId}-${model}`,
data: particles,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getFillColor: [...color, 180] as [number, number, number, number],
getRadius: 3,
radiusMinPixels: 2,
radiusMaxPixels: 5,
}))
})
// 해안 부착 입자: 모델별 색상 + 테두리 강조
const beachedByModel: Record<string, typeof trajectory> = {}
trajectory.forEach(p => {
if (p.stranded === 1) {
const m = p.model ?? 'default'
if (!beachedByModel[m]) beachedByModel[m] = []
beachedByModel[m].push(p)
}
})
Object.entries(beachedByModel).forEach(([model, particles]) => {
const color = MODEL_COLORS[model] ?? MODEL_COLORS['default']
layers.push(new ScatterplotLayer({
id: `traj-beached-${runId}-${model}`,
data: particles,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getFillColor: [...color, 220] as [number, number, number, number],
getRadius: 4,
radiusMinPixels: 3,
radiusMaxPixels: 6,
stroked: true,
getLineColor: [255, 255, 255, 160] as [number, number, number, number],
getLineWidth: 1,
lineWidthMinPixels: 1,
}))
})
}
// 중심점 경로선 (모델별 그룹)
if (centerPoints && centerPoints.length >= 2) {
const byModel: Record<string, typeof centerPoints> = {}
centerPoints.forEach(cp => {
const m = cp.model ?? 'default'
if (!byModel[m]) byModel[m] = []
byModel[m].push(cp)
})
Object.entries(byModel).forEach(([model, pts]) => {
const color = MODEL_COLORS[model] ?? MODEL_COLORS['default']
const sorted = [...pts].sort((a, b) => a.time - b.time)
const pathId = `${runIdx}-${model}`
layers.push(new PathLayer({
id: `traj-path-${pathId}`,
data: [{ path: sorted.map(p => [p.lon, p.lat]) }],
getPath: (d: { path: number[][] }) => d.path,
getColor: [...color, 230] as [number, number, number, number],
getWidth: 2,
widthMinPixels: 2,
widthMaxPixels: 4,
}))
layers.push(new ScatterplotLayer({
id: `traj-centers-${pathId}`,
data: sorted,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getFillColor: [...color, 230] as [number, number, number, number],
getRadius: 5,
radiusMinPixels: 4,
radiusMaxPixels: 8,
}))
layers.push(new TextLayer({
id: `traj-labels-${pathId}`,
data: sorted,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getText: (d: { time: number }) => {
const dt = new Date(startDt.getTime() + d.time * 3600 * 1000)
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
},
getSize: 11,
getColor: [...color, 240] as [number, number, number, number],
getPixelOffset: [0, -14] as [number, number],
outlineWidth: 2,
outlineColor: [0, 0, 0, 180] as [number, number, number, number],
fontSettings: { sdf: true },
billboard: true,
}))
})
}
}
return layers
}, [trajectoryEntries])
// ── 민감자원 GeoJSON 레이어 ──────────────────────────
const sensLayer = useMemo(() => {
if (!sensitiveGeojson || sensCheckedCategories.size === 0) return null
const filtered = {
...sensitiveGeojson,
features: sensitiveGeojson.features.filter(
f => sensCheckedCategories.has((f.properties as Record<string, unknown>)?.['category'] as string ?? '')
),
}
if (filtered.features.length === 0) return null
return new GeoJsonLayer({
id: 'incidents-sensitive-geojson',
data: filtered,
pickable: false,
stroked: true,
filled: true,
pointRadiusMinPixels: 8,
lineWidthMinPixels: 1,
getFillColor: (f: { properties: Record<string, unknown> }) => {
const color = sensColorMap.get((f.properties['category'] as string) ?? '') ?? [128, 128, 128]
return [...color, 60] as [number, number, number, number]
},
getLineColor: (f: { properties: Record<string, unknown> }) => {
const color = sensColorMap.get((f.properties['category'] as string) ?? '') ?? [128, 128, 128]
return [...color, 180] as [number, number, number, number]
},
getLineWidth: 1,
updateTriggers: {
getFillColor: [sensColorMap],
getLineColor: [sensColorMap],
},
})
}, [sensitiveGeojson, sensCheckedCategories, sensColorMap])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(
() => [incidentLayer, vesselIconLayer, ...dischargeZoneLayers, ...measureDeckLayers],
[incidentLayer, vesselIconLayer, dischargeZoneLayers, measureDeckLayers],
() => [
incidentLayer, vesselIconLayer,
...dischargeZoneLayers,
...measureDeckLayers,
...trajectoryLayers,
...(sensLayer ? [sensLayer] : []),
],
[incidentLayer, vesselIconLayer, dischargeZoneLayers, measureDeckLayers, trajectoryLayers, sensLayer],
)
return (
@ -358,7 +614,7 @@ export function IncidentsView() {
{/* Default Map (visible when not in analysis or in overlay mode) */}
{(!analysisActive || viewMode === 'overlay') && (
<div className="absolute inset-0">
<Map
<MapLibre
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
mapStyle={BASE_STYLE}
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
@ -378,6 +634,7 @@ export function IncidentsView() {
cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined}
>
<DeckGLOverlay layers={deckLayers} />
<FlyToController incident={selectedIncident} />
<MeasureOverlay />
{/* 사고 팝업 */}
@ -410,7 +667,7 @@ export function IncidentsView() {
</div>
</Popup>
)}
</Map>
</MapLibre>
{/* 호버 툴팁 */}
{hoverInfo && (
@ -785,6 +1042,9 @@ export function IncidentsView() {
onRunAnalysis={handleRunAnalysis}
analysisActive={analysisActive}
onCloseAnalysis={handleCloseAnalysis}
onCheckedPredsChange={handleCheckedPredsChange}
onSensitiveDataChange={handleSensitiveDataChange}
selectedVessel={selectedVessel}
/>
</div>
)

파일 보기

@ -170,3 +170,30 @@ export async function fetchIncidentPredictions(sn: number): Promise<PredExecItem
const { data } = await api.get<PredExecItem[]>(`/incidents/${sn}/predictions`);
return data;
}
export interface NearbyOrgItem {
orgSn: number;
orgTp: string;
jrsdNm: string;
areaNm: string;
orgNm: string;
addr: string;
tel: string;
lat: number;
lng: number;
pinSize: string;
vesselCnt: number;
skimmerCnt: number;
pumpCnt: number;
vehicleCnt: number;
sprayerCnt: number;
totalAssets: number;
distanceNm: number;
}
export async function fetchNearbyOrgs(lat: number, lng: number, radiusNm: number): Promise<NearbyOrgItem[]> {
const { data } = await api.get<NearbyOrgItem[]>('/assets/orgs/nearby', {
params: { lat, lng, radius: radiusNm },
});
return data;
}

파일 보기

@ -218,19 +218,6 @@ export function OilSpillView() {
const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
const analysisCircleRadiusM = circleRadiusNm * 1852
// 분석 탭 초기 진입 시 기본 데모 자동 표시
useEffect(() => {
if (activeSubTab === 'analysis' && oilTrajectory.length === 0 && !selectedAnalysis) {
const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set<PredictionModel>(['OpenDrift']))
const coord = incidentCoord ?? { lat: 37.39, lon: 126.64 }
const demoTrajectory = generateDemoTrajectory(coord, models, predictionTime)
setOilTrajectory(demoTrajectory)
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
setBoomLines(demoBooms)
setSensitiveResources([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSubTab])
const handleToggleLayer = (layerId: string, enabled: boolean) => {
setEnabledLayers(prev => {

파일 보기

@ -84,6 +84,7 @@ export interface BacktrackResult {
export const fetchPredictionAnalyses = async (params?: {
search?: string;
acdntSn?: number;
}): Promise<PredictionAnalysis[]> => {
const response = await api.get<PredictionAnalysis[]>('/prediction/analyses', { params });
return response.data;

파일 보기

@ -51,7 +51,7 @@ export interface OilSpillReportData {
}
// eslint-disable-next-line react-refresh/only-export-components
export function createEmptyReport(): OilSpillReportData {
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 {
@ -62,7 +62,7 @@ export function createEmptyReport(): OilSpillReportData {
author: '',
reportType: '예측보고서',
analysisCategory: '',
jurisdiction: '남해청',
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: '' }],

파일 보기

@ -1,8 +1,10 @@
import { useState, useEffect } from 'react';
import {
createEmptyReport,
type Jurisdiction,
} from './OilSpillReportTemplate';
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
import { useAuthStore } from '@common/store/authStore';
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore';
import OilSpreadMapPanel from './OilSpreadMapPanel';
import { saveReport } from '../services/reportsApi';
@ -18,6 +20,7 @@ interface ReportGeneratorProps {
}
function ReportGenerator({ onSave }: ReportGeneratorProps) {
const user = useAuthStore(s => s.user);
const [activeCat, setActiveCat] = useState<ReportCategory>(() => {
const hint = consumeReportGenCategory()
return (hint === 0 || hint === 1 || hint === 2) ? hint : 0
@ -67,7 +70,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
}
const handleSave = async () => {
const report = createEmptyReport()
const report = createEmptyReport((user?.org?.abbr || '') as Jurisdiction)
report.reportType = activeCat === 0 ? '유출유 보고' : activeCat === 1 ? '종합보고서' : '초기보고서'
report.analysisCategory = activeCat === 0 ? '유출유 확산예측' : activeCat === 1 ? 'HNS 대기확산' : '긴급구난'
report.title = cat.reportName

파일 보기

@ -4,6 +4,7 @@ import {
type ReportType,
type Jurisdiction,
} from './OilSpillReportTemplate';
import { useAuthStore } from '@common/store/authStore';
import { templateTypes } from './reportTypes';
import { generateReportHTML, exportAsPDF, exportAsHWP } from './reportUtils';
import { saveReport } from '../services/reportsApi';
@ -14,6 +15,7 @@ interface TemplateFormEditorProps {
}
function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
const user = useAuthStore(s => s.user);
const [selectedType, setSelectedType] = useState<ReportType>('초기보고서')
const [formData, setFormData] = useState<Record<string, string>>({})
const [reportMeta, setReportMeta] = useState(() => {
@ -21,7 +23,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
return {
title: '',
author: '',
jurisdiction: '남해청' as Jurisdiction,
jurisdiction: (user?.org?.abbr || '') as Jurisdiction,
writeTime: `${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')}`,
}
})
@ -42,9 +44,8 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
}
const handleSave = async () => {
const report = createEmptyReport()
const report = createEmptyReport(reportMeta.jurisdiction)
report.reportType = selectedType
report.jurisdiction = reportMeta.jurisdiction
report.author = reportMeta.author
report.title = formData['incident.name'] || `${selectedType} ${reportMeta.writeTime}`
report.status = '완료'

파일 보기

@ -288,7 +288,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
author: item.authorName || '',
reportType: (item.tmplCd ? TMPL_CODE_TO_TYPE[item.tmplCd] : '초기보고서') || '초기보고서',
analysisCategory: (item.ctgrCd ? CTGR_CODE_TO_CAT[item.ctgrCd] : '') || '',
jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청',
jurisdiction: (item.jrsdCd as Jurisdiction) || '',
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
hasMapCapture: item.hasMapCapture,
acdntSn: item.acdntSn ?? undefined,