refactor(phase4): HNS 물질정보 DB 이전 + 정적 데이터 정리

- HNS_SUBSTANCE 테이블 마이그레이션 SQL 추가 (002_hns_substance.sql)
- HNS 검색/상세 API 구현 (hnsRouter, hnsService)
- HNS 시드 스크립트 추가 (seedHns.ts, 20종 물질 데이터)
- 프론트엔드 HNSSubstanceView: 정적 HNS_SEARCH_DB → API 호출 전환
- HNSSearchSubstance 타입 common/types/hns.ts로 분리
- Mock 데이터 이동: data/ → common/mock/ (vesselMockData, backtrackMockData)
- layerDatabase.ts → common/services/layerService.ts 이동
- layerData.ts → common/data/layerData.ts 이동
- scat/index.ts 누락 수정 + .gitignore scat 규칙 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-28 14:52:46 +09:00
부모 7b6c2652f0
커밋 63645e9f85
20개의 변경된 파일415개의 추가작업 그리고 36개의 파일을 삭제

2
.gitignore vendored
파일 보기

@ -29,7 +29,7 @@ backend/data/*.db-wal
# Large reference data (keep locally, do not commit)
_reference/
scat/
/scat/
참고용/
논문/

63
backend/src/db/seedHns.ts Normal file
파일 보기

@ -0,0 +1,63 @@
import 'dotenv/config'
import { wingPool } from './wingDb.js'
// 프론트엔드 정적 데이터를 직접 import (tsx로 실행)
import { HNS_SEARCH_DB } from '../../../frontend/src/data/hnsSubstanceSearchData.js'
async function seedHnsSubstances() {
console.log('HNS 물질정보 시드 시작...')
console.log(`${HNS_SEARCH_DB.length}종 물질 데이터 삽입 예정`)
const client = await wingPool.connect()
try {
await client.query('BEGIN')
// 기존 데이터 삭제
await client.query('DELETE FROM HNS_SUBSTANCE')
let inserted = 0
for (const s of HNS_SEARCH_DB) {
// 검색용 컬럼 추출, 나머지는 DATA JSONB로 저장
const { abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, ...detailData } = s
await client.query(
`INSERT INTO HNS_SUBSTANCE (SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (SBST_SN) DO UPDATE SET
ABBREVIATION = EXCLUDED.ABBREVIATION,
NM_KR = EXCLUDED.NM_KR,
NM_EN = EXCLUDED.NM_EN,
UN_NO = EXCLUDED.UN_NO,
CAS_NO = EXCLUDED.CAS_NO,
SEBC = EXCLUDED.SEBC,
DATA = EXCLUDED.DATA`,
[s.id, abbreviation, nameKr, nameEn, unNumber, casNumber, sebc, JSON.stringify(detailData)]
)
inserted++
if (inserted % 100 === 0) {
console.log(` ${inserted}/${HNS_SEARCH_DB.length}건 삽입 완료...`)
}
}
await client.query('COMMIT')
// 결과 확인
const { rows } = await client.query('SELECT COUNT(*) as count FROM HNS_SUBSTANCE')
console.log(`시드 완료! 총 ${rows[0].count}종의 HNS 물질이 저장되었습니다.`)
} catch (err) {
await client.query('ROLLBACK')
console.error('HNS 시드 실패:', err)
throw err
} finally {
client.release()
await wingPool.end()
}
}
seedHnsSubstances().catch((err) => {
console.error(err)
process.exit(1)
})

파일 보기

@ -0,0 +1,53 @@
import express from 'express'
import { searchSubstances, getSubstanceById } from './hnsService.js'
import { isValidNumber } from '../middleware/security.js'
const router = express.Router()
// HNS 물질 검색
router.get('/', async (req, res) => {
try {
const q = req.query.q as string | undefined
const type = req.query.type as string | undefined
const sebc = req.query.sebc as string | undefined
const page = parseInt(req.query.page as string, 10) || 1
const limit = parseInt(req.query.limit as string, 10) || 50
if (!isValidNumber(page, 1, 10000) || !isValidNumber(limit, 1, 100)) {
return res.status(400).json({
error: '유효하지 않은 페이지네이션',
message: 'page는 1~10000, limit은 1~100 범위여야 합니다.',
})
}
const validTypes = ['abbreviation', 'nameKr', 'nameEn', 'casNumber', 'unNumber', 'cargoCode']
const searchType = type && validTypes.includes(type)
? type as 'abbreviation' | 'nameKr' | 'nameEn' | 'casNumber' | 'unNumber' | 'cargoCode'
: undefined
const result = await searchSubstances({ q, type: searchType, sebc, page, limit })
res.json(result)
} catch {
res.status(500).json({ error: 'HNS 물질 검색 실패' })
}
})
// HNS 물질 상세 조회
router.get('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10)
if (!isValidNumber(id, 1, 999999)) {
return res.status(400).json({ error: '유효하지 않은 물질 ID' })
}
const substance = await getSubstanceById(id)
if (!substance) {
return res.status(404).json({ error: '물질을 찾을 수 없습니다' })
}
res.json(substance)
} catch {
res.status(500).json({ error: 'HNS 물질 조회 실패' })
}
})
export default router

파일 보기

@ -0,0 +1,110 @@
import { wingPool } from '../db/wingDb.js'
interface HnsSearchParams {
q?: string
type?: 'abbreviation' | 'nameKr' | 'nameEn' | 'casNumber' | 'unNumber' | 'cargoCode'
sebc?: string
page?: number
limit?: number
}
export async function searchSubstances(params: HnsSearchParams) {
const { q, type = 'nameKr', sebc, page = 1, limit = 50 } = params
const conditions: string[] = ["USE_YN = 'Y'"]
const values: (string | number)[] = []
let paramIdx = 1
if (q && q.trim()) {
const keyword = q.trim()
switch (type) {
case 'abbreviation':
conditions.push(`ABBREVIATION ILIKE $${paramIdx}`)
values.push(`%${keyword}%`)
break
case 'nameKr':
conditions.push(`NM_KR ILIKE $${paramIdx}`)
values.push(`%${keyword}%`)
break
case 'nameEn':
conditions.push(`NM_EN ILIKE $${paramIdx}`)
values.push(`%${keyword}%`)
break
case 'casNumber':
conditions.push(`CAS_NO ILIKE $${paramIdx}`)
values.push(`%${keyword}%`)
break
case 'unNumber':
conditions.push(`UN_NO = $${paramIdx}`)
values.push(keyword)
break
case 'cargoCode':
conditions.push(`DATA->'cargoCodes' @> $${paramIdx}::jsonb`)
values.push(JSON.stringify([{ code: keyword }]))
break
default:
conditions.push(`(NM_KR ILIKE $${paramIdx} OR NM_EN ILIKE $${paramIdx} OR ABBREVIATION ILIKE $${paramIdx})`)
values.push(`%${keyword}%`)
}
paramIdx++
}
if (sebc && sebc.trim()) {
conditions.push(`SEBC ILIKE $${paramIdx}`)
values.push(`%${sebc.trim()}%`)
paramIdx++
}
const where = conditions.join(' AND ')
const offset = (page - 1) * limit
const countQuery = `SELECT COUNT(*) as total FROM HNS_SUBSTANCE WHERE ${where}`
const dataQuery = `
SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA
FROM HNS_SUBSTANCE
WHERE ${where}
ORDER BY SBST_SN
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`
const [countResult, dataResult] = await Promise.all([
wingPool.query(countQuery, values),
wingPool.query(dataQuery, [...values, limit, offset]),
])
return {
total: parseInt(countResult.rows[0].total, 10),
page,
limit,
items: dataResult.rows.map(row => ({
id: row.sbst_sn,
abbreviation: row.abbreviation,
nameKr: row.nm_kr,
nameEn: row.nm_en,
unNumber: row.un_no,
casNumber: row.cas_no,
sebc: row.sebc,
...row.data,
})),
}
}
export async function getSubstanceById(id: number) {
const { rows } = await wingPool.query(
`SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA
FROM HNS_SUBSTANCE WHERE SBST_SN = $1`,
[id]
)
if (rows.length === 0) return null
const row = rows[0]
return {
id: row.sbst_sn,
abbreviation: row.abbreviation,
nameKr: row.nm_kr,
nameEn: row.nm_en,
unNumber: row.un_no,
casNumber: row.cas_no,
sebc: row.sebc,
...row.data,
}
}

파일 보기

@ -14,6 +14,7 @@ import roleRouter from './roles/roleRouter.js'
import settingsRouter from './settings/settingsRouter.js'
import menuRouter from './menus/menuRouter.js'
import auditRouter from './audit/auditRouter.js'
import hnsRouter from './hns/hnsRouter.js'
import {
sanitizeBody,
sanitizeQuery,
@ -137,6 +138,7 @@ app.use('/api/audit', auditRouter)
// API 라우트 — 업무
app.use('/api/layers', layersRouter)
app.use('/api/simulation', simulationLimiter, simulationRouter)
app.use('/api/hns', hnsRouter)
// 헬스 체크
app.get('/health', (_req, res) => {

파일 보기

@ -13,5 +13,5 @@
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "src/db/seedHns.ts"]
}

파일 보기

@ -0,0 +1,46 @@
-- ================================================================
-- 002: HNS 물질정보 테이블 (프론트엔드 정적 데이터 → DB 이전)
-- ================================================================
-- 검색용 컬럼 + 상세 데이터 JSONB 구조
-- pg_trgm 인덱스로 한글/영문 물질명 검색 지원
-- ================================================================
CREATE TABLE IF NOT EXISTS HNS_SUBSTANCE (
SBST_SN SERIAL NOT NULL, -- 물질순번
ABBREVIATION VARCHAR(50), -- 약자/제품명
NM_KR VARCHAR(200) NOT NULL, -- 국문명
NM_EN VARCHAR(200), -- 영문명
UN_NO VARCHAR(10), -- UN번호
CAS_NO VARCHAR(20), -- CAS번호
SEBC VARCHAR(50), -- SEBC 거동분류
DATA JSONB NOT NULL, -- 전체 상세 데이터
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_HNS_SUBSTANCE PRIMARY KEY (SBST_SN),
CONSTRAINT CK_HNS_SBST_USE CHECK (USE_YN IN ('Y', 'N'))
);
COMMENT ON TABLE HNS_SUBSTANCE IS 'HNS물질정보 (1,316종)';
COMMENT ON COLUMN HNS_SUBSTANCE.SBST_SN IS '물질순번';
COMMENT ON COLUMN HNS_SUBSTANCE.ABBREVIATION IS '약자/제품명 (화물적부도 코드)';
COMMENT ON COLUMN HNS_SUBSTANCE.NM_KR IS '국문명';
COMMENT ON COLUMN HNS_SUBSTANCE.NM_EN IS '영문명';
COMMENT ON COLUMN HNS_SUBSTANCE.UN_NO IS 'UN번호 (위험물 식별번호)';
COMMENT ON COLUMN HNS_SUBSTANCE.CAS_NO IS 'CAS번호 (화학물질등록번호)';
COMMENT ON COLUMN HNS_SUBSTANCE.SEBC IS 'SEBC 거동분류 (G/GD/E/ED/FE/FED/F/FD/D/S/SD)';
COMMENT ON COLUMN HNS_SUBSTANCE.DATA IS '전체 상세 데이터 (JSONB)';
COMMENT ON COLUMN HNS_SUBSTANCE.USE_YN IS '사용여부 (Y:사용, N:미사용)';
COMMENT ON COLUMN HNS_SUBSTANCE.REG_DTM IS '등록일시';
-- 텍스트 검색 인덱스 (pg_trgm)
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_NM_KR ON HNS_SUBSTANCE USING GIN(NM_KR gin_trgm_ops);
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_NM_EN ON HNS_SUBSTANCE USING GIN(NM_EN gin_trgm_ops);
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_ABBR ON HNS_SUBSTANCE USING GIN(ABBREVIATION gin_trgm_ops);
-- 코드 검색 인덱스
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_UN ON HNS_SUBSTANCE(UN_NO);
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_CAS ON HNS_SUBSTANCE(CAS_NO);
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_SEBC ON HNS_SUBSTANCE(SEBC);
-- JSONB 내 cargoCodes 검색용 인덱스
CREATE INDEX IF NOT EXISTS IDX_HNS_SBST_DATA ON HNS_SUBSTANCE USING GIN(DATA jsonb_path_ops);

파일 보기

@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import type { Layer } from '../../../data/layerDatabase'
import type { Layer } from '@common/services/layerService'
const PRESET_COLORS = [
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4',

파일 보기

@ -2,7 +2,7 @@ import { useState, useMemo, useEffect } from 'react'
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, CircleMarker, Circle, Polyline } from 'react-leaflet'
import 'leaflet/dist/leaflet.css'
import L from 'leaflet'
import { layerDatabase } from '../../../data/layerDatabase'
import { layerDatabase } from '@common/services/layerService'
import { decimalToDMS } from '@common/utils/coordinates'
import type { PredictionModel } from '@tabs/prediction/components/OilSpillView'
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'

파일 보기

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { fetchAllLayers, fetchLayerTree, fetchWMSLayers } from '../services/api'
import type { Layer } from '../../data/layerDatabase'
import type { Layer } from '@common/services/layerService'
// 모든 레이어 조회 훅
export function useLayers() {

파일 보기

@ -0,0 +1,67 @@
/* HNS 물질 검색 데이터 타입 */
export interface HNSSearchSubstance {
id: number
abbreviation: string // 약자/제품명 (화물적부도 코드)
nameKr: string // 국문명
nameEn: string // 영문명
synonymsEn: string // 영문 동의어
synonymsKr: string // 국문 동의어/용도
unNumber: string // UN번호
casNumber: string // CAS번호
transportMethod: string // 운송방법
sebc: string // SEBC 거동분류
/* 물리·화학적 특성 */
usage: string
state: string
color: string
odor: string
flashPoint: string
autoIgnition: string
boilingPoint: string
density: string // 비중 (물=1)
solubility: string
vaporPressure: string
vaporDensity: string // 증기밀도 (공기=1)
explosionRange: string // 폭발범위
/* 위험등급·농도기준 */
nfpa: { health: number; fire: number; reactivity: number; special: string }
hazardClass: string
ergNumber: string
idlh: string
aegl2: string
erpg2: string
/* 방제거리 */
responseDistanceFire: string
responseDistanceSpillDay: string
responseDistanceSpillNight: string
marineResponse: string
/* PPE */
ppeClose: string
ppeFar: string
/* MSDS 요약 */
msds: {
hazard: string
firstAid: string
fireFighting: string
spillResponse: string
exposure: string
regulation: string
}
/* IBC CODE */
ibcHazard: string
ibcShipType: string
ibcTankType: string
ibcDetection: string
ibcFireFighting: string
ibcMinRequirement: string
/* EmS */
emsCode: string
emsFire: string
emsSpill: string
emsFirstAid: string
/* 화물적부도 코드 */
cargoCodes: Array<{ code: string; name: string; company: string; source: string }>
/* 항구별 반입 */
portFrequency: Array<{ port: string; portCode: string; lastImport: string; frequency: string }>
}

파일 보기

@ -1,6 +1,7 @@
import React, { useState, useRef, useMemo } from 'react'
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { sanitizeHtml } from '@common/utils/sanitize'
import { HNS_SEARCH_DB, type HNSSearchSubstance } from '../../../data/hnsSubstanceSearchData'
import { api } from '@common/services/api'
import type { HNSSearchSubstance } from '@common/types/hns'
/* ═══ HNS 물질 데이터베이스 ═══ */
interface HNSSubstance {
@ -65,8 +66,60 @@ export function HNSSubstanceView() {
const [hmsSelectedId, setHmsSelectedId] = useState<number | null>(null)
const [hmsDetailTab, setHmsDetailTab] = useState(0)
const [hmsPage, setHmsPage] = useState(1)
const [hmsResults, setHmsResults] = useState<HNSSearchSubstance[]>([])
const [hmsTotal, setHmsTotal] = useState(0)
const [hmsLoading, setHmsLoading] = useState(false)
const [hmsSelectedSubstance, setHmsSelectedSubstance] = useState<HNSSearchSubstance | null>(null)
const contentRef = useRef<HTMLDivElement>(null)
// 검색 타입 매핑 (프론트엔드 → API)
const searchTypeMap: Record<string, string> = {
abbr: 'abbreviation', korName: 'nameKr', engName: 'nameEn', cas: 'casNumber', un: 'unNumber',
}
// HNS 물질 검색 API 호출
const fetchHnsSubstances = useCallback(async () => {
setHmsLoading(true)
try {
const params: Record<string, string | number> = { page: hmsPage, limit: 10 }
if (hmsSearchInput.trim()) {
params.q = hmsSearchInput.trim()
params.type = searchTypeMap[hmsSearchType] || 'abbreviation'
}
if (hmsFilterSebc !== '전체 거동분류') {
params.sebc = hmsFilterSebc.split(' ')[0]
}
const { data } = await api.get('/hns', { params })
setHmsResults(data.items)
setHmsTotal(data.total)
} catch {
setHmsResults([])
setHmsTotal(0)
} finally {
setHmsLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hmsSearchInput, hmsSearchType, hmsFilterSebc, hmsPage])
// 검색 조건 변경 시 API 호출 (디바운스)
useEffect(() => {
const timer = setTimeout(fetchHnsSubstances, 300)
return () => clearTimeout(timer)
}, [fetchHnsSubstances])
// 물질 선택 시 상세 정보 조회
useEffect(() => {
if (hmsSelectedId === null) {
setHmsSelectedSubstance(null)
return
}
api.get(`/hns/${hmsSelectedId}`).then(({ data }) => {
setHmsSelectedSubstance(data)
}).catch(() => {
setHmsSelectedSubstance(null)
})
}, [hmsSelectedId])
const handleExportPDF = () => {
if (!contentRef.current) return
const clone = contentRef.current.cloneNode(true) as HTMLElement
@ -113,28 +166,10 @@ ${styles}
return matchName && matchCas && matchSebc
})
/* Panel 3: HNS 통합 검색 필터 */
const hmsFiltered = useMemo(() => {
const q = hmsSearchInput.toLowerCase().replace(/[\s\-./]/g, '')
return HNS_SEARCH_DB.filter(s => {
// SEBC 필터
if (hmsFilterSebc !== '전체 거동분류' && !s.sebc.startsWith(hmsFilterSebc.split(' ')[0])) return false
if (!q) return true
switch (hmsSearchType) {
case 'abbr': return s.abbreviation.toLowerCase().replace(/[\s\-./]/g, '').includes(q) || s.cargoCodes.some(c => c.code.toLowerCase().replace(/[\s\-./]/g, '').includes(q))
case 'korName': return s.nameKr.includes(hmsSearchInput) || s.synonymsKr.includes(hmsSearchInput)
case 'engName': return s.nameEn.toLowerCase().includes(q) || s.synonymsEn.toLowerCase().includes(q)
case 'cas': return s.casNumber.replace(/-/g, '').includes(q.replace(/-/g, ''))
case 'un': return s.unNumber.includes(hmsSearchInput)
default: return true
}
})
}, [hmsSearchInput, hmsSearchType, hmsFilterSebc])
/* Panel 3: HNS API 기반 검색 결과 */
const HMS_PER_PAGE = 10
const hmsTotalPages = Math.max(1, Math.ceil(hmsFiltered.length / HMS_PER_PAGE))
const hmsPageData = hmsFiltered.slice((hmsPage - 1) * HMS_PER_PAGE, hmsPage * HMS_PER_PAGE)
const hmsSelectedSubstance = hmsSelectedId !== null ? HNS_SEARCH_DB.find(s => s.id === hmsSelectedId) ?? null : null
const hmsTotalPages = Math.max(1, Math.ceil(hmsTotal / HMS_PER_PAGE))
const hmsPageData = hmsResults
const tabLabels = [
{ icon: '📊', label: 'SEBC 거동분류' },
@ -563,7 +598,7 @@ ${styles}
{/* ── 검색 결과 테이블 ── */}
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>📋 <span style={{ fontSize: 9, fontWeight: 400, color: 'var(--t3)' }}> {hmsFiltered.length} </span></div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>📋 <span style={{ fontSize: 9, fontWeight: 400, color: 'var(--t3)' }}> {hmsTotal} </span></div>
<select value={hmsFilterSebc} onChange={e => { setHmsFilterSebc(e.target.value); setHmsPage(1) }} style={{ padding: '3px 8px', borderRadius: 4, border: '1px solid var(--bd)', background: 'var(--bg0)', color: 'var(--t2)', fontSize: 9, fontFamily: 'var(--fK)', outline: 'none' }}>
<option> </option><option>G (Gas)</option><option>E (Evaporator)</option><option>F (Floater)</option><option>D (Dissolver)</option><option>S (Sinker)</option>
</select>
@ -583,7 +618,9 @@ ${styles}
</tr>
</thead>
<tbody>
{hmsPageData.length > 0 ? hmsPageData.map((s, idx) => {
{hmsLoading ? (
<tr><td colSpan={8} style={{ padding: '20px 8px', textAlign: 'center', color: 'var(--t3)' }}> ...</td></tr>
) : hmsPageData.length > 0 ? hmsPageData.map((s: HNSSearchSubstance, idx: number) => {
const isSel = hmsSelectedId === s.id
return (
<tr key={s.id} onClick={() => { setHmsSelectedId(isSel ? null : s.id); setHmsDetailTab(0) }}
@ -608,7 +645,7 @@ ${styles}
</table>
</div>
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 9, fontFamily: 'var(--fK)', color: 'var(--t3)' }}>
<span> <b style={{ color: 'var(--orange)' }}>1,316</b> · Port-MIS · · IBC CODE 692</span>
<span> <b style={{ color: 'var(--orange)' }}>{hmsTotal.toLocaleString()}</b> · Port-MIS · · IBC CODE 692</span>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => setHmsPage(p => Math.max(1, p - 1))} disabled={hmsPage <= 1} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid var(--bd)', background: 'var(--bg0)', color: 'var(--t2)', fontSize: 9, cursor: 'pointer', opacity: hmsPage <= 1 ? 0.4 : 1 }}></button>
{Array.from({ length: Math.min(hmsTotalPages, 5) }, (_, i) => i + 1).map(p => (

파일 보기

@ -5,7 +5,7 @@ import type { LatLngExpression } from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'
import { mockVessels, VESSEL_LEGEND, type Vessel } from '../../../data/vesselMockData'
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
// Mock incident data (HTML 참고 6건)
const mockIncidents: Incident[] = [

파일 보기

@ -1,9 +1,9 @@
import { useState, useMemo } from 'react'
import { LayerTree } from '@common/components/layer/LayerTree'
import { useLayerTree } from '@common/hooks/useLayers'
import { layerData } from '../../../data/layerData'
import type { LayerNode } from '../../../data/layerData'
import type { Layer } from '../../../data/layerDatabase'
import { layerData } from '@common/data/layerData'
import type { LayerNode } from '@common/data/layerData'
import type { Layer } from '@common/services/layerService'
import { decimalToDMS } from '@common/utils/coordinates'
import { ComboBox } from '@common/components/ui/ComboBox'
import { ALL_MODELS } from './OilSpillView'

파일 보기

@ -12,7 +12,7 @@ import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/u
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
import type { BacktrackPhase, BacktrackVessel } from '@common/types/backtrack'
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
import { MOCK_CONDITIONS, MOCK_VESSELS, MOCK_REPLAY_SHIPS, MOCK_COLLISION } from '../../../data/backtrackMockData'
import { MOCK_CONDITIONS, MOCK_VESSELS, MOCK_REPLAY_SHIPS, MOCK_COLLISION } from '@common/mock/backtrackMockData'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
// eslint-disable-next-line react-refresh/only-export-components

파일 보기

@ -0,0 +1 @@
export { PreScatView } from './components/PreScatView'