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
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 34s
This commit is contained in:
커밋
bc7e966cb1
@ -83,5 +83,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"allow": []
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
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();
|
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 — 기관 상세 (장비 + 담당자)
|
// 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 — 분석 목록
|
// GET /api/prediction/analyses — 분석 목록
|
||||||
router.get('/analyses', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
router.get('/analyses', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { search } = req.query;
|
const { search, acdntSn } = req.query;
|
||||||
const items = await listAnalyses({ search: search as string | undefined });
|
const items = await listAnalyses({
|
||||||
|
search: search as string | undefined,
|
||||||
|
acdntSn: acdntSn ? parseInt(acdntSn as string, 10) : undefined,
|
||||||
|
});
|
||||||
res.json(items);
|
res.json(items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[prediction] 분석 목록 오류:', err);
|
console.error('[prediction] 분석 목록 오류:', err);
|
||||||
|
|||||||
@ -115,12 +115,18 @@ interface BoomLineItem {
|
|||||||
|
|
||||||
interface ListAnalysesInput {
|
interface ListAnalysesInput {
|
||||||
search?: string;
|
search?: string;
|
||||||
|
acdntSn?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
|
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
const conditions: string[] = ["A.USE_YN = 'Y'"];
|
const conditions: string[] = ["A.USE_YN = 'Y'"];
|
||||||
|
|
||||||
|
if (input.acdntSn) {
|
||||||
|
params.push(input.acdntSn);
|
||||||
|
conditions.push(`A.ACDNT_SN = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (input.search) {
|
if (input.search) {
|
||||||
params.push(`%${input.search}%`);
|
params.push(`%${input.search}%`);
|
||||||
conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`);
|
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.KOSPS_STATUS,
|
||||||
P.POSEIDON_STATUS,
|
P.POSEIDON_STATUS,
|
||||||
P.OPENDRIFT_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
|
FROM ACDNT A
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
@ -157,6 +165,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
|||||||
PRED_RUN_SN,
|
PRED_RUN_SN,
|
||||||
MIN(BGNG_DTM) AS RUN_DTM,
|
MIN(BGNG_DTM) AS RUN_DTM,
|
||||||
MIN(SPIL_DATA_SN) AS SPIL_DATA_SN,
|
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 = '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 = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS,
|
||||||
MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_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
|
GROUP BY ACDNT_SN, PRED_RUN_SN
|
||||||
) P ON P.ACDNT_SN = A.ACDNT_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 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 (
|
LEFT JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
ACDNT_SN,
|
ACDNT_SN,
|
||||||
@ -193,8 +204,8 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<Prediction
|
|||||||
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
|
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
|
||||||
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
|
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
|
||||||
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
||||||
analyst: String(row['analyst_nm'] ?? ''),
|
analyst: String(row['resolved_analyst'] ?? ''),
|
||||||
officeName: String(row['office_nm'] ?? ''),
|
officeName: String(row['resolved_office'] ?? ''),
|
||||||
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
|
acdntSttsCd: String(row['acdnt_stts_cd'] ?? 'ACTIVE'),
|
||||||
predRunSn: row['pred_run_sn'] != null ? Number(row['pred_run_sn']) : null,
|
predRunSn: row['pred_run_sn'] != null ? Number(row['pred_run_sn']) : null,
|
||||||
runDtm: row['run_dtm'] ? String(row['run_dtm']) : null,
|
runDtm: row['run_dtm'] ? String(row['run_dtm']) : null,
|
||||||
|
|||||||
@ -242,10 +242,10 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const kospsExecNm = `${execNmBase}_KOSPS`
|
const kospsExecNm = `${execNmBase}_KOSPS`
|
||||||
const insertRes = await wingPool.query(
|
const insertRes = await wingPool.query(
|
||||||
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM)
|
`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, NOW())
|
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW())
|
||||||
RETURNING PRED_EXEC_SN`,
|
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 })
|
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
@ -267,10 +267,10 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => {
|
|||||||
let predExecSn: number
|
let predExecSn: number
|
||||||
try {
|
try {
|
||||||
const insertRes = await wingPool.query(
|
const insertRes = await wingPool.query(
|
||||||
`INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM)
|
`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, NOW())
|
VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW())
|
||||||
RETURNING PRED_EXEC_SN`,
|
RETURNING PRED_EXEC_SN`,
|
||||||
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm]
|
[resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, req.user!.sub]
|
||||||
)
|
)
|
||||||
predExecSn = insertRes.rows[0].pred_exec_sn as number
|
predExecSn = insertRes.rows[0].pred_exec_sn as number
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
@ -589,10 +589,10 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const kospsExecNm = `${execNmBase}_KOSPS`
|
const kospsExecNm = `${execNmBase}_KOSPS`
|
||||||
const insertRes = await wingPool.query(
|
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)
|
`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, NOW())
|
VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, $5, NOW())
|
||||||
RETURNING PRED_EXEC_SN`,
|
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 })
|
execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number })
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
@ -626,10 +626,10 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => {
|
|||||||
let predExecSn: number
|
let predExecSn: number
|
||||||
try {
|
try {
|
||||||
const insertRes = await wingPool.query(
|
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)
|
`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, NOW())
|
VALUES ($1, $2, $3, 'PENDING', $4, $5, $6, NOW())
|
||||||
RETURNING PRED_EXEC_SN`,
|
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
|
predExecSn = insertRes.rows[0].pred_exec_sn as number
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
|
|||||||
@ -307,7 +307,6 @@ export async function listOrgs(): Promise<OrgItem[]> {
|
|||||||
const { rows } = await authPool.query(
|
const { rows } = await authPool.query(
|
||||||
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
|
`SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN
|
||||||
FROM AUTH_ORG
|
FROM AUTH_ORG
|
||||||
WHERE USE_YN = 'Y'
|
|
||||||
ORDER BY ORG_SN`
|
ORDER BY ORG_SN`
|
||||||
)
|
)
|
||||||
return rows.map((r: Record<string, unknown>) => ({
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
|
|||||||
3
database/migration/029_pred_exec_user.sql
Normal file
3
database/migration/029_pred_exec_user.sql
Normal file
@ -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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-03-25.2]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑)
|
||||||
|
- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin)
|
||||||
|
- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용
|
||||||
|
- 사고: 선택된 항목 재클릭 시 선택 해제 지원
|
||||||
|
|
||||||
## [2026-03-25]
|
## [2026-03-25]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export interface Incident {
|
|||||||
interface IncidentsLeftPanelProps {
|
interface IncidentsLeftPanelProps {
|
||||||
incidents: Incident[]
|
incidents: Incident[]
|
||||||
selectedIncidentId: string | null
|
selectedIncidentId: string | null
|
||||||
onIncidentSelect: (id: string) => void
|
onIncidentSelect: (id: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const
|
const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const
|
||||||
@ -290,7 +290,7 @@ export function IncidentsLeftPanel({
|
|||||||
active: '대응중', investigating: '조사중', closed: '종료',
|
active: '대응중', investigating: '조사중', closed: '종료',
|
||||||
}
|
}
|
||||||
return (
|
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"
|
className="px-4 py-3 border-b border-border cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
background: isSel ? 'rgba(6,182,212,0.04)' : undefined,
|
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 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'
|
export type ViewMode = 'overlay' | 'split2' | 'split3'
|
||||||
|
|
||||||
@ -20,10 +24,11 @@ interface IncidentsRightPanelProps {
|
|||||||
onRunAnalysis: (sections: AnalysisSection[], sensitiveCount: number) => void
|
onRunAnalysis: (sections: AnalysisSection[], sensitiveCount: number) => void
|
||||||
analysisActive: boolean
|
analysisActive: boolean
|
||||||
onCloseAnalysis: () => void
|
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 {
|
interface AnalysisItem {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -31,95 +36,184 @@ interface AnalysisItem {
|
|||||||
checked: boolean
|
checked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_DATA: {
|
/* ── 카테고리별 고유 색상 (목록 순서 인덱스 기반 — 중복 없음) ── */
|
||||||
key: string
|
const CATEGORY_PALETTE: [number, number, number][] = [
|
||||||
icon: string
|
[239, 68, 68 ], // red
|
||||||
title: string
|
[249, 115, 22 ], // orange
|
||||||
color: string
|
[234, 179, 8 ], // yellow
|
||||||
colorRgb: string
|
[132, 204, 22 ], // lime
|
||||||
totalLabel: string
|
[20, 184, 166], // teal
|
||||||
items: AnalysisItem[]
|
[6, 182, 212], // cyan
|
||||||
}[] = [
|
[59, 130, 246], // blue
|
||||||
{
|
[99, 102, 241], // indigo
|
||||||
key: 'oil',
|
[168, 85, 247], // purple
|
||||||
icon: '🛢',
|
[236, 72, 153], // pink
|
||||||
title: '유출유 확산예측',
|
[244, 63, 94 ], // rose
|
||||||
color: '#f97316',
|
[16, 185, 129], // emerald
|
||||||
colorRgb: '249,115,22',
|
[14, 165, 233], // sky
|
||||||
totalLabel: '전체 8건',
|
[139, 92, 246], // violet
|
||||||
items: [
|
[217, 119, 6 ], // amber
|
||||||
{ id: 'o1', name: '여수항 BUNKER-C 확산', sub: 'KOSPS+OpenDrift · 150kL', checked: true },
|
[45, 212, 191], // turquoise
|
||||||
{ 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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SensitiveResource {
|
function getCategoryColor(index: number): [number, number, number] {
|
||||||
id: string
|
return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length]
|
||||||
name: string
|
|
||||||
area: string
|
|
||||||
checked: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_SENSITIVE: SensitiveResource[] = [
|
/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반) ── */
|
||||||
{ id: 's1', name: '돌산 어장 (김·전복)', area: '131ha', checked: true },
|
const CATEGORY_ICON: Record<string, string> = {
|
||||||
{ 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 },
|
'인공어초': '🪸', '암초': '🪨', '침선': '🚢',
|
||||||
|
'해수욕장': '🏖', '갯바위낚시': '🪨', '선상낚시': '🚤', '마리나항': '⛵',
|
||||||
|
'무역항': '🚢', '연안항': '⛵', '국가어항': '⚓', '지방어항': '⚓',
|
||||||
|
'어항': '⚓', '항만구역': '⚓', '항로': '🚢', '정박지': '⛵',
|
||||||
|
'항로표지': '🔴', '해수취수시설': '💧', '취수구·배수구': '🚰',
|
||||||
|
'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 ───────────────────────────────────── */
|
/* ── Component ───────────────────────────────────── */
|
||||||
|
|
||||||
export function IncidentsRightPanel({
|
export function IncidentsRightPanel({
|
||||||
incident, viewMode, onViewModeChange, onRunAnalysis, analysisActive, onCloseAnalysis
|
incident, viewMode, onViewModeChange, onRunAnalysis, analysisActive, onCloseAnalysis,
|
||||||
|
onCheckedPredsChange, onSensitiveDataChange, selectedVessel,
|
||||||
}: IncidentsRightPanelProps) {
|
}: IncidentsRightPanelProps) {
|
||||||
const [sections, setSections] = useState(SECTION_DATA)
|
const [predItems, setPredItems] = useState<PredictionAnalysis[]>([])
|
||||||
const [sensitive, setSensitive] = useState(MOCK_SENSITIVE)
|
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 [nearbyRadius, setNearbyRadius] = useState(50)
|
||||||
|
const [nearbyOrgs, setNearbyOrgs] = useState<NearbyOrgItem[]>([])
|
||||||
|
const [nearbyLoading, setNearbyLoading] = useState(false)
|
||||||
|
|
||||||
const toggleItem = (sectionKey: string, itemId: string) => {
|
useEffect(() => {
|
||||||
setSections(prev =>
|
if (!incident) {
|
||||||
prev.map(s =>
|
void Promise.resolve().then(() => {
|
||||||
s.key === sectionKey
|
setPredItems([])
|
||||||
? { ...s, items: s.items.map(it => (it.id === itemId ? { ...it, checked: !it.checked } : it)) }
|
setSensCategories([])
|
||||||
: s
|
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) => {
|
const removePredItem = (id: string) => {
|
||||||
setSections(prev =>
|
setPredItems(prev => {
|
||||||
prev.map(s =>
|
const next = prev.filter(p => String(p.predRunSn ?? p.acdntSn) !== id)
|
||||||
s.key === sectionKey ? { ...s, items: s.items.filter(it => it.id !== itemId) } : s
|
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) => {
|
const toggleSensCategory = (category: string) => {
|
||||||
setSensitive(prev => prev.map(s => (s.id === id ? { ...s, checked: !s.checked } : s)))
|
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) {
|
if (!incident) {
|
||||||
@ -147,18 +241,17 @@ export function IncidentsRightPanel({
|
|||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* 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' }}>
|
<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
|
const checkedCount = sec.items.filter(it => it.checked).length
|
||||||
return (
|
return (
|
||||||
<div key={sec.key} className="bg-bg-2 border border-border rounded-md p-2.5">
|
<div className="bg-bg-2 border border-border rounded-md p-2.5">
|
||||||
{/* Section Header */}
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">{sec.icon}</span>
|
<span className="text-sm">{sec.icon}</span>
|
||||||
<span className="text-xs font-bold" style={{ color: sec.color }}>
|
<span className="text-xs font-bold" style={{ color: sec.color }}>{sec.title}</span>
|
||||||
{sec.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button className="text-[10px] font-semibold cursor-pointer"
|
<button className="text-[10px] font-semibold cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
@ -170,77 +263,108 @@ export function IncidentsRightPanel({
|
|||||||
📋 조회
|
📋 조회
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items */}
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{sec.items.map(item => (
|
{sec.items.length === 0 ? (
|
||||||
<div key={item.id} className="flex items-center gap-1.5"
|
<div className="text-[9px] text-text-3 text-center py-1.5">예측 실행 이력이 없습니다</div>
|
||||||
style={{
|
) : (
|
||||||
padding: '5px 8px',
|
sec.items.map(item => (
|
||||||
background: `rgba(${sec.colorRgb},0.06)`,
|
<div key={item.id} className="flex items-center gap-1.5"
|
||||||
border: `1px solid rgba(${sec.colorRgb},0.15)`,
|
style={{
|
||||||
borderRadius: '4px',
|
padding: '5px 8px',
|
||||||
}}>
|
background: `rgba(${sec.colorRgb},0.06)`,
|
||||||
<input
|
border: `1px solid rgba(${sec.colorRgb},0.15)`,
|
||||||
type="checkbox"
|
borderRadius: '4px',
|
||||||
checked={item.checked}
|
}}>
|
||||||
onChange={() => toggleItem(sec.key, item.id)}
|
<input
|
||||||
className="shrink-0"
|
type="checkbox"
|
||||||
style={{ accentColor: sec.color }}
|
checked={item.checked}
|
||||||
/>
|
onChange={() => togglePredItem(item.id)}
|
||||||
<div className="flex-1 min-w-0">
|
className="shrink-0"
|
||||||
<div className="text-[10px] font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
|
style={{ accentColor: sec.color }}
|
||||||
{item.name}
|
/>
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-text-3 font-mono text-[8px]">
|
<div className="text-[10px] font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
|
||||||
{item.sub}
|
{item.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-text-3 font-mono text-[8px]">{item.sub}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
onClick={() => removePredItem(item.id)}
|
||||||
|
title="제거"
|
||||||
|
className="text-[10px] cursor-pointer text-text-3 shrink-0"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
))
|
||||||
onClick={() => removeItem(sec.key, item.id)}
|
)}
|
||||||
title="제거"
|
|
||||||
className="text-[10px] cursor-pointer text-text-3 shrink-0"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-text-3">
|
<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}
|
선택: <b style={{ color: sec.color }}>{checkedCount}건</b> · {sec.totalLabel}
|
||||||
</div>
|
</div>
|
||||||
</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="bg-bg-2 border border-border rounded-md p-2.5">
|
||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<span className="text-sm">🐟</span>
|
<span className="text-sm">🐟</span>
|
||||||
<span className="text-xs font-bold text-[#22c55e]">
|
<span className="text-xs font-bold text-[#22c55e]">민감자원</span>
|
||||||
민감자원
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-[3px]">
|
<div className="flex flex-col gap-[3px]">
|
||||||
{sensitive.map(res => (
|
{sensCategories.length === 0 ? (
|
||||||
<label key={res.id} className="flex items-center cursor-pointer text-[9px] gap-[5px] rounded-[3px]"
|
<div className="text-[9px] text-text-3 text-center py-1.5">해당 사고 영역의 민감자원이 없습니다</div>
|
||||||
style={{
|
) : (
|
||||||
padding: '4px 6px', background: 'rgba(34,197,94,0.06)',
|
sensCategories.map((cat, i) => {
|
||||||
}}>
|
const icon = CATEGORY_ICON[cat.category] ?? '🌊'
|
||||||
<input
|
const areaLabel = cat.totalArea != null
|
||||||
type="checkbox"
|
? `${cat.totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}ha`
|
||||||
checked={res.checked}
|
: `${cat.count}개소`
|
||||||
onChange={() => toggleSensitive(res.id)}
|
const [r, g, b] = getCategoryColor(i)
|
||||||
style={{ accentColor: 'var(--green)' }}
|
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||||
/>
|
return (
|
||||||
{res.name}
|
<label key={cat.category} className="flex items-center cursor-pointer text-[9px] gap-[5px] rounded-[3px]"
|
||||||
{res.area && (
|
style={{ padding: '4px 6px', background: `rgba(${r},${g},${b},0.06)` }}>
|
||||||
<span className="text-text-3 font-mono">({res.area})</span>
|
<input
|
||||||
)}
|
type="checkbox"
|
||||||
</label>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -251,13 +375,44 @@ export function IncidentsRightPanel({
|
|||||||
<span className="text-xs font-bold text-[#f59e0b]">
|
<span className="text-xs font-bold text-[#f59e0b]">
|
||||||
근처 방제자원
|
근처 방제자원
|
||||||
</span>
|
</span>
|
||||||
|
{nearbyOrgs.length > 0 && (
|
||||||
|
<span className="ml-auto text-[9px] font-mono text-[#f59e0b]">{nearbyOrgs.length}개</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty state */}
|
{!selectedVessel ? (
|
||||||
<div className="py-2.5 text-center text-text-3 text-[10px] leading-[1.7]">
|
<div className="py-2.5 text-center text-text-3 text-[10px] leading-[1.7]">
|
||||||
<div className="text-xl mb-1 opacity-40">🚢</div>
|
<div className="text-xl mb-1 opacity-40">🚢</div>
|
||||||
지도에서 선박을 클릭하면<br />부근 방제자원이 표시됩니다
|
지도에서 선박을 클릭하면<br />부근 방제자원이 표시됩니다
|
||||||
</div>
|
</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 */}
|
{/* Radius slider */}
|
||||||
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(245,158,11,0.1)' }}>
|
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(245,158,11,0.1)' }}>
|
||||||
@ -313,8 +468,11 @@ export function IncidentsRightPanel({
|
|||||||
{/* Execute */}
|
{/* Execute */}
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
if (analysisActive) { onCloseAnalysis(); return }
|
if (analysisActive) { onCloseAnalysis(); return }
|
||||||
const checkedSections = sections.filter(s => s.items.some(it => it.checked))
|
const checkedOilItems = oilSection.items.filter(it => it.checked)
|
||||||
const sensChecked = sensitive.filter(s => s.checked).length
|
const checkedSections = checkedOilItems.length > 0
|
||||||
|
? [{ ...oilSection, items: checkedOilItems }]
|
||||||
|
: []
|
||||||
|
const sensChecked = checkedSensCategories.size
|
||||||
onRunAnalysis(checkedSections, sensChecked)
|
onRunAnalysis(checkedSections, sensChecked)
|
||||||
}} className="w-full text-[11px] font-bold cursor-pointer"
|
}} className="w-full text-[11px] font-bold cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { Map, Popup, useControl } from '@vis.gl/react-maplibre'
|
import { Map as MapLibre, Popup, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
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 { PathStyleExtension } from '@deck.gl/extensions'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
import type { StyleSpecification } from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
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 { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
|
||||||
import { fetchIncidents } from '../services/incidentsApi'
|
import { fetchIncidents } from '../services/incidentsApi'
|
||||||
import type { IncidentCompat } 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 { DischargeZonePanel } from './DischargeZonePanel'
|
||||||
import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData'
|
import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData'
|
||||||
import { useMapStore } from '@common/store/mapStore'
|
import { useMapStore } from '@common/store/mapStore'
|
||||||
@ -17,6 +19,30 @@ import { useMeasureTool } from '@common/hooks/useMeasureTool'
|
|||||||
import { buildMeasureLayers } from '@common/components/map/measureLayers'
|
import { buildMeasureLayers } from '@common/components/map/measureLayers'
|
||||||
import { MeasureOverlay } from '@common/components/map/MeasureOverlay'
|
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 베이스맵 (밝은 테마) ────────────────
|
// ── CartoDB Positron 베이스맵 (밝은 테마) ────────────────
|
||||||
const BASE_STYLE: StyleSpecification = {
|
const BASE_STYLE: StyleSpecification = {
|
||||||
version: 8,
|
version: 8,
|
||||||
@ -43,6 +69,25 @@ function DeckGLOverlay({ layers }: { layers: any[] }) {
|
|||||||
return null
|
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] {
|
function getMarkerColor(s: string): [number, number, number, number] {
|
||||||
if (s === 'active') return [239, 68, 68, 204]
|
if (s === 'active') return [239, 68, 68, 204]
|
||||||
@ -111,15 +156,26 @@ export function IncidentsView() {
|
|||||||
const [analysisActive, setAnalysisActive] = useState(false)
|
const [analysisActive, setAnalysisActive] = useState(false)
|
||||||
const [analysisTags, setAnalysisTags] = useState<{ icon: string; label: string; color: string }[]>([])
|
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(() => {
|
useEffect(() => {
|
||||||
fetchIncidents().then(data => {
|
fetchIncidents().then(data => {
|
||||||
setIncidents(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 selectedIncident = incidents.find(i => i.id === selectedIncidentId) ?? null
|
||||||
|
|
||||||
const handleRunAnalysis = (sections: AnalysisSection[], sensitiveCount: number) => {
|
const handleRunAnalysis = (sections: AnalysisSection[], sensitiveCount: number) => {
|
||||||
@ -140,6 +196,35 @@ export function IncidentsView() {
|
|||||||
setAnalysisTags([])
|
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) ──────────────────────
|
// ── 사고 마커 (ScatterplotLayer) ──────────────────────
|
||||||
const incidentLayer = useMemo(
|
const incidentLayer = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -159,12 +244,17 @@ export function IncidentsView() {
|
|||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: { object?: IncidentCompat; coordinate?: number[] }) => {
|
onClick: (info: { object?: IncidentCompat; coordinate?: number[] }) => {
|
||||||
if (info.object && info.coordinate) {
|
if (info.object && info.coordinate) {
|
||||||
setSelectedIncidentId(info.object.id)
|
const newId = selectedIncidentId === info.object.id ? null : info.object.id
|
||||||
setIncidentPopup({
|
setSelectedIncidentId(newId)
|
||||||
longitude: info.coordinate[0],
|
if (newId) {
|
||||||
latitude: info.coordinate[1],
|
setIncidentPopup({
|
||||||
incident: info.object,
|
longitude: info.coordinate[0],
|
||||||
})
|
latitude: info.coordinate[1],
|
||||||
|
incident: info.object,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setIncidentPopup(null)
|
||||||
|
}
|
||||||
setVesselPopup(null)
|
setVesselPopup(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -264,10 +354,176 @@ export function IncidentsView() {
|
|||||||
[measureInProgress, measureMode, measurements],
|
[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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const deckLayers: any[] = useMemo(
|
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 (
|
return (
|
||||||
@ -358,7 +614,7 @@ export function IncidentsView() {
|
|||||||
{/* Default Map (visible when not in analysis or in overlay mode) */}
|
{/* Default Map (visible when not in analysis or in overlay mode) */}
|
||||||
{(!analysisActive || viewMode === 'overlay') && (
|
{(!analysisActive || viewMode === 'overlay') && (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<Map
|
<MapLibre
|
||||||
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
|
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
|
||||||
mapStyle={BASE_STYLE}
|
mapStyle={BASE_STYLE}
|
||||||
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
|
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
|
||||||
@ -378,6 +634,7 @@ export function IncidentsView() {
|
|||||||
cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined}
|
cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined}
|
||||||
>
|
>
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
|
<FlyToController incident={selectedIncident} />
|
||||||
<MeasureOverlay />
|
<MeasureOverlay />
|
||||||
|
|
||||||
{/* 사고 팝업 */}
|
{/* 사고 팝업 */}
|
||||||
@ -410,7 +667,7 @@ export function IncidentsView() {
|
|||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
)}
|
)}
|
||||||
</Map>
|
</MapLibre>
|
||||||
|
|
||||||
{/* 호버 툴팁 */}
|
{/* 호버 툴팁 */}
|
||||||
{hoverInfo && (
|
{hoverInfo && (
|
||||||
@ -785,6 +1042,9 @@ export function IncidentsView() {
|
|||||||
onRunAnalysis={handleRunAnalysis}
|
onRunAnalysis={handleRunAnalysis}
|
||||||
analysisActive={analysisActive}
|
analysisActive={analysisActive}
|
||||||
onCloseAnalysis={handleCloseAnalysis}
|
onCloseAnalysis={handleCloseAnalysis}
|
||||||
|
onCheckedPredsChange={handleCheckedPredsChange}
|
||||||
|
onSensitiveDataChange={handleSensitiveDataChange}
|
||||||
|
selectedVessel={selectedVessel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -170,3 +170,30 @@ export async function fetchIncidentPredictions(sn: number): Promise<PredExecItem
|
|||||||
const { data } = await api.get<PredExecItem[]>(`/incidents/${sn}/predictions`);
|
const { data } = await api.get<PredExecItem[]>(`/incidents/${sn}/predictions`);
|
||||||
return data;
|
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 analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null
|
||||||
const analysisCircleRadiusM = circleRadiusNm * 1852
|
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) => {
|
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
||||||
setEnabledLayers(prev => {
|
setEnabledLayers(prev => {
|
||||||
|
|||||||
@ -84,6 +84,7 @@ export interface BacktrackResult {
|
|||||||
|
|
||||||
export const fetchPredictionAnalyses = async (params?: {
|
export const fetchPredictionAnalyses = async (params?: {
|
||||||
search?: string;
|
search?: string;
|
||||||
|
acdntSn?: number;
|
||||||
}): Promise<PredictionAnalysis[]> => {
|
}): Promise<PredictionAnalysis[]> => {
|
||||||
const response = await api.get<PredictionAnalysis[]>('/prediction/analyses', { params });
|
const response = await api.get<PredictionAnalysis[]>('/prediction/analyses', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export interface OilSpillReportData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export function createEmptyReport(): OilSpillReportData {
|
export function createEmptyReport(jurisdiction?: Jurisdiction): OilSpillReportData {
|
||||||
const now = new Date()
|
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')}`
|
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 {
|
return {
|
||||||
@ -62,7 +62,7 @@ export function createEmptyReport(): OilSpillReportData {
|
|||||||
author: '',
|
author: '',
|
||||||
reportType: '예측보고서',
|
reportType: '예측보고서',
|
||||||
analysisCategory: '',
|
analysisCategory: '',
|
||||||
jurisdiction: '남해청',
|
jurisdiction: jurisdiction || '',
|
||||||
status: '수행중',
|
status: '수행중',
|
||||||
incident: { name: '', writeTime: ts, shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
|
incident: { name: '', writeTime: ts, shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
|
||||||
tide: [{ date: '', tideType: '', lowTide1: '', highTide1: '', lowTide2: '', highTide2: '' }],
|
tide: [{ date: '', tideType: '', lowTide1: '', highTide1: '', lowTide2: '', highTide2: '' }],
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
createEmptyReport,
|
createEmptyReport,
|
||||||
|
type Jurisdiction,
|
||||||
} from './OilSpillReportTemplate';
|
} from './OilSpillReportTemplate';
|
||||||
import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu';
|
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 { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore';
|
||||||
import OilSpreadMapPanel from './OilSpreadMapPanel';
|
import OilSpreadMapPanel from './OilSpreadMapPanel';
|
||||||
import { saveReport } from '../services/reportsApi';
|
import { saveReport } from '../services/reportsApi';
|
||||||
@ -18,6 +20,7 @@ interface ReportGeneratorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||||
|
const user = useAuthStore(s => s.user);
|
||||||
const [activeCat, setActiveCat] = useState<ReportCategory>(() => {
|
const [activeCat, setActiveCat] = useState<ReportCategory>(() => {
|
||||||
const hint = consumeReportGenCategory()
|
const hint = consumeReportGenCategory()
|
||||||
return (hint === 0 || hint === 1 || hint === 2) ? hint : 0
|
return (hint === 0 || hint === 1 || hint === 2) ? hint : 0
|
||||||
@ -67,7 +70,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const report = createEmptyReport()
|
const report = createEmptyReport((user?.org?.abbr || '') as Jurisdiction)
|
||||||
report.reportType = activeCat === 0 ? '유출유 보고' : activeCat === 1 ? '종합보고서' : '초기보고서'
|
report.reportType = activeCat === 0 ? '유출유 보고' : activeCat === 1 ? '종합보고서' : '초기보고서'
|
||||||
report.analysisCategory = activeCat === 0 ? '유출유 확산예측' : activeCat === 1 ? 'HNS 대기확산' : '긴급구난'
|
report.analysisCategory = activeCat === 0 ? '유출유 확산예측' : activeCat === 1 ? 'HNS 대기확산' : '긴급구난'
|
||||||
report.title = cat.reportName
|
report.title = cat.reportName
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
type ReportType,
|
type ReportType,
|
||||||
type Jurisdiction,
|
type Jurisdiction,
|
||||||
} from './OilSpillReportTemplate';
|
} from './OilSpillReportTemplate';
|
||||||
|
import { useAuthStore } from '@common/store/authStore';
|
||||||
import { templateTypes } from './reportTypes';
|
import { templateTypes } from './reportTypes';
|
||||||
import { generateReportHTML, exportAsPDF, exportAsHWP } from './reportUtils';
|
import { generateReportHTML, exportAsPDF, exportAsHWP } from './reportUtils';
|
||||||
import { saveReport } from '../services/reportsApi';
|
import { saveReport } from '../services/reportsApi';
|
||||||
@ -14,6 +15,7 @@ interface TemplateFormEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||||
|
const user = useAuthStore(s => s.user);
|
||||||
const [selectedType, setSelectedType] = useState<ReportType>('초기보고서')
|
const [selectedType, setSelectedType] = useState<ReportType>('초기보고서')
|
||||||
const [formData, setFormData] = useState<Record<string, string>>({})
|
const [formData, setFormData] = useState<Record<string, string>>({})
|
||||||
const [reportMeta, setReportMeta] = useState(() => {
|
const [reportMeta, setReportMeta] = useState(() => {
|
||||||
@ -21,7 +23,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
|||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
author: '',
|
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')}`,
|
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 handleSave = async () => {
|
||||||
const report = createEmptyReport()
|
const report = createEmptyReport(reportMeta.jurisdiction)
|
||||||
report.reportType = selectedType
|
report.reportType = selectedType
|
||||||
report.jurisdiction = reportMeta.jurisdiction
|
|
||||||
report.author = reportMeta.author
|
report.author = reportMeta.author
|
||||||
report.title = formData['incident.name'] || `${selectedType} ${reportMeta.writeTime}`
|
report.title = formData['incident.name'] || `${selectedType} ${reportMeta.writeTime}`
|
||||||
report.status = '완료'
|
report.status = '완료'
|
||||||
|
|||||||
@ -288,7 +288,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport
|
|||||||
author: item.authorName || '',
|
author: item.authorName || '',
|
||||||
reportType: (item.tmplCd ? TMPL_CODE_TO_TYPE[item.tmplCd] : '초기보고서') || '초기보고서',
|
reportType: (item.tmplCd ? TMPL_CODE_TO_TYPE[item.tmplCd] : '초기보고서') || '초기보고서',
|
||||||
analysisCategory: (item.ctgrCd ? CTGR_CODE_TO_CAT[item.ctgrCd] : '') || '',
|
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] || '테스트',
|
status: CODE_TO_STATUS[item.sttsCd] || '테스트',
|
||||||
hasMapCapture: item.hasMapCapture,
|
hasMapCapture: item.hasMapCapture,
|
||||||
acdntSn: item.acdntSn ?? undefined,
|
acdntSn: item.acdntSn ?? undefined,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user