Compare commits
24 커밋
931971dc5c
...
007c950e8c
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 007c950e8c | |||
| 7f276bebe2 | |||
| e32c630da5 | |||
| 9c44ab4ffa | |||
| f336f6b93a | |||
|
|
0cf3ff1ea0 | ||
|
|
7949b96866 | ||
|
|
6bea387ee2 | ||
|
|
7110d76276 | ||
|
|
7fb98ebb08 | ||
|
|
8c0ada08fd | ||
|
|
39277c1c02 | ||
|
|
0549fb879f | ||
|
|
f0fee9d92b | ||
|
|
5191e606a1 | ||
|
|
19fdc489f3 | ||
|
|
7564f42918 | ||
|
|
00e7a3e70a | ||
|
|
0c4bfb2f24 | ||
|
|
044994bd57 | ||
|
|
326237b91f | ||
|
|
bbdb654857 | ||
|
|
f5bcbde40e | ||
|
|
6b5d5f89dd |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-13",
|
||||
"applied_date": "2026-03-19",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
@ -435,6 +435,103 @@ router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'C
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// UP42 위성 패스 조회 (실시간 위성 목록 + 궤도)
|
||||
// ============================================================
|
||||
|
||||
/** 한국 주변 위성 패스 시뮬레이션 데이터 (UP42 API 연동 시 교체) */
|
||||
function generateKoreaSatellitePasses() {
|
||||
const now = new Date();
|
||||
const passes = [
|
||||
{
|
||||
id: 'pass-kmp3a-1', satellite: 'KOMPSAT-3A', provider: 'KARI', type: 'optical',
|
||||
resolution: '0.5m', color: '#a855f7',
|
||||
startTime: new Date(now.getTime() + 2 * 3600000).toISOString(),
|
||||
endTime: new Date(now.getTime() + 2 * 3600000 + 14 * 60000).toISOString(),
|
||||
maxElevation: 72, direction: 'descending',
|
||||
orbit: [
|
||||
{ lat: 42.0, lon: 126.5 }, { lat: 40.5, lon: 127.0 }, { lat: 39.0, lon: 127.4 },
|
||||
{ lat: 37.5, lon: 127.8 }, { lat: 36.0, lon: 128.1 }, { lat: 34.5, lon: 128.4 },
|
||||
{ lat: 33.0, lon: 128.6 }, { lat: 31.5, lon: 128.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pass-pneo-1', satellite: 'Pléiades Neo', provider: 'Airbus', type: 'optical',
|
||||
resolution: '0.3m', color: '#06b6d4',
|
||||
startTime: new Date(now.getTime() + 3.5 * 3600000).toISOString(),
|
||||
endTime: new Date(now.getTime() + 3.5 * 3600000 + 12 * 60000).toISOString(),
|
||||
maxElevation: 65, direction: 'ascending',
|
||||
orbit: [
|
||||
{ lat: 30.0, lon: 130.0 }, { lat: 31.5, lon: 129.2 }, { lat: 33.0, lon: 128.5 },
|
||||
{ lat: 34.5, lon: 127.8 }, { lat: 36.0, lon: 127.1 }, { lat: 37.5, lon: 126.4 },
|
||||
{ lat: 39.0, lon: 125.8 }, { lat: 40.5, lon: 125.2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pass-s1-1', satellite: 'Sentinel-1 SAR', provider: 'ESA', type: 'sar',
|
||||
resolution: '20m', color: '#f59e0b',
|
||||
startTime: new Date(now.getTime() + 5 * 3600000).toISOString(),
|
||||
endTime: new Date(now.getTime() + 5 * 3600000 + 18 * 60000).toISOString(),
|
||||
maxElevation: 58, direction: 'descending',
|
||||
orbit: [
|
||||
{ lat: 43.0, lon: 124.0 }, { lat: 41.0, lon: 125.0 }, { lat: 39.0, lon: 126.0 },
|
||||
{ lat: 37.0, lon: 126.8 }, { lat: 35.0, lon: 127.5 }, { lat: 33.0, lon: 128.0 },
|
||||
{ lat: 31.0, lon: 128.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pass-wv3-1', satellite: 'Maxar WorldView-3', provider: 'Maxar', type: 'optical',
|
||||
resolution: '0.31m', color: '#3b82f6',
|
||||
startTime: new Date(now.getTime() + 8 * 3600000).toISOString(),
|
||||
endTime: new Date(now.getTime() + 8 * 3600000 + 10 * 60000).toISOString(),
|
||||
maxElevation: 80, direction: 'descending',
|
||||
orbit: [
|
||||
{ lat: 41.0, lon: 129.5 }, { lat: 39.5, lon: 129.0 }, { lat: 38.0, lon: 128.5 },
|
||||
{ lat: 36.5, lon: 128.0 }, { lat: 35.0, lon: 127.5 }, { lat: 33.5, lon: 127.0 },
|
||||
{ lat: 32.0, lon: 126.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pass-skysat-1', satellite: 'SkySat', provider: 'Planet', type: 'optical',
|
||||
resolution: '0.5m', color: '#22c55e',
|
||||
startTime: new Date(now.getTime() + 12 * 3600000).toISOString(),
|
||||
endTime: new Date(now.getTime() + 12 * 3600000 + 8 * 60000).toISOString(),
|
||||
maxElevation: 55, direction: 'ascending',
|
||||
orbit: [
|
||||
{ lat: 31.0, lon: 127.0 }, { lat: 32.5, lon: 126.5 }, { lat: 34.0, lon: 126.0 },
|
||||
{ lat: 35.5, lon: 125.5 }, { lat: 37.0, lon: 125.0 }, { lat: 38.5, lon: 124.5 },
|
||||
{ lat: 40.0, lon: 124.0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pass-s2-1', satellite: 'Sentinel-2', provider: 'ESA', type: 'optical',
|
||||
resolution: '10m', color: '#ec4899',
|
||||
startTime: new Date(now.getTime() + 18 * 3600000).toISOString(),
|
||||
endTime: new Date(now.getTime() + 18 * 3600000 + 20 * 60000).toISOString(),
|
||||
maxElevation: 62, direction: 'descending',
|
||||
orbit: [
|
||||
{ lat: 42.0, lon: 128.0 }, { lat: 40.0, lon: 128.0 }, { lat: 38.0, lon: 128.0 },
|
||||
{ lat: 36.0, lon: 128.0 }, { lat: 34.0, lon: 128.0 }, { lat: 32.0, lon: 128.0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
return passes;
|
||||
}
|
||||
|
||||
// GET /api/aerial/satellite/passes — 한국 주변 실시간 위성 패스 목록 (UP42 API 연동 준비)
|
||||
router.get('/satellite/passes', requireAuth, requirePermission('aerial', 'READ'), async (_req, res) => {
|
||||
try {
|
||||
// TODO: UP42 API 연동 시 아래 코드를 실제 API 호출로 교체
|
||||
// const token = await getUp42Token()
|
||||
// const passes = await fetchUp42Catalog(token, { bbox: [124, 33, 132, 39] })
|
||||
const passes = generateKoreaSatellitePasses();
|
||||
res.json({ passes, source: 'simulation', note: 'UP42 API 연동 시 실제 데이터로 교체 예정' });
|
||||
} catch (err) {
|
||||
console.error('[aerial] 위성 패스 조회 오류:', err);
|
||||
res.status(500).json({ error: '위성 패스 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// OIL INFERENCE 라우트
|
||||
// ============================================================
|
||||
|
||||
117
backend/src/map-base/mapBaseRouter.ts
Normal file
117
backend/src/map-base/mapBaseRouter.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||
import {
|
||||
listMapBase,
|
||||
getActiveMapTypes,
|
||||
createMapBase,
|
||||
updateMapBase,
|
||||
deleteMapBase,
|
||||
} from './mapBaseService.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/map-base/active — 활성 지도 목록 (전체 사용자)
|
||||
router.get('/active', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const types = await getActiveMapTypes()
|
||||
res.json(types)
|
||||
} catch (err) {
|
||||
console.error('[map-base] 활성 목록 조회 오류:', err)
|
||||
res.status(500).json({ error: '지도 목록 조회 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/map-base — 전체 목록 (관리자)
|
||||
router.get('/', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string, 10) || 1
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100)
|
||||
const result = await listMapBase(page, limit)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
console.error('[map-base] 목록 조회 오류:', err)
|
||||
res.status(500).json({ error: '지도 목록 조회 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/map-base — 등록 (관리자)
|
||||
router.post('/', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
try {
|
||||
const { mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn } = req.body as {
|
||||
mapKey?: string;
|
||||
mapNm?: string;
|
||||
mapLevelCd?: string;
|
||||
mapSrc?: string;
|
||||
mapDc?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
if (!mapKey || !mapNm) {
|
||||
res.status(400).json({ error: '지도 키와 이름은 필수입니다.' })
|
||||
return
|
||||
}
|
||||
const created = await createMapBase({
|
||||
mapKey,
|
||||
mapNm,
|
||||
mapLevelCd,
|
||||
mapSrc,
|
||||
mapDc,
|
||||
useYn,
|
||||
regId: req.user?.sub,
|
||||
regNm: req.user?.name,
|
||||
})
|
||||
res.json(created)
|
||||
} catch (err) {
|
||||
console.error('[map-base] 등록 오류:', err)
|
||||
res.status(500).json({ error: '지도 등록 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/map-base/update — 수정 (관리자)
|
||||
router.post('/update', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
try {
|
||||
const { mapSn, mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn } = req.body as {
|
||||
mapSn?: number;
|
||||
mapKey?: string;
|
||||
mapNm?: string;
|
||||
mapLevelCd?: string | null;
|
||||
mapSrc?: string | null;
|
||||
mapDc?: string | null;
|
||||
useYn?: string;
|
||||
}
|
||||
if (!mapSn) {
|
||||
res.status(400).json({ error: '지도 번호가 필요합니다.' })
|
||||
return
|
||||
}
|
||||
const updated = await updateMapBase(Number(mapSn), { mapKey, mapNm, mapLevelCd, mapSrc, mapDc, useYn })
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: '지도를 찾을 수 없습니다.' })
|
||||
return
|
||||
}
|
||||
res.json(updated)
|
||||
} catch (err) {
|
||||
console.error('[map-base] 수정 오류:', err)
|
||||
res.status(500).json({ error: '지도 수정 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/map-base/delete — 삭제 (관리자, soft-delete)
|
||||
router.post('/delete', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
try {
|
||||
const { mapSn } = req.body as { mapSn?: number }
|
||||
if (!mapSn) {
|
||||
res.status(400).json({ error: '지도 번호가 필요합니다.' })
|
||||
return
|
||||
}
|
||||
const ok = await deleteMapBase(Number(mapSn))
|
||||
if (!ok) {
|
||||
res.status(404).json({ error: '지도를 찾을 수 없습니다.' })
|
||||
return
|
||||
}
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error('[map-base] 삭제 오류:', err)
|
||||
res.status(500).json({ error: '지도 삭제 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
140
backend/src/map-base/mapBaseService.ts
Normal file
140
backend/src/map-base/mapBaseService.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { wingPool } from '../db/wingDb.js'
|
||||
|
||||
export interface MapBaseItem {
|
||||
mapSn: number;
|
||||
mapKey: string;
|
||||
mapNm: string;
|
||||
mapLevelCd: string | null;
|
||||
mapSrc: string | null;
|
||||
mapDc: string | null;
|
||||
useYn: string;
|
||||
regId: string | null;
|
||||
regNm: string | null;
|
||||
regDtm: string | null;
|
||||
}
|
||||
|
||||
export interface MapTypeItem {
|
||||
mapKey: string;
|
||||
mapNm: string;
|
||||
mapLevelCd: string | null;
|
||||
}
|
||||
|
||||
function rowToItem(r: Record<string, unknown>): MapBaseItem {
|
||||
return {
|
||||
mapSn: r.map_sn as number,
|
||||
mapKey: r.map_key as string,
|
||||
mapNm: r.map_nm as string,
|
||||
mapLevelCd: (r.map_level_cd as string) ?? null,
|
||||
mapSrc: (r.map_src as string) ?? null,
|
||||
mapDc: (r.map_dc as string) ?? null,
|
||||
useYn: r.use_yn as string,
|
||||
regId: (r.reg_id as string) ?? null,
|
||||
regNm: (r.reg_nm as string) ?? null,
|
||||
regDtm: r.reg_dtm ? new Date(r.reg_dtm as string).toISOString().slice(0, 10) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMapBase(
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<{ rows: MapBaseItem[]; total: number }> {
|
||||
const offset = (page - 1) * limit
|
||||
const countResult = await wingPool.query(`SELECT COUNT(*) AS cnt FROM wing.MAP_BASE_DATA WHERE DEL_YN = 'N'`)
|
||||
const total = parseInt(countResult.rows[0].cnt as string, 10)
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT MAP_SN, MAP_KEY, MAP_NM, MAP_LEVEL_CD, MAP_SRC, MAP_DC,
|
||||
USE_YN, REG_ID, REG_NM, REG_DTM
|
||||
FROM wing.MAP_BASE_DATA
|
||||
WHERE DEL_YN = 'N'
|
||||
ORDER BY MAP_SN
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
)
|
||||
return { rows: rows.map(rowToItem), total }
|
||||
}
|
||||
|
||||
export async function getActiveMapTypes(): Promise<MapTypeItem[]> {
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT MAP_KEY, MAP_NM, MAP_LEVEL_CD
|
||||
FROM wing.MAP_BASE_DATA
|
||||
WHERE USE_YN = 'Y' AND DEL_YN = 'N'
|
||||
ORDER BY MAP_SN`
|
||||
)
|
||||
return rows.map((r: Record<string, unknown>) => ({
|
||||
mapKey: r.map_key as string,
|
||||
mapNm: r.map_nm as string,
|
||||
mapLevelCd: (r.map_level_cd as string) ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createMapBase(data: {
|
||||
mapKey: string;
|
||||
mapNm: string;
|
||||
mapLevelCd?: string;
|
||||
mapSrc?: string;
|
||||
mapDc?: string;
|
||||
useYn?: string;
|
||||
regId?: string;
|
||||
regNm?: string;
|
||||
}): Promise<MapBaseItem> {
|
||||
const { rows } = await wingPool.query(
|
||||
`INSERT INTO wing.MAP_BASE_DATA
|
||||
(MAP_KEY, MAP_NM, MAP_LEVEL_CD, MAP_SRC, MAP_DC, USE_YN, REG_ID, REG_NM)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.mapKey,
|
||||
data.mapNm,
|
||||
data.mapLevelCd ?? null,
|
||||
data.mapSrc ?? null,
|
||||
data.mapDc ?? null,
|
||||
data.useYn ?? 'Y',
|
||||
data.regId ?? null,
|
||||
data.regNm ?? null,
|
||||
]
|
||||
)
|
||||
return rowToItem(rows[0])
|
||||
}
|
||||
|
||||
export async function updateMapBase(
|
||||
mapSn: number,
|
||||
data: {
|
||||
mapKey?: string;
|
||||
mapNm?: string;
|
||||
mapLevelCd?: string | null;
|
||||
mapSrc?: string | null;
|
||||
mapDc?: string | null;
|
||||
useYn?: string;
|
||||
}
|
||||
): Promise<MapBaseItem | null> {
|
||||
const fields: string[] = []
|
||||
const params: unknown[] = []
|
||||
let idx = 1
|
||||
|
||||
if (data.mapKey !== undefined) { fields.push(`MAP_KEY = $${idx++}`); params.push(data.mapKey) }
|
||||
if (data.mapNm !== undefined) { fields.push(`MAP_NM = $${idx++}`); params.push(data.mapNm) }
|
||||
if (data.mapLevelCd !== undefined) { fields.push(`MAP_LEVEL_CD = $${idx++}`); params.push(data.mapLevelCd) }
|
||||
if (data.mapSrc !== undefined) { fields.push(`MAP_SRC = $${idx++}`); params.push(data.mapSrc) }
|
||||
if (data.mapDc !== undefined) { fields.push(`MAP_DC = $${idx++}`); params.push(data.mapDc) }
|
||||
if (data.useYn !== undefined) { fields.push(`USE_YN = $${idx++}`); params.push(data.useYn) }
|
||||
|
||||
if (fields.length === 0) return null
|
||||
fields.push(`MDFCN_DTM = NOW()`)
|
||||
|
||||
params.push(mapSn)
|
||||
const { rows } = await wingPool.query(
|
||||
`UPDATE wing.MAP_BASE_DATA SET ${fields.join(', ')} WHERE MAP_SN = $${idx} RETURNING *`,
|
||||
params
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
return rowToItem(rows[0])
|
||||
}
|
||||
|
||||
export async function deleteMapBase(mapSn: number): Promise<boolean> {
|
||||
const { rowCount } = await wingPool.query(
|
||||
`UPDATE wing.MAP_BASE_DATA SET DEL_YN = 'Y', MDFCN_DTM = NOW() WHERE MAP_SN = $1`,
|
||||
[mapSn]
|
||||
)
|
||||
return (rowCount ?? 0) > 0
|
||||
}
|
||||
@ -457,6 +457,13 @@ interface SingleModelTrajectoryResult {
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
};
|
||||
stepSummaries: Array<{
|
||||
remainingVolume: number;
|
||||
weatheredVolume: number;
|
||||
pollutionArea: number;
|
||||
beachedVolume: number;
|
||||
pollutionCoastLength: number;
|
||||
}>;
|
||||
centerPoints: Array<{ lat: number; lon: number; time: number; model: string }>;
|
||||
windData: TrajectoryWindPoint[][];
|
||||
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
|
||||
@ -475,6 +482,7 @@ interface TrajectoryResult {
|
||||
windDataByModel: Record<string, TrajectoryWindPoint[][]>;
|
||||
hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]>;
|
||||
summaryByModel: Record<string, SingleModelTrajectoryResult['summary']>;
|
||||
stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']>;
|
||||
}
|
||||
|
||||
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: string): SingleModelTrajectoryResult {
|
||||
@ -503,13 +511,20 @@ function transformTrajectoryResult(rawResult: TrajectoryTimeStep[], model: strin
|
||||
: null
|
||||
)
|
||||
.filter((p): p is { lat: number; lon: number; time: number; model: string } => p !== null);
|
||||
const stepSummaries = rawResult.map((step) => ({
|
||||
remainingVolume: step.remaining_volume_m3,
|
||||
weatheredVolume: step.weathered_volume_m3,
|
||||
pollutionArea: step.pollution_area_km2,
|
||||
beachedVolume: step.beached_volume_m3,
|
||||
pollutionCoastLength: step.pollution_coast_length_m,
|
||||
}));
|
||||
const windData = rawResult.map((step) => step.wind_data ?? []);
|
||||
const hydrData = rawResult.map((step) =>
|
||||
step.hydr_data && step.hydr_grid
|
||||
? { value: step.hydr_data, grid: step.hydr_grid }
|
||||
: null
|
||||
);
|
||||
return { trajectory, summary, centerPoints, windData, hydrData };
|
||||
return { trajectory, summary, stepSummaries, centerPoints, windData, hydrData };
|
||||
}
|
||||
|
||||
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
|
||||
@ -533,6 +548,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
|
||||
const windDataByModel: Record<string, TrajectoryWindPoint[][]> = {};
|
||||
const hydrDataByModel: Record<string, ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[]> = {};
|
||||
const summaryByModel: Record<string, SingleModelTrajectoryResult['summary']> = {};
|
||||
const stepSummariesByModel: Record<string, SingleModelTrajectoryResult['stepSummaries']> = {};
|
||||
|
||||
// OpenDrift 우선, 없으면 POSEIDON 선택 (ORDER BY CMPL_DTM DESC이므로 첫 번째 행이 가장 최근)
|
||||
const opendriftRow = (rows as Array<Record<string, unknown>>).find((r) => r['algo_cd'] === 'OPENDRIFT');
|
||||
@ -549,6 +565,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
|
||||
windDataByModel[modelName] = parsed.windData;
|
||||
hydrDataByModel[modelName] = parsed.hydrData;
|
||||
summaryByModel[modelName] = parsed.summary;
|
||||
stepSummariesByModel[modelName] = parsed.stepSummaries;
|
||||
|
||||
if (row === baseRow) {
|
||||
baseResult = parsed;
|
||||
@ -564,6 +581,7 @@ export async function getAnalysisTrajectory(acdntSn: number): Promise<Trajectory
|
||||
windDataByModel,
|
||||
hydrDataByModel,
|
||||
summaryByModel,
|
||||
stepSummariesByModel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
isValidNumber,
|
||||
isValidStringLength,
|
||||
} from '../middleware/security.js'
|
||||
import { requireAuth, requireRole } from '../auth/authMiddleware.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@ -36,7 +37,7 @@ router.use(sanitizeParams)
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD`
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
res.json(enrichedLayers)
|
||||
@ -49,7 +50,7 @@ router.get('/', async (_req, res) => {
|
||||
router.get('/tree/all', async (_req, res) => {
|
||||
try {
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' ORDER BY LAYER_CD`
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
|
||||
@ -81,7 +82,7 @@ router.get('/tree/all', async (_req, res) => {
|
||||
router.get('/wms/all', async (_req, res) => {
|
||||
try {
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' ORDER BY LAYER_CD`
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE WMS_LAYER_NM IS NOT NULL AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
res.json(enrichedLayers)
|
||||
@ -103,7 +104,7 @@ router.get('/level/:level', async (req, res) => {
|
||||
}
|
||||
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`,
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_LEVEL = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`,
|
||||
[level]
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
@ -127,7 +128,7 @@ router.get('/children/:parentId', async (req, res) => {
|
||||
|
||||
const sanitizedId = sanitizeString(parentId)
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' ORDER BY LAYER_CD`,
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE UP_LAYER_CD = $1 AND USE_YN = 'Y' AND DEL_YN = 'N' ORDER BY LAYER_CD`,
|
||||
[sanitizedId]
|
||||
)
|
||||
const enrichedLayers = rows.map(enrichLayerWithMetadata)
|
||||
@ -151,7 +152,7 @@ router.get('/:id', async (req, res) => {
|
||||
|
||||
const sanitizedId = sanitizeString(id)
|
||||
const { rows } = await wingPool.query<Layer>(
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1`,
|
||||
`SELECT ${LAYER_COLUMNS} FROM LAYER WHERE LAYER_CD = $1 AND DEL_YN = 'N'`,
|
||||
[sanitizedId]
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
@ -164,4 +165,309 @@ router.get('/:id', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── 관리자 전용 엔드포인트 ──────────────────────────────────────
|
||||
|
||||
// 전체 레이어 목록 (페이지네이션 + 검색/필터, USE_YN 무관)
|
||||
router.get('/admin/list', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
try {
|
||||
const page = Math.max(1, parseInt(String(req.query.page ?? '1'), 10) || 1)
|
||||
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? '10'), 10) || 10))
|
||||
const offset = (page - 1) * limit
|
||||
const search = sanitizeString(String(req.query.search ?? '')).trim()
|
||||
const useYnFilter = String(req.query.useYn ?? '')
|
||||
|
||||
// 동적 WHERE 절 구성 (DEL_YN = 'N' 기본 조건)
|
||||
const conditions: string[] = ["DEL_YN = 'N'"]
|
||||
const params: (string | number)[] = []
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`)
|
||||
const n = params.length
|
||||
conditions.push(`(LAYER_CD ILIKE $${n} OR LAYER_NM ILIKE $${n} OR LAYER_FULL_NM ILIKE $${n})`)
|
||||
}
|
||||
|
||||
if (useYnFilter === 'Y' || useYnFilter === 'N') {
|
||||
params.push(useYnFilter)
|
||||
conditions.push(`USE_YN = $${params.length}`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
|
||||
// 데이터 쿼리 파라미터: WHERE 조건 + LIMIT + OFFSET
|
||||
const dataParams = [...params, limit, offset]
|
||||
const limitIdx = dataParams.length - 1
|
||||
const offsetIdx = dataParams.length
|
||||
|
||||
const [dataResult, countResult] = await Promise.all([
|
||||
wingPool.query(
|
||||
`SELECT
|
||||
LAYER_CD AS "layerCd",
|
||||
UP_LAYER_CD AS "upLayerCd",
|
||||
LAYER_FULL_NM AS "layerFullNm",
|
||||
LAYER_NM AS "layerNm",
|
||||
LAYER_LEVEL AS "layerLevel",
|
||||
WMS_LAYER_NM AS "wmsLayerNm",
|
||||
USE_YN AS "useYn",
|
||||
SORT_ORD AS "sortOrd",
|
||||
TO_CHAR(REG_DTM, 'YYYY-MM-DD') AS "regDtm"
|
||||
FROM LAYER
|
||||
${whereClause}
|
||||
ORDER BY LAYER_CD
|
||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||
dataParams
|
||||
),
|
||||
wingPool.query(
|
||||
`SELECT COUNT(*)::int AS total FROM LAYER ${whereClause}`,
|
||||
params
|
||||
),
|
||||
])
|
||||
|
||||
const total: number = countResult.rows[0].total
|
||||
res.json({
|
||||
items: dataResult.rows,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
})
|
||||
} catch {
|
||||
res.status(500).json({ error: '레이어 목록 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// 드롭다운용 레이어 옵션 목록
|
||||
router.get('/admin/options', requireAuth, requireRole('ADMIN'), async (_req, res) => {
|
||||
try {
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT LAYER_CD AS "layerCd", LAYER_NM AS "layerNm",
|
||||
LAYER_FULL_NM AS "layerFullNm", LAYER_LEVEL AS "layerLevel"
|
||||
FROM LAYER WHERE DEL_YN = 'N' ORDER BY LAYER_CD`
|
||||
)
|
||||
res.json(rows)
|
||||
} catch {
|
||||
res.status(500).json({ error: '레이어 옵션 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// 상위코드 기반 다음 자식 코드 계산
|
||||
router.get('/admin/next-code', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
const upLayerCd = sanitizeString(String(req.query.upLayerCd ?? '')).trim()
|
||||
if (!upLayerCd || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd) || !isValidStringLength(upLayerCd, 50)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 상위 레이어코드' })
|
||||
}
|
||||
try {
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT LAYER_CD AS "layerCd" FROM LAYER
|
||||
WHERE UP_LAYER_CD = $1 AND DEL_YN = 'N'
|
||||
ORDER BY LAYER_CD DESC LIMIT 1`,
|
||||
[upLayerCd]
|
||||
)
|
||||
let nextCode: string
|
||||
if (rows.length === 0) {
|
||||
nextCode = upLayerCd + '001'
|
||||
} else {
|
||||
const lastCd = rows[0].layerCd as string
|
||||
const suffix = lastCd.substring(upLayerCd.length)
|
||||
const num = parseInt(suffix, 10)
|
||||
nextCode = isNaN(num)
|
||||
? upLayerCd + '001'
|
||||
: upLayerCd + String(num + 1).padStart(suffix.length, '0')
|
||||
}
|
||||
res.json({ nextCode })
|
||||
} catch {
|
||||
res.status(500).json({ error: '다음 레이어코드 계산 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// 레이어 생성
|
||||
router.post('/admin/create', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
try {
|
||||
const body = req.body as {
|
||||
layerCd?: string
|
||||
upLayerCd?: string
|
||||
layerFullNm?: string
|
||||
layerNm?: string
|
||||
layerLevel?: number
|
||||
wmsLayerNm?: string
|
||||
useYn?: string
|
||||
sortOrd?: number
|
||||
}
|
||||
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' })
|
||||
}
|
||||
if (!layerNm || !isValidStringLength(layerNm, 100)) {
|
||||
return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' })
|
||||
}
|
||||
if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) {
|
||||
return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' })
|
||||
}
|
||||
if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) {
|
||||
return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' })
|
||||
}
|
||||
|
||||
// 선택 필드 검증
|
||||
if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') {
|
||||
if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' })
|
||||
}
|
||||
}
|
||||
if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') {
|
||||
if (!isValidStringLength(wmsLayerNm, 100)) {
|
||||
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`INSERT INTO LAYER (LAYER_CD, UP_LAYER_CD, LAYER_FULL_NM, LAYER_NM, LAYER_LEVEL, WMS_LAYER_NM, USE_YN, SORT_ORD, DEL_YN)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'N')
|
||||
RETURNING LAYER_CD AS "layerCd"`,
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
)
|
||||
|
||||
res.json(rows[0])
|
||||
} catch (err) {
|
||||
const pgErr = err as { code?: string }
|
||||
if (pgErr.code === '23505') {
|
||||
return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' })
|
||||
}
|
||||
res.status(500).json({ error: '레이어 생성 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// 레이어 수정
|
||||
router.post('/admin/update', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
try {
|
||||
const body = req.body as {
|
||||
layerCd?: string
|
||||
upLayerCd?: string
|
||||
layerFullNm?: string
|
||||
layerNm?: string
|
||||
layerLevel?: number
|
||||
wmsLayerNm?: string
|
||||
useYn?: string
|
||||
sortOrd?: number
|
||||
}
|
||||
|
||||
const { layerCd, upLayerCd, layerFullNm, layerNm, layerLevel, wmsLayerNm, useYn, sortOrd } = body
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 레이어코드입니다. 영숫자, 언더스코어, 하이픈만 허용됩니다.' })
|
||||
}
|
||||
if (!layerNm || !isValidStringLength(layerNm, 100)) {
|
||||
return res.status(400).json({ error: '레이어명은 필수이며 100자 이내여야 합니다.' })
|
||||
}
|
||||
if (!layerFullNm || !isValidStringLength(layerFullNm, 200)) {
|
||||
return res.status(400).json({ error: '레이어 전체명은 필수이며 200자 이내여야 합니다.' })
|
||||
}
|
||||
if (layerLevel === undefined || layerLevel === null || !isValidNumber(layerLevel, 1, 10)) {
|
||||
return res.status(400).json({ error: '레이어 레벨은 1~10 범위의 정수여야 합니다.' })
|
||||
}
|
||||
|
||||
// 선택 필드 검증
|
||||
if (upLayerCd !== undefined && upLayerCd !== null && upLayerCd !== '') {
|
||||
if (!isValidStringLength(upLayerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(upLayerCd)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 상위 레이어코드입니다.' })
|
||||
}
|
||||
}
|
||||
if (wmsLayerNm !== undefined && wmsLayerNm !== null && wmsLayerNm !== '') {
|
||||
if (!isValidStringLength(wmsLayerNm, 100)) {
|
||||
return res.status(400).json({ error: 'WMS 레이어명은 100자 이내여야 합니다.' })
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedLayerCd = sanitizeString(layerCd)
|
||||
const sanitizedUpLayerCd = upLayerCd ? sanitizeString(upLayerCd) : null
|
||||
const sanitizedLayerFullNm = sanitizeString(layerFullNm)
|
||||
const sanitizedLayerNm = sanitizeString(layerNm)
|
||||
const sanitizedWmsLayerNm = wmsLayerNm ? sanitizeString(wmsLayerNm) : null
|
||||
const sanitizedUseYn = useYn === 'N' ? 'N' : 'Y'
|
||||
const sanitizedSortOrd = typeof sortOrd === 'number' ? sortOrd : null
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`UPDATE LAYER
|
||||
SET UP_LAYER_CD = $2, LAYER_FULL_NM = $3, LAYER_NM = $4, LAYER_LEVEL = $5,
|
||||
WMS_LAYER_NM = $6, USE_YN = $7, SORT_ORD = $8
|
||||
WHERE LAYER_CD = $1
|
||||
RETURNING LAYER_CD AS "layerCd"`,
|
||||
[sanitizedLayerCd, sanitizedUpLayerCd, sanitizedLayerFullNm, sanitizedLayerNm, layerLevel, sanitizedWmsLayerNm, sanitizedUseYn, sanitizedSortOrd]
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
|
||||
}
|
||||
res.json(rows[0])
|
||||
} catch (err) {
|
||||
const pgErr = err as { code?: string }
|
||||
if (pgErr.code === '23505') {
|
||||
return res.status(409).json({ error: '이미 존재하는 레이어코드입니다.' })
|
||||
}
|
||||
res.status(500).json({ error: '레이어 수정 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// 레이어 삭제
|
||||
router.post('/admin/delete', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
try {
|
||||
const { layerCd } = req.body as { layerCd?: string }
|
||||
|
||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 레이어코드입니다.' })
|
||||
}
|
||||
|
||||
const sanitizedCd = sanitizeString(layerCd)
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`UPDATE LAYER SET DEL_YN = 'Y' WHERE LAYER_CD = $1 AND DEL_YN = 'N'
|
||||
RETURNING LAYER_CD AS "layerCd"`,
|
||||
[sanitizedCd]
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
|
||||
}
|
||||
res.json(rows[0])
|
||||
} catch {
|
||||
res.status(500).json({ error: '레이어 삭제 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// USE_YN 토글
|
||||
router.post('/admin/toggle-use', requireAuth, requireRole('ADMIN'), async (req, res) => {
|
||||
try {
|
||||
const { layerCd } = req.body as { layerCd?: string }
|
||||
|
||||
if (!layerCd || !isValidStringLength(layerCd, 50) || !/^[a-zA-Z0-9_-]+$/.test(layerCd)) {
|
||||
return res.status(400).json({ error: '유효하지 않은 레이어코드' })
|
||||
}
|
||||
|
||||
const sanitizedCd = sanitizeString(layerCd)
|
||||
const { rows } = await wingPool.query(
|
||||
`UPDATE LAYER
|
||||
SET USE_YN = CASE WHEN USE_YN = 'Y' THEN 'N' ELSE 'Y' END
|
||||
WHERE LAYER_CD = $1
|
||||
RETURNING LAYER_CD AS "layerCd", USE_YN AS "useYn"`,
|
||||
[sanitizedCd]
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
|
||||
}
|
||||
res.json(rows[0])
|
||||
} catch {
|
||||
res.status(500).json({ error: 'USE_YN 변경 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@ -22,6 +22,7 @@ import scatRouter from './scat/scatRouter.js'
|
||||
import predictionRouter from './prediction/predictionRouter.js'
|
||||
import aerialRouter from './aerial/aerialRouter.js'
|
||||
import rescueRouter from './rescue/rescueRouter.js'
|
||||
import mapBaseRouter from './map-base/mapBaseRouter.js'
|
||||
import {
|
||||
sanitizeBody,
|
||||
sanitizeQuery,
|
||||
@ -168,6 +169,7 @@ app.use('/api/scat', scatRouter)
|
||||
app.use('/api/prediction', predictionRouter)
|
||||
app.use('/api/aerial', aerialRouter)
|
||||
app.use('/api/rescue', rescueRouter)
|
||||
app.use('/api/map-base', mapBaseRouter)
|
||||
|
||||
// 헬스 체크
|
||||
app.get('/health', (_req, res) => {
|
||||
|
||||
@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS LAYER (
|
||||
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
|
||||
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
|
||||
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
|
||||
DEL_YN CHAR(1) DEFAULT 'N' NOT NULL,
|
||||
CONSTRAINT PK_LAYER PRIMARY KEY (LAYER_CD),
|
||||
CONSTRAINT FK_LAYER_UP FOREIGN KEY (UP_LAYER_CD) REFERENCES LAYER(LAYER_CD),
|
||||
CONSTRAINT CK_LAYER_USE_YN CHECK (USE_YN IN ('Y', 'N'))
|
||||
|
||||
19
database/migration/022_aerial_spectral_perm.sql
Normal file
19
database/migration/022_aerial_spectral_perm.sql
Normal file
@ -0,0 +1,19 @@
|
||||
-- aerial:spectral (AI 탐지/분석) 서브탭 권한 추가
|
||||
-- 기존 aerial 서브탭(satellite) 뒤, cctv 앞에 배치 (SORT_ORD = 6)
|
||||
|
||||
-- 기존 cctv, theory 순서 밀기
|
||||
UPDATE AUTH_PERM_TREE SET SORT_ORD = 7 WHERE RSRC_CD = 'aerial:cctv';
|
||||
UPDATE AUTH_PERM_TREE SET SORT_ORD = 8 WHERE RSRC_CD = 'aerial:theory';
|
||||
|
||||
-- spectral 리소스 추가
|
||||
INSERT INTO AUTH_PERM_TREE (RSRC_CD, PARENT_CD, RSRC_NM, RSRC_LEVEL, SORT_ORD)
|
||||
VALUES ('aerial:spectral', 'aerial', 'AI 탐지/분석', 1, 6)
|
||||
ON CONFLICT (RSRC_CD) DO NOTHING;
|
||||
|
||||
-- 기존 역할에 spectral READ 권한 부여 (aerial READ 권한이 있는 역할)
|
||||
INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, OPER_CD, USE_YN)
|
||||
SELECT ap.ROLE_SN, 'aerial:spectral', ap.OPER_CD, ap.USE_YN
|
||||
FROM AUTH_PERM ap
|
||||
WHERE ap.RSRC_CD = 'aerial'
|
||||
AND ap.USE_YN = 'Y'
|
||||
ON CONFLICT (ROLE_SN, RSRC_CD, OPER_CD) DO NOTHING;
|
||||
43
database/migration/022_map_base.sql
Normal file
43
database/migration/022_map_base.sql
Normal file
@ -0,0 +1,43 @@
|
||||
-- ============================================================
|
||||
-- 022_map_base.sql — 지도 백데이터 관리 테이블
|
||||
-- ============================================================
|
||||
-- 관리자가 등록한 지도 유형(S-57, S-101, 3D 등)을 DB로 관리하며,
|
||||
-- USE_YN 으로 활성/비활성을 제어해 TopBar 햄버거 메뉴 노출을 조정한다.
|
||||
-- ============================================================
|
||||
|
||||
SET search_path TO wing;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 지도 백데이터 마스터
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS MAP_BASE_DATA (
|
||||
MAP_SN SERIAL PRIMARY KEY,
|
||||
MAP_KEY VARCHAR(30) NOT NULL UNIQUE, -- 지도 식별 키 (s57, s101, threeD, satellite 등)
|
||||
MAP_NM VARCHAR(100) NOT NULL, -- 지도 표시명 (예: S-57 전자해도)
|
||||
MAP_LEVEL_CD VARCHAR(20), -- 지도 레벨 코드: S-52 | S-57 | S-101 | 3D | SAT | 기타
|
||||
MAP_SRC TEXT, -- 타일 URL 또는 파일 경로
|
||||
MAP_DC TEXT, -- 상세 설명
|
||||
USE_YN CHAR(1) DEFAULT 'Y', -- 사용 여부: Y(사용중) / N(미사용)
|
||||
DEL_YN CHAR(1) DEFAULT 'N', -- 삭제 여부: Y(삭제됨) / N(정상)
|
||||
REG_ID VARCHAR(50), -- 등록자 ID
|
||||
REG_NM VARCHAR(50), -- 등록자 이름
|
||||
REG_DTM TIMESTAMPTZ DEFAULT NOW(), -- 등록 일시
|
||||
MDFCN_DTM TIMESTAMPTZ DEFAULT NOW() -- 수정 일시
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 인덱스
|
||||
-- ============================================================
|
||||
CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_USE ON MAP_BASE_DATA(USE_YN);
|
||||
CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_KEY ON MAP_BASE_DATA(MAP_KEY);
|
||||
CREATE INDEX IF NOT EXISTS IDX_MAP_BASE_DEL ON MAP_BASE_DATA(DEL_YN);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 초기 데이터 — 기존 하드코딩 4개 지도 유형
|
||||
-- ============================================================
|
||||
INSERT INTO MAP_BASE_DATA (MAP_KEY, MAP_NM, MAP_LEVEL_CD, USE_YN) VALUES
|
||||
('s57', 'S-57 전자해도', 'S-57', 'Y'),
|
||||
('s101', 'S-101 전자해도', 'S-101', 'Y'),
|
||||
('threeD', '3D 지도', '3D', 'Y'),
|
||||
('satellite', '위성 영상', 'SAT', 'Y')
|
||||
ON CONFLICT (MAP_KEY) DO NOTHING;
|
||||
10
database/migration/023_layer_del_yn.sql
Normal file
10
database/migration/023_layer_del_yn.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- 023_layer_del_yn.sql
|
||||
-- LAYER 테이블에 소프트 삭제 플래그 추가
|
||||
|
||||
ALTER TABLE LAYER ADD COLUMN IF NOT EXISTS DEL_YN CHAR(1) DEFAULT 'N' NOT NULL;
|
||||
|
||||
-- 기존 데이터 초기화
|
||||
UPDATE LAYER SET DEL_YN = 'N' WHERE DEL_YN IS NULL;
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS IDX_LAYER_DEL ON LAYER (DEL_YN);
|
||||
@ -4,6 +4,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선
|
||||
|
||||
### 기타
|
||||
- 기상 탭 머지 충돌 해결
|
||||
|
||||
## [2026-03-19]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -6,6 +6,7 @@ import { LoginPage } from '@common/components/auth/LoginPage'
|
||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { useMenuStore } from '@common/store/menuStore'
|
||||
import { useMapStore } from '@common/store/mapStore'
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
import { OilSpillView } from '@tabs/prediction'
|
||||
import { ReportsView } from '@tabs/reports'
|
||||
@ -25,6 +26,7 @@ function App() {
|
||||
const [activeMainTab, setActiveMainTab] = useState<MainTab>('prediction')
|
||||
const { isAuthenticated, isLoading, checkSession } = useAuthStore()
|
||||
const { loadMenuConfig } = useMenuStore()
|
||||
const { loadMapTypes } = useMapStore()
|
||||
|
||||
useEffect(() => {
|
||||
checkSession()
|
||||
@ -33,8 +35,9 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadMenuConfig()
|
||||
loadMapTypes()
|
||||
}
|
||||
}, [isAuthenticated, loadMenuConfig])
|
||||
}, [isAuthenticated, loadMenuConfig, loadMapTypes])
|
||||
|
||||
useEffect(() => {
|
||||
registerMainTabSwitcher(setActiveMainTab)
|
||||
|
||||
@ -16,7 +16,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
const quickMenuRef = useRef<HTMLDivElement>(null)
|
||||
const { hasPermission, user, logout } = useAuthStore()
|
||||
const { menuConfig, isLoaded } = useMenuStore()
|
||||
const { mapToggles, toggleMap, measureMode, setMeasureMode } = useMapStore()
|
||||
const { mapToggles, toggleMap, mapTypes, measureMode, setMeasureMode } = useMapStore()
|
||||
|
||||
const MAP_TABS = new Set<string>(['prediction', 'hns', 'scat', 'weather'])
|
||||
const isMapTab = MAP_TABS.has(activeTab)
|
||||
@ -218,18 +218,13 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
|
||||
<div className="px-3 py-1.5 flex items-center gap-2 text-[11px] font-bold text-text-3">
|
||||
<span>🗺</span> 지도 유형
|
||||
</div>
|
||||
{([
|
||||
{ key: 's57' as const, label: 'S-57 전자해도', icon: '🗺' },
|
||||
{ key: 's101' as const, label: 'S-101 전자해도', icon: '🗺' },
|
||||
{ key: 'threeD' as const, label: '3D 지도', icon: '🗺' },
|
||||
{ key: 'satellite' as const, label: '위성 영상', icon: '🛰' },
|
||||
]).map(item => (
|
||||
<button key={item.key} onClick={() => toggleMap(item.key)} className="w-full px-3 py-2 flex items-center justify-between text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] transition-all">
|
||||
{mapTypes.map(item => (
|
||||
<button key={item.mapKey} onClick={() => toggleMap(item.mapKey)} className="w-full px-3 py-2 flex items-center justify-between text-[12px] text-text-2 hover:bg-[rgba(255,255,255,0.06)] transition-all">
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="text-[13px]">{item.icon}</span> {item.label}
|
||||
<span className="text-[13px]">🗺</span> {item.mapNm}
|
||||
</span>
|
||||
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.key] ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'}`}>
|
||||
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.key] ? 'left-[16px]' : 'left-[2px]'}`} />
|
||||
<div className={`w-[34px] h-[18px] rounded-full transition-all relative ${mapToggles[item.mapKey] ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'}`}>
|
||||
<div className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-all ${mapToggles[item.mapKey] ? 'left-[16px]' : 'left-[2px]'}`} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@ -1236,7 +1236,7 @@ export function MapView({
|
||||
])
|
||||
|
||||
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
|
||||
const currentMapStyle = mapToggles.threeD ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE
|
||||
const currentMapStyle = mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
@ -1258,7 +1258,7 @@ export function MapView({
|
||||
{/* 지도 중앙 좌표 + 줌 추적 */}
|
||||
<MapCenterTracker onCenterChange={handleMapCenterChange} />
|
||||
{/* 3D 모드 pitch 제어 */}
|
||||
<MapPitchController threeD={mapToggles.threeD} />
|
||||
<MapPitchController threeD={mapToggles['threeD'] ?? false} />
|
||||
{/* 사고 지점 변경 시 지도 이동 */}
|
||||
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
|
||||
{/* 외부에서 flyTo 트리거 */}
|
||||
@ -1400,23 +1400,23 @@ function MapControls({ center, zoom }: { center: [number, number]; zoom: number
|
||||
const { current: map } = useMap()
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="absolute top-[80px] left-[10px] z-10">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => map?.zoomIn()}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.zoomOut()}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-xs"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
|
||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
|
||||
className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-[10px]"
|
||||
>
|
||||
🎯
|
||||
</button>
|
||||
@ -1435,7 +1435,7 @@ interface MapLegendProps {
|
||||
}
|
||||
|
||||
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
|
||||
const [minimized, setMinimized] = useState(false)
|
||||
const [minimized, setMinimized] = useState(true)
|
||||
|
||||
if (dispersionResult && incidentCoord) {
|
||||
return (
|
||||
|
||||
@ -40,9 +40,10 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
||||
{ id: 'media', label: '영상사진관리', icon: '📷' },
|
||||
{ id: 'analysis', label: '영상사진합성', icon: '🧩' },
|
||||
{ id: 'realtime', label: '실시간드론', icon: '🛸' },
|
||||
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
|
||||
{ id: 'satellite', label: '위성요청', icon: '🛰' },
|
||||
{ id: 'satellite', label: '위성영상', icon: '🛰' },
|
||||
{ id: 'cctv', label: 'CCTV 조회', icon: '📹' },
|
||||
{ id: 'spectral', label: 'AI 탐지/분석', icon: '🤖' },
|
||||
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
|
||||
{ id: 'theory', label: '항공탐색 이론', icon: '📐' }
|
||||
],
|
||||
assets: null,
|
||||
@ -211,6 +212,13 @@ export interface OilReportPayload {
|
||||
coastal: {
|
||||
firstTime: string | null;
|
||||
};
|
||||
spreadSteps?: Array<{
|
||||
elapsed: string;
|
||||
weathered: string;
|
||||
seaRemain: string;
|
||||
coastAttach: string;
|
||||
area: string;
|
||||
}>;
|
||||
hasSimulation: boolean;
|
||||
mapData: {
|
||||
center: [number, number];
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
import { api } from '../services/api'
|
||||
import { haversineDistance, polygonAreaKm2 } from '../utils/geo'
|
||||
|
||||
interface MapToggles {
|
||||
s57: boolean;
|
||||
s101: boolean;
|
||||
threeD: boolean;
|
||||
satellite: boolean;
|
||||
export interface MapTypeItem {
|
||||
mapKey: string;
|
||||
mapNm: string;
|
||||
mapLevelCd: string | null;
|
||||
}
|
||||
|
||||
export type MeasureMode = 'distance' | 'area' | null;
|
||||
@ -22,9 +22,18 @@ export interface MeasureResult {
|
||||
value: number; // distance(m) or area(km²)
|
||||
}
|
||||
|
||||
interface MapToggles {
|
||||
s57: boolean;
|
||||
s101: boolean;
|
||||
threeD: boolean;
|
||||
satellite: boolean;
|
||||
}
|
||||
|
||||
interface MapState {
|
||||
mapToggles: MapToggles;
|
||||
mapTypes: MapTypeItem[];
|
||||
toggleMap: (key: keyof MapToggles) => void;
|
||||
loadMapTypes: () => Promise<void>;
|
||||
// 측정
|
||||
measureMode: MeasureMode;
|
||||
measureInProgress: MeasurePoint[];
|
||||
@ -36,14 +45,42 @@ interface MapState {
|
||||
clearAllMeasurements: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAP_TYPES: MapTypeItem[] = [
|
||||
{ mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' },
|
||||
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
|
||||
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
|
||||
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
|
||||
]
|
||||
|
||||
let measureIdCounter = 0;
|
||||
|
||||
export const useMapStore = create<MapState>((set, get) => ({
|
||||
mapToggles: { s57: true, s101: false, threeD: false, satellite: false },
|
||||
mapTypes: DEFAULT_MAP_TYPES,
|
||||
toggleMap: (key) =>
|
||||
set((s) => ({
|
||||
mapToggles: { ...s.mapToggles, [key]: !s.mapToggles[key] },
|
||||
})),
|
||||
loadMapTypes: async () => {
|
||||
try {
|
||||
const res = await api.get<MapTypeItem[]>('/map-base/active')
|
||||
const types = res.data
|
||||
const current = get().mapToggles
|
||||
const newToggles: Partial<MapToggles> = {}
|
||||
for (const t of types) {
|
||||
if (t.mapKey in current) {
|
||||
newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false
|
||||
}
|
||||
}
|
||||
// s57 기본값 유지
|
||||
if (newToggles['s57'] === undefined && types.find(t => t.mapKey === 's57')) {
|
||||
newToggles['s57'] = true
|
||||
}
|
||||
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } })
|
||||
} catch {
|
||||
// API 실패 시 fallback 유지
|
||||
}
|
||||
},
|
||||
|
||||
// 측정
|
||||
measureMode: null,
|
||||
|
||||
@ -69,6 +69,76 @@
|
||||
color: var(--t3);
|
||||
}
|
||||
|
||||
/* Date/Time picker custom styling */
|
||||
.prd-date-input::-webkit-calendar-picker-indicator,
|
||||
.prd-time-input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prd-date-input,
|
||||
.prd-time-input {
|
||||
font-size: 10px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit,
|
||||
.prd-time-input::-webkit-datetime-edit {
|
||||
color: var(--t2);
|
||||
font-family: var(--fM);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-fields-wrapper,
|
||||
.prd-time-input::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-year-field,
|
||||
.prd-date-input::-webkit-datetime-edit-month-field,
|
||||
.prd-date-input::-webkit-datetime-edit-day-field,
|
||||
.prd-time-input::-webkit-datetime-edit-hour-field,
|
||||
.prd-time-input::-webkit-datetime-edit-minute-field,
|
||||
.prd-time-input::-webkit-datetime-edit-ampm-field {
|
||||
color: var(--t2);
|
||||
background: transparent;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-year-field:focus,
|
||||
.prd-date-input::-webkit-datetime-edit-month-field:focus,
|
||||
.prd-date-input::-webkit-datetime-edit-day-field:focus,
|
||||
.prd-time-input::-webkit-datetime-edit-hour-field:focus,
|
||||
.prd-time-input::-webkit-datetime-edit-minute-field:focus,
|
||||
.prd-time-input::-webkit-datetime-edit-ampm-field:focus {
|
||||
background: rgba(6, 182, 212, 0.12);
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.prd-date-input::-webkit-datetime-edit-text,
|
||||
.prd-time-input::-webkit-datetime-edit-text {
|
||||
color: var(--t3);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* Time hour/minute select (dark dropdown) */
|
||||
select.prd-i.prd-time-select {
|
||||
color-scheme: dark;
|
||||
-webkit-appearance: menulist !important;
|
||||
appearance: menulist !important;
|
||||
background: var(--bg3) !important;
|
||||
background-image: none !important;
|
||||
padding-right: 4px;
|
||||
color: var(--t1);
|
||||
border-color: var(--bd);
|
||||
}
|
||||
|
||||
/* Select Dropdown */
|
||||
select.prd-i {
|
||||
cursor: pointer;
|
||||
@ -210,10 +280,11 @@
|
||||
.prd-mc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 13px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 5px 4px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
cursor: pointer;
|
||||
@ -294,18 +365,20 @@
|
||||
.cod {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 16px;
|
||||
background: rgba(18, 25, 41, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--bd);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(18, 25, 41, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(30, 42, 66, 0.4);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
padding: 5px 14px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--t2);
|
||||
font-size: 10px;
|
||||
color: #1a1a2e;
|
||||
font-weight: 600;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cov {
|
||||
@ -316,40 +389,41 @@
|
||||
/* ═══ Weather Info Panel ═══ */
|
||||
.wip {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
background: rgba(18, 25, 41, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(18, 25, 41, 0.65);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(30, 42, 66, 0.5);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.wii {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wii-icon {
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.wii-value {
|
||||
font-size: 15px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--t1);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.wii-label {
|
||||
font-size: 9px;
|
||||
color: var(--t3);
|
||||
font-size: 7px;
|
||||
color: #1a1a2e;
|
||||
font-weight: 700;
|
||||
font-family: 'Noto Sans KR', sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ import BoardMgmtPanel from './BoardMgmtPanel';
|
||||
import VesselSignalPanel from './VesselSignalPanel';
|
||||
import CleanupEquipPanel from './CleanupEquipPanel';
|
||||
import AssetUploadPanel from './AssetUploadPanel';
|
||||
import MapBasePanel from './MapBasePanel';
|
||||
import LayerPanel from './LayerPanel';
|
||||
|
||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||
@ -23,6 +25,8 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||
'collect-vessel-signal': () => <VesselSignalPanel />,
|
||||
'cleanup-equip': () => <CleanupEquipPanel />,
|
||||
'asset-upload': () => <AssetUploadPanel />,
|
||||
'map-base': () => <MapBasePanel />,
|
||||
'map-layer': () => <LayerPanel />,
|
||||
};
|
||||
|
||||
export function AdminView() {
|
||||
|
||||
620
frontend/src/tabs/admin/components/LayerPanel.tsx
Normal file
620
frontend/src/tabs/admin/components/LayerPanel.tsx
Normal file
@ -0,0 +1,620 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
interface LayerAdminItem {
|
||||
layerCd: string;
|
||||
upLayerCd: string | null;
|
||||
layerFullNm: string;
|
||||
layerNm: string;
|
||||
layerLevel: number;
|
||||
wmsLayerNm: string | null;
|
||||
useYn: string;
|
||||
sortOrd: number;
|
||||
regDtm: string | null;
|
||||
}
|
||||
|
||||
interface LayerListResponse {
|
||||
items: LayerAdminItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface LayerOption {
|
||||
layerCd: string;
|
||||
layerNm: string;
|
||||
layerFullNm: string;
|
||||
layerLevel: number;
|
||||
}
|
||||
|
||||
interface LayerFormData {
|
||||
layerCd: string;
|
||||
upLayerCd: string;
|
||||
layerFullNm: string;
|
||||
layerNm: string;
|
||||
layerLevel: number;
|
||||
wmsLayerNm: string;
|
||||
useYn: string;
|
||||
sortOrd: number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
async function fetchLayers(page: number, search: string, useYn: string): Promise<LayerListResponse> {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) });
|
||||
if (search) params.set('search', search);
|
||||
if (useYn) params.set('useYn', useYn);
|
||||
const res = await api.get<LayerListResponse>(`/layers/admin/list?${params}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function fetchLayerOptions(): Promise<LayerOption[]> {
|
||||
const res = await api.get<LayerOption[]>('/layers/admin/options');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function toggleLayerUse(layerCd: string): Promise<{ layerCd: string; useYn: string }> {
|
||||
const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', { layerCd });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function createLayer(body: LayerFormData): Promise<void> {
|
||||
await api.post('/layers/admin/create', body);
|
||||
}
|
||||
|
||||
async function updateLayer(body: LayerFormData): Promise<void> {
|
||||
await api.post('/layers/admin/update', body);
|
||||
}
|
||||
|
||||
async function deleteLayer(layerCd: string): Promise<void> {
|
||||
await api.post('/layers/admin/delete', { layerCd });
|
||||
}
|
||||
|
||||
async function fetchNextLayerCode(upLayerCd: string): Promise<string> {
|
||||
const params = new URLSearchParams({ upLayerCd });
|
||||
const res = await api.get<{ nextCode: string }>(`/layers/admin/next-code?${params}`);
|
||||
return res.data.nextCode;
|
||||
}
|
||||
|
||||
// ---------- LayerFormModal ----------
|
||||
|
||||
interface LayerFormModalProps {
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: LayerAdminItem;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalProps) => {
|
||||
const [form, setForm] = useState<LayerFormData>({
|
||||
layerCd: initialData?.layerCd ?? '',
|
||||
upLayerCd: initialData?.upLayerCd ?? '',
|
||||
layerFullNm: initialData?.layerFullNm ?? '',
|
||||
layerNm: initialData?.layerNm ?? '',
|
||||
layerLevel: initialData?.layerLevel ?? 1,
|
||||
wmsLayerNm: initialData?.wmsLayerNm ?? '',
|
||||
useYn: initialData?.useYn ?? 'Y',
|
||||
sortOrd: initialData?.sortOrd ?? 0,
|
||||
});
|
||||
const [options, setOptions] = useState<LayerOption[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [parentInfo, setParentInfo] = useState<{ fullNm: string; level: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLayerOptions().then(setOptions).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleField = <K extends keyof LayerFormData>(key: K, value: LayerFormData[K]) => {
|
||||
setForm(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleParentChange = async (upLayerCd: string) => {
|
||||
if (!upLayerCd) {
|
||||
setParentInfo(null);
|
||||
setForm(prev => ({ ...prev, upLayerCd: '' }));
|
||||
return;
|
||||
}
|
||||
const parent = options.find(o => o.layerCd === upLayerCd);
|
||||
if (parent) {
|
||||
setParentInfo({ fullNm: parent.layerFullNm, level: parent.layerLevel });
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
upLayerCd,
|
||||
layerLevel: parent.layerLevel + 1,
|
||||
layerFullNm: prev.layerNm ? `${parent.layerFullNm} ${prev.layerNm}` : parent.layerFullNm,
|
||||
}));
|
||||
}
|
||||
try {
|
||||
const nextCode = await fetchNextLayerCode(upLayerCd);
|
||||
setForm(prev => ({ ...prev, layerCd: nextCode }));
|
||||
} catch { /* 실패 시 사용자 수동 입력 */ }
|
||||
};
|
||||
|
||||
const handleLayerNmChange = (value: string) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
layerNm: value,
|
||||
...(parentInfo && {
|
||||
layerFullNm: value ? `${parentInfo.fullNm} ${value}` : parentInfo.fullNm,
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.layerCd.trim()) { setFormError('레이어코드는 필수입니다.'); return; }
|
||||
if (!form.layerNm.trim()) { setFormError('레이어명은 필수입니다.'); return; }
|
||||
if (!form.layerFullNm.trim()) { setFormError('레이어전체명은 필수입니다.'); return; }
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
await createLayer(form);
|
||||
} else {
|
||||
await updateLayer(form);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setFormError(msg ?? (mode === 'create' ? '등록에 실패했습니다.' : '수정에 실패했습니다.'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none';
|
||||
const labelCls = 'block text-[11px] font-semibold text-text-2 font-korean mb-1.5';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">
|
||||
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-text-3 hover:text-text-1 transition-colors">✕</button>
|
||||
</div>
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* 상위 레이어코드 */}
|
||||
<div>
|
||||
<label className={labelCls}>상위 레이어코드</label>
|
||||
<select
|
||||
value={form.upLayerCd}
|
||||
onChange={e => mode === 'create' ? handleParentChange(e.target.value) : handleField('upLayerCd', e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||
>
|
||||
<option value="">(없음)</option>
|
||||
{options
|
||||
.filter(o => o.layerCd !== form.layerCd)
|
||||
.map(o => (
|
||||
<option key={o.layerCd} value={o.layerCd}>
|
||||
{o.layerCd} — {o.layerNm}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
{/* 레이어코드 */}
|
||||
<div>
|
||||
<label className={labelCls}>레이어코드 <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.layerCd}
|
||||
onChange={e => handleField('layerCd', e.target.value)}
|
||||
readOnly={mode === 'edit'}
|
||||
placeholder="예: LAYER_001"
|
||||
className={`${inputCls} font-mono${mode === 'edit' ? ' bg-bg-0 text-text-3 cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{/* 레이어명 */}
|
||||
<div>
|
||||
<label className={labelCls}>레이어명 <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.layerNm}
|
||||
onChange={e => mode === 'create' ? handleLayerNmChange(e.target.value) : handleField('layerNm', e.target.value)}
|
||||
maxLength={100}
|
||||
placeholder="레이어 이름"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/* 레이어전체명 */}
|
||||
<div>
|
||||
<label className={labelCls}>레이어전체명 <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.layerFullNm}
|
||||
onChange={e => handleField('layerFullNm', e.target.value)}
|
||||
maxLength={200}
|
||||
placeholder="레이어 전체 경로명"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/* 레벨 */}
|
||||
<div>
|
||||
<label className={labelCls}>레벨</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.layerLevel}
|
||||
onChange={e => handleField('layerLevel', Number(e.target.value))}
|
||||
min={1}
|
||||
max={10}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/* WMS레이어명 */}
|
||||
<div>
|
||||
<label className={labelCls}>WMS레이어명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.wmsLayerNm}
|
||||
onChange={e => handleField('wmsLayerNm', e.target.value)}
|
||||
placeholder="WMS 레이어명 (선택)"
|
||||
className={`${inputCls} font-mono`}
|
||||
/>
|
||||
</div>
|
||||
{/* 정렬순서 */}
|
||||
<div>
|
||||
<label className={labelCls}>정렬순서</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.sortOrd}
|
||||
onChange={e => handleField('sortOrd', Number(e.target.value))}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{/* 사용여부 */}
|
||||
<div>
|
||||
<label className={labelCls}>사용여부</label>
|
||||
<select
|
||||
value={form.useYn}
|
||||
onChange={e => handleField('useYn', e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none"
|
||||
>
|
||||
<option value="Y">사용</option>
|
||||
<option value="N">미사용</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 에러 */}
|
||||
{formError && (
|
||||
<div className="px-6 pb-2">
|
||||
<p className="text-[11px] text-red-400 font-korean">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 text-xs bg-primary-cyan text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
|
||||
>
|
||||
{saving ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- LayerPanel ----------
|
||||
|
||||
const LayerPanel = () => {
|
||||
const [items, setItems] = useState<LayerAdminItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 검색
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [appliedSearch, setAppliedSearch] = useState('');
|
||||
const [filterUseYn, setFilterUseYn] = useState('');
|
||||
|
||||
// 모달
|
||||
const [modal, setModal] = useState<{ mode: 'create' | 'edit'; data?: LayerAdminItem } | null>(null);
|
||||
|
||||
const load = useCallback(async (p: number, search: string, useYn: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchLayers(p, search, useYn);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setTotalPages(res.totalPages);
|
||||
} catch {
|
||||
setError('레이어 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(page, appliedSearch, filterUseYn);
|
||||
}, [load, page, appliedSearch, filterUseYn]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedSearch(searchInput);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleToggle = async (layerCd: string) => {
|
||||
if (toggling) return;
|
||||
setToggling(layerCd);
|
||||
try {
|
||||
const result = await toggleLayerUse(layerCd);
|
||||
setItems(prev =>
|
||||
prev.map(item =>
|
||||
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
setError('사용여부 변경에 실패했습니다.');
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (layerCd: string) => {
|
||||
if (!confirm(`레이어 [${layerCd}]를 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
await deleteLayer(layerCd);
|
||||
load(page, appliedSearch, filterUseYn);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg ?? '레이어 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const buildPageButtons = () => {
|
||||
const buttons: (number | 'ellipsis')[] = [];
|
||||
const delta = 2;
|
||||
const left = page - delta;
|
||||
const right = page + delta;
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= left && i <= right)) {
|
||||
buttons.push(i);
|
||||
} else if (buttons[buttons.length - 1] !== 'ellipsis') {
|
||||
buttons.push('ellipsis');
|
||||
}
|
||||
}
|
||||
return buttons;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">레이어 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {total}개</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'create' })}
|
||||
className="px-3 py-1.5 text-xs font-semibold bg-primary-cyan text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
|
||||
>
|
||||
신규 등록
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="레이어코드 / 레이어명 검색"
|
||||
className="flex-1 px-3 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
/>
|
||||
<select
|
||||
value={filterUseYn}
|
||||
onChange={e => setFilterUseYn(e.target.value)}
|
||||
className="px-2 py-1.5 text-xs bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="Y">사용</option>
|
||||
<option value="N">미사용</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-3 py-1.5 text-xs border border-border text-text-2 rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
검색
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<div className="px-6 py-2 text-xs text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-border shrink-0 font-korean">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-text-3 text-sm font-korean">
|
||||
불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">레이어코드</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어명</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">레이어전체명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-12 whitespace-nowrap">레벨</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-mono">WMS레이어명</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-16">정렬</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-28">등록일시</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-20">사용여부</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean w-28 whitespace-nowrap">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-12 text-center text-text-3 text-sm font-korean">
|
||||
데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item, idx) => (
|
||||
<tr
|
||||
key={item.layerCd}
|
||||
className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
>
|
||||
{/* 번호 */}
|
||||
<td className="px-4 py-3 text-xs text-text-3 font-mono">
|
||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
{/* 레이어코드 */}
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||
{item.layerCd}
|
||||
</td>
|
||||
{/* 레이어명 */}
|
||||
<td className="px-4 py-3 text-xs text-text-1 font-korean">
|
||||
{item.layerNm}
|
||||
</td>
|
||||
{/* 레이어전체명 */}
|
||||
<td className="px-4 py-3 text-xs text-text-2 font-korean max-w-[200px]">
|
||||
<span className="block truncate" title={item.layerFullNm}>
|
||||
{item.layerFullNm}
|
||||
</span>
|
||||
</td>
|
||||
{/* 레벨 */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-[rgba(6,182,212,0.3)]">
|
||||
{item.layerLevel}
|
||||
</span>
|
||||
</td>
|
||||
{/* WMS레이어명 */}
|
||||
<td className="px-4 py-3 text-[11px] text-text-2 font-mono">
|
||||
{item.wmsLayerNm ?? <span className="text-text-3">-</span>}
|
||||
</td>
|
||||
{/* 정렬순서 */}
|
||||
<td className="px-4 py-3 text-xs text-text-3 text-center font-mono">
|
||||
{item.sortOrd}
|
||||
</td>
|
||||
{/* 등록일시 */}
|
||||
<td className="px-4 py-3 text-[11px] text-text-3 font-mono">
|
||||
{item.regDtm ?? '-'}
|
||||
</td>
|
||||
{/* 사용여부 토글 */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggle(item.layerCd)}
|
||||
disabled={toggling === item.layerCd}
|
||||
title={item.useYn === 'Y' ? '사용 중 (클릭하여 비활성화)' : '미사용 (클릭하여 활성화)'}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||||
item.useYn === 'Y'
|
||||
? 'bg-primary-cyan'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
item.useYn === 'Y' ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
{/* 액션 */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5 flex-nowrap">
|
||||
<button
|
||||
onClick={() => setModal({ mode: 'edit', data: item })}
|
||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-primary-cyan hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.layerCd)}
|
||||
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 font-korean whitespace-nowrap"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1 shrink-0">
|
||||
<span className="text-[11px] text-text-3 font-korean">
|
||||
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
{buildPageButtons().map((btn, i) =>
|
||||
btn === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1.5 text-[11px] text-text-3">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={btn}
|
||||
onClick={() => setPage(btn)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
|
||||
page === btn
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'border border-border text-text-3 hover:bg-[rgba(255,255,255,0.04)]'
|
||||
}`}
|
||||
>
|
||||
{btn}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-border text-text-3 rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 */}
|
||||
{modal && (
|
||||
<LayerFormModal
|
||||
mode={modal.mode}
|
||||
initialData={modal.data}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={() => load(page, appliedSearch, filterUseYn)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayerPanel;
|
||||
505
frontend/src/tabs/admin/components/MapBasePanel.tsx
Normal file
505
frontend/src/tabs/admin/components/MapBasePanel.tsx
Normal file
@ -0,0 +1,505 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '@common/services/api';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
|
||||
// ─── 타입 ─────────────────────────────────────────────────
|
||||
interface MapBaseItem {
|
||||
mapSn: number;
|
||||
mapKey: string;
|
||||
mapNm: string;
|
||||
mapLevelCd: string | null;
|
||||
mapSrc: string | null;
|
||||
mapDc: string | null;
|
||||
useYn: string;
|
||||
regId: string | null;
|
||||
regNm: string | null;
|
||||
regDtm: string | null;
|
||||
}
|
||||
|
||||
interface MapBaseForm {
|
||||
mapKey: string;
|
||||
mapNm: string;
|
||||
mapLevelCd: string;
|
||||
mapSrc: string;
|
||||
mapDc: string;
|
||||
useYn: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
type: 'success' | 'error';
|
||||
text: string;
|
||||
}
|
||||
|
||||
// ─── 상수 ─────────────────────────────────────────────────
|
||||
const EMPTY_FORM: MapBaseForm = {
|
||||
mapKey: '',
|
||||
mapNm: '',
|
||||
mapLevelCd: '',
|
||||
mapSrc: '',
|
||||
mapDc: '',
|
||||
useYn: 'Y',
|
||||
};
|
||||
|
||||
const MAP_LEVEL_OPTIONS = ['S-52', 'S-57', 'S-101', '3D', 'SAT', '기타'] as const;
|
||||
|
||||
// ─── 모달 ─────────────────────────────────────────────────
|
||||
interface MapBaseModalProps {
|
||||
editItem: MapBaseItem | null;
|
||||
form: MapBaseForm;
|
||||
onFormChange: (form: MapBaseForm) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => Promise<void>;
|
||||
saving: boolean;
|
||||
modalError: string | null;
|
||||
}
|
||||
|
||||
function MapBaseModal({
|
||||
editItem,
|
||||
form,
|
||||
onFormChange,
|
||||
onClose,
|
||||
onSave,
|
||||
saving,
|
||||
modalError,
|
||||
}: MapBaseModalProps) {
|
||||
const isEdit = editItem !== null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await onSave();
|
||||
};
|
||||
|
||||
const setField = <K extends keyof MapBaseForm>(key: K, value: MapBaseForm[K]) => {
|
||||
onFormChange({ ...form, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-bg-1 border border-border rounded-lg shadow-lg w-[520px] max-h-[90vh] flex flex-col">
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-text-1 font-korean">
|
||||
{isEdit ? '지도 수정' : '지도 등록'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-3 hover:text-text-1 transition-colors"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* 지도 이름 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
지도 이름 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.mapNm}
|
||||
onChange={e => setField('mapNm', e.target.value)}
|
||||
placeholder="지도 이름을 입력하세요"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지도 키 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
지도 키 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.mapKey}
|
||||
onChange={e => setField('mapKey', e.target.value)}
|
||||
placeholder="고유 식별 키 (영문/숫자)"
|
||||
disabled={isEdit}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지도 레벨 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
지도 레벨
|
||||
</label>
|
||||
<select
|
||||
value={form.mapLevelCd}
|
||||
onChange={e => setField('mapLevelCd', e.target.value)}
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{MAP_LEVEL_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 파일 소스 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
파일 소스
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.mapSrc}
|
||||
onChange={e => setField('mapSrc', e.target.value)}
|
||||
placeholder="타일 URL 또는 파일 경로"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상세 설명 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
상세 설명
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.mapDc}
|
||||
onChange={e => setField('mapDc', e.target.value)}
|
||||
placeholder="지도에 대한 설명을 입력하세요"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 사용여부 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-text-2 font-korean mb-1.5">
|
||||
사용여부
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setField('useYn', form.useYn === 'Y' ? 'N' : 'Y')}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
form.useYn === 'Y' ? 'bg-primary-cyan' : 'bg-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
form.useYn === 'Y' ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-xs text-text-2 font-korean">
|
||||
{form.useYn === 'Y' ? '사용' : '미사용'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 */}
|
||||
{modalError && (
|
||||
<p className="text-[11px] text-red-400 font-korean">{modalError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-xs border border-border text-text-2 rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||
>
|
||||
{saving ? '저장 중...' : isEdit ? '수정' : '등록'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 패널 ─────────────────────────────────────────────
|
||||
function MapBasePanel() {
|
||||
const loadMapTypes = useMapStore(s => s.loadMapTypes);
|
||||
const [items, setItems] = useState<MapBaseItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editItem, setEditItem] = useState<MapBaseItem | null>(null);
|
||||
const [form, setForm] = useState<MapBaseForm>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modalError, setModalError] = useState<string | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ rows: MapBaseItem[]; total: number }>(
|
||||
`/map-base?page=${page}&limit=10`
|
||||
);
|
||||
setItems(res.data.rows);
|
||||
setTotal(res.data.total);
|
||||
setTotalPages(Math.max(1, Math.ceil(res.data.total / 10)));
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: '지도 목록을 불러오는 데 실패했습니다.' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// page 변경 시 목록 재조회 (loadData는 page를 클로저로 참조)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { loadData(); }, [page]);
|
||||
|
||||
const openModal = (item: MapBaseItem | null) => {
|
||||
setEditItem(item);
|
||||
setModalError(null);
|
||||
if (item) {
|
||||
setForm({
|
||||
mapKey: item.mapKey,
|
||||
mapNm: item.mapNm,
|
||||
mapLevelCd: item.mapLevelCd ?? '',
|
||||
mapSrc: item.mapSrc ?? '',
|
||||
mapDc: item.mapDc ?? '',
|
||||
useYn: item.useYn,
|
||||
});
|
||||
} else {
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setEditItem(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setModalError(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.mapNm.trim()) {
|
||||
setModalError('지도 이름은 필수 항목입니다.');
|
||||
return;
|
||||
}
|
||||
if (!form.mapKey.trim()) {
|
||||
setModalError('지도 키는 필수 항목입니다.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setModalError(null);
|
||||
try {
|
||||
if (editItem) {
|
||||
await api.post('/map-base/update', { mapSn: editItem.mapSn, ...form });
|
||||
} else {
|
||||
await api.post('/map-base', form);
|
||||
}
|
||||
closeModal();
|
||||
await loadData();
|
||||
setMessage({
|
||||
type: 'success',
|
||||
text: editItem ? '지도 정보가 수정되었습니다.' : '지도가 등록되었습니다.',
|
||||
});
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch {
|
||||
setModalError(editItem ? '지도 수정에 실패했습니다.' : '지도 등록에 실패했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item: MapBaseItem) => {
|
||||
if (!window.confirm('이 지도를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api.post('/map-base/delete', { mapSn: item.mapSn });
|
||||
await loadData();
|
||||
setMessage({ type: 'success', text: '지도가 삭제되었습니다.' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: '지도 삭제에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUse = async (item: MapBaseItem) => {
|
||||
try {
|
||||
await api.post('/map-base/update', {
|
||||
mapSn: item.mapSn,
|
||||
useYn: item.useYn === 'Y' ? 'N' : 'Y',
|
||||
});
|
||||
await loadData();
|
||||
await loadMapTypes();
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: '사용여부 변경에 실패했습니다.' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-bg-0">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-text-1 font-korean">지도 관리</h1>
|
||||
<p className="text-xs text-text-3 mt-1 font-korean">총 {total}건</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openModal(null)}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
>
|
||||
+ 등록
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 메시지 */}
|
||||
{message && (
|
||||
<div
|
||||
className={`mx-6 mt-2 px-3 py-2 text-[11px] rounded-md font-korean ${
|
||||
message.type === 'success'
|
||||
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
|
||||
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-bg-1 z-10">
|
||||
<tr className="border-b border-border text-text-3">
|
||||
<th className="w-12 py-3 text-center">번호</th>
|
||||
<th className="py-3 text-left pl-4">지도 리스트</th>
|
||||
<th className="w-20 py-3 text-center">지도 레벨</th>
|
||||
<th className="w-24 py-3 text-center">등록자</th>
|
||||
<th className="w-28 py-3 text-center">등록일</th>
|
||||
<th className="w-16 py-3 text-center">사용여부</th>
|
||||
<th className="w-12 py-3 text-center">수정</th>
|
||||
<th className="w-12 py-3 text-center">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-text-3 font-korean">
|
||||
불러오는 중...
|
||||
</td>
|
||||
</tr>
|
||||
) : items.map((item, idx) => (
|
||||
<tr
|
||||
key={item.mapSn}
|
||||
className="border-b border-border hover:bg-bg-1/50 transition-colors"
|
||||
>
|
||||
<td className="py-3 text-center text-text-3">
|
||||
{(page - 1) * 10 + idx + 1}
|
||||
</td>
|
||||
<td className="py-3 pl-4">
|
||||
<span className="text-text-1 font-korean">{item.mapNm}</span>
|
||||
<span className="ml-2 text-[10px] text-text-3 font-mono">{item.mapKey}</span>
|
||||
</td>
|
||||
<td className="py-3 text-center text-text-2">{item.mapLevelCd ?? '-'}</td>
|
||||
<td className="py-3 text-center text-text-2 font-korean">{item.regNm ?? '-'}</td>
|
||||
<td className="py-3 text-center text-text-3">{item.regDtm ?? '-'}</td>
|
||||
<td
|
||||
className="py-3 text-center cursor-pointer"
|
||||
onClick={() => handleToggleUse(item)}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
item.useYn === 'Y' ? 'bg-primary-cyan' : 'bg-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||
item.useYn === 'Y' ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 text-center">
|
||||
<button
|
||||
onClick={() => openModal(item)}
|
||||
className="px-3 py-1 text-xs rounded bg-[rgba(6,182,212,0.15)] text-primary-cyan hover:bg-[rgba(6,182,212,0.25)]"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="px-3 py-1 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!loading && items.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-xs text-text-3 font-korean">
|
||||
등록된 지도가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-1 py-2 border-t border-border">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => {
|
||||
const startPage = Math.max(1, Math.min(page - 4, totalPages - 9));
|
||||
const p = startPage + i;
|
||||
if (p > totalPages) return null;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`w-7 h-7 text-xs rounded ${
|
||||
p === page ? 'bg-blue-500/20 text-blue-400 font-medium' : 'text-text-3 hover:bg-bg-2'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-2 py-1 text-xs rounded text-text-3 hover:bg-bg-2 disabled:opacity-30"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 */}
|
||||
{showModal && (
|
||||
<MapBaseModal
|
||||
editItem={editItem}
|
||||
form={form}
|
||||
onFormChange={setForm}
|
||||
onClose={closeModal}
|
||||
onSave={handleSave}
|
||||
saving={saving}
|
||||
modalError={modalError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapBasePanel;
|
||||
@ -36,7 +36,7 @@ export const ADMIN_MENU: AdminMenuItem[] = [
|
||||
{
|
||||
id: 'map-mgmt', label: '지도관리',
|
||||
children: [
|
||||
{ id: 'map-vector', label: '지도벡데이터' },
|
||||
{ id: 'map-base', label: '지도백데이터' },
|
||||
{ id: 'map-layer', label: '레이어' },
|
||||
],
|
||||
},
|
||||
|
||||
@ -5,6 +5,7 @@ import { OilAreaAnalysis } from './OilAreaAnalysis'
|
||||
import { RealtimeDrone } from './RealtimeDrone'
|
||||
import { SensorAnalysis } from './SensorAnalysis'
|
||||
import { SatelliteRequest } from './SatelliteRequest'
|
||||
import { WingAI } from './WingAI'
|
||||
import { CctvView } from './CctvView'
|
||||
|
||||
export function AerialView() {
|
||||
@ -16,6 +17,8 @@ export function AerialView() {
|
||||
return <AerialTheoryView />
|
||||
case 'satellite':
|
||||
return <SatelliteRequest />
|
||||
case 'spectral':
|
||||
return <WingAI />
|
||||
case 'cctv':
|
||||
return <CctvView />
|
||||
case 'analysis':
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Map, Source, Layer } from '@vis.gl/react-maplibre'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { Marker } from '@vis.gl/react-maplibre'
|
||||
import { fetchSatellitePasses } from '../services/aerialApi'
|
||||
|
||||
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''
|
||||
import type { SatellitePass } from '../services/aerialApi'
|
||||
|
||||
interface SatRequest {
|
||||
id: string
|
||||
@ -9,20 +17,22 @@ interface SatRequest {
|
||||
requestDate: string
|
||||
expectedReceive: string
|
||||
resolution: string
|
||||
status: '촬영중' | '대기' | '완료'
|
||||
status: '촬영중' | '대기' | '완료' | '취소'
|
||||
provider?: string
|
||||
purpose?: string
|
||||
requester?: string
|
||||
/** ISO 날짜 (필터용) */
|
||||
dateKey?: string
|
||||
}
|
||||
|
||||
const satRequests: SatRequest[] = [
|
||||
{ id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 08:14', expectedReceive: '02-20 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양' },
|
||||
{ id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '02-20 09:02', expectedReceive: '02-21 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수' },
|
||||
{ id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '02-20 09:30', expectedReceive: '02-21 11:00', resolution: '10m', status: '대기', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진' },
|
||||
{ id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 10:05', expectedReceive: '02-22 08:00', resolution: '0.5m', status: '대기', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양' },
|
||||
{ id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '02-19 14:00', expectedReceive: '02-19 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진' },
|
||||
{ id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '02-18 11:30', expectedReceive: '02-18 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양' },
|
||||
{ id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '02-17 09:00', expectedReceive: '02-17 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진' },
|
||||
{ id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '03-17 08:14', expectedReceive: '03-17 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양', dateKey: '2026-03-17' },
|
||||
{ id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '03-17 09:02', expectedReceive: '03-18 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수', dateKey: '2026-03-17' },
|
||||
{ id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '03-16 09:30', expectedReceive: '03-16 23:00', resolution: '10m', status: '완료', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진', dateKey: '2026-03-16' },
|
||||
{ id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '03-16 10:05', expectedReceive: '03-17 08:00', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양', dateKey: '2026-03-16' },
|
||||
{ id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '03-15 14:00', expectedReceive: '03-15 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진', dateKey: '2026-03-15' },
|
||||
{ id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '03-14 11:30', expectedReceive: '03-14 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양', dateKey: '2026-03-14' },
|
||||
{ id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '03-13 09:00', expectedReceive: '03-13 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진', dateKey: '2026-03-13' },
|
||||
]
|
||||
|
||||
const satellites = [
|
||||
@ -59,28 +69,70 @@ const up42Satellites = [
|
||||
{ id: 'cop-dem', name: 'Copernicus DEM', res: '10m', type: 'elevation' as const, color: '#64748b', cloud: 0 },
|
||||
]
|
||||
|
||||
const up42Passes = [
|
||||
{ sat: 'KOMPSAT-3A', time: '오늘 14:10–14:24', res: '0.5m', cloud: '≤10%', note: '최우선 추천', color: '#a855f7' },
|
||||
{ sat: 'Pléiades Neo', time: '오늘 14:38–14:52', res: '0.3m', cloud: '≤15%', note: '초고해상도', color: '#06b6d4' },
|
||||
{ sat: 'Sentinel-1 SAR', time: '오늘 16:55–17:08', res: '20m', cloud: '야간/우천 가능', note: 'SAR', color: '#f59e0b' },
|
||||
{ sat: 'KOMPSAT-3', time: '내일 09:12', res: '1.0m', cloud: '≤15%', note: '', color: '#a855f7' },
|
||||
{ sat: 'Maxar WV-3', time: '내일 13:20', res: '0.5m', cloud: '≤20%', note: '', color: '#3b82f6' },
|
||||
]
|
||||
// up42Passes — 실시간 패스로 대체됨 (satPasses from API)
|
||||
|
||||
const SAT_MAP_STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }],
|
||||
}
|
||||
|
||||
/** 좌표 문자열 파싱 ("33.24°N 126.50°E" → {lat, lon}) */
|
||||
function parseCoord(coordStr: string): { lat: number; lon: number } | null {
|
||||
const m = coordStr.match(/([\d.]+)°N\s+([\d.]+)°E/)
|
||||
if (!m) return null
|
||||
return { lat: parseFloat(m[1]), lon: parseFloat(m[2]) }
|
||||
}
|
||||
|
||||
type SatModalPhase = 'none' | 'provider' | 'blacksky' | 'up42'
|
||||
|
||||
export function SatelliteRequest() {
|
||||
const [requests, setRequests] = useState(satRequests)
|
||||
const [mainTab, setMainTab] = useState<'list' | 'map'>('list')
|
||||
const [statusFilter, setStatusFilter] = useState('전체')
|
||||
const [modalPhase, setModalPhase] = useState<SatModalPhase>('none')
|
||||
const [selectedRequest, setSelectedRequest] = useState<SatRequest | null>(null)
|
||||
const [showMoreCompleted, setShowMoreCompleted] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const PAGE_SIZE = 5
|
||||
// UP42 sub-tab
|
||||
const [up42SubTab, setUp42SubTab] = useState<'optical' | 'sar' | 'elevation'>('optical')
|
||||
const [up42SelSat, setUp42SelSat] = useState<string | null>(null)
|
||||
const [up42SelPass, setUp42SelPass] = useState<number | null>(null)
|
||||
const [up42SelPass, setUp42SelPass] = useState<string | null>(null)
|
||||
const [satPasses, setSatPasses] = useState<SatellitePass[]>([])
|
||||
const [satPassesLoading, setSatPassesLoading] = useState(false)
|
||||
// 히스토리 지도 — 캘린더 + 선택 항목
|
||||
const [mapSelectedDate, setMapSelectedDate] = useState(() => {
|
||||
const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
})
|
||||
const [mapSelectedItem, setMapSelectedItem] = useState<SatRequest | null>(null)
|
||||
const satImgOpacity = 90
|
||||
const satImgBrightness = 100
|
||||
const satShowOverlay = true
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadSatPasses = useCallback(async () => {
|
||||
setSatPassesLoading(true)
|
||||
try {
|
||||
const passes = await fetchSatellitePasses()
|
||||
setSatPasses(passes)
|
||||
} catch {
|
||||
setSatPasses([])
|
||||
} finally {
|
||||
setSatPassesLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
@ -91,15 +143,21 @@ export function SatelliteRequest() {
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [modalPhase])
|
||||
|
||||
const allRequests = showMoreCompleted ? satRequests : satRequests.filter(r => r.status !== '완료' || r.id === 'SAT-003')
|
||||
// UP42 모달 열릴 때 위성 패스 로드
|
||||
useEffect(() => {
|
||||
if (modalPhase === 'up42') loadSatPasses()
|
||||
}, [modalPhase, loadSatPasses])
|
||||
|
||||
const filtered = allRequests.filter(r => {
|
||||
const filtered = requests.filter(r => {
|
||||
if (statusFilter === '전체') return true
|
||||
if (statusFilter === '대기') return r.status === '대기'
|
||||
if (statusFilter === '진행') return r.status === '촬영중'
|
||||
if (statusFilter === '완료') return r.status === '완료'
|
||||
if (statusFilter === '취소') return r.status === '취소'
|
||||
return true
|
||||
})
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||
const pagedItems = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)
|
||||
|
||||
const statusBadge = (s: SatRequest['status']) => {
|
||||
if (s === '촬영중') return (
|
||||
@ -110,6 +168,9 @@ export function SatelliteRequest() {
|
||||
if (s === '대기') return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(59,130,246,.15)', border: '1px solid rgba(59,130,246,.3)', color: 'var(--blue)' }}>⏳ 대기</span>
|
||||
)
|
||||
if (s === '취소') return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: 'var(--red)' }}>✕ 취소</span>
|
||||
)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: 'var(--green)' }}>✅ 완료</span>
|
||||
)
|
||||
@ -122,7 +183,7 @@ export function SatelliteRequest() {
|
||||
{ value: '0.5m', label: '최고 해상도', color: 'var(--cyan)' },
|
||||
]
|
||||
|
||||
const filters = ['전체', '대기', '진행', '완료']
|
||||
const filters = ['전체', '대기', '진행', '완료', '취소']
|
||||
|
||||
const up42Filtered = up42Satellites.filter(s => s.type === up42SubTab)
|
||||
|
||||
@ -138,19 +199,35 @@ export function SatelliteRequest() {
|
||||
const bsInputStyle = { border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' }
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto py-5 px-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-[10px] flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(59,130,246,.2),rgba(168,85,247,.2))', border: '1px solid rgba(59,130,246,.3)' }}>🛰</div>
|
||||
<div>
|
||||
<div className="text-base font-bold font-korean text-text-1">위성 촬영 요청</div>
|
||||
<div className="text-[11px] text-text-3 font-korean mt-0.5">위성 촬영 임무를 요청하고 수신 현황을 관리합니다</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-6 pt-1 pb-2" style={{ height: mainTab === 'map' ? '100%' : undefined }}>
|
||||
{/* 헤더 + 탭 + 새요청 한 줄 (높이 통일) */}
|
||||
<div className="flex items-center gap-3 mb-2 h-9">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-7 h-7 rounded-md flex items-center justify-center text-sm" style={{ background: 'linear-gradient(135deg,rgba(59,130,246,.2),rgba(168,85,247,.2))', border: '1px solid rgba(59,130,246,.3)' }}>🛰</div>
|
||||
<div className="text-[12px] font-bold font-korean text-text-1">위성 촬영 요청</div>
|
||||
</div>
|
||||
<button onClick={() => setModalPhase('provider')} className="px-4 py-2.5 text-white border-none rounded-sm text-[13px] font-semibold cursor-pointer font-korean flex items-center gap-1.5" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 새 요청</button>
|
||||
<div className="flex gap-1 h-7">
|
||||
<button
|
||||
onClick={() => setMainTab('list')}
|
||||
className="px-2.5 h-full rounded text-[10px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={mainTab === 'list'
|
||||
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
}
|
||||
>📋 요청 목록</button>
|
||||
<button
|
||||
onClick={() => setMainTab('map')}
|
||||
className="px-2.5 h-full rounded text-[10px] font-bold font-korean cursor-pointer border transition-colors"
|
||||
style={mainTab === 'map'
|
||||
? { background: 'rgba(59,130,246,.12)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
}
|
||||
>🗺 히스토리 지도</button>
|
||||
</div>
|
||||
<button onClick={() => setModalPhase('provider')} className="ml-auto px-3 h-7 text-white border-none rounded-sm text-[10px] font-semibold cursor-pointer font-korean flex items-center gap-1 shrink-0" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 새 요청</button>
|
||||
</div>
|
||||
|
||||
{mainTab === 'list' && (<>
|
||||
{/* 요약 통계 */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||
{stats.map((s, i) => (
|
||||
@ -187,8 +264,8 @@ export function SatelliteRequest() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데이터 행 */}
|
||||
{filtered.map(r => (
|
||||
{/* 데이터 행 (페이징) */}
|
||||
{pagedItems.map(r => (
|
||||
<div key={r.id}>
|
||||
<div
|
||||
onClick={() => setSelectedRequest(selectedRequest?.id === r.id ? null : r)}
|
||||
@ -197,7 +274,7 @@ export function SatelliteRequest() {
|
||||
gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px',
|
||||
borderColor: 'rgba(255,255,255,.04)',
|
||||
background: selectedRequest?.id === r.id ? 'rgba(99,102,241,.06)' : r.status === '촬영중' ? 'rgba(234,179,8,.03)' : 'transparent',
|
||||
opacity: r.status === '완료' ? 0.7 : 1,
|
||||
opacity: (r.status === '완료' || r.status === '취소') ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="text-[11px] font-mono text-text-2">{r.id}</div>
|
||||
@ -232,8 +309,18 @@ export function SatelliteRequest() {
|
||||
{r.status === '완료' && (
|
||||
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(34,197,94,.08)', borderColor: 'rgba(34,197,94,.2)', color: 'var(--green)' }}>📥 영상 다운로드</button>
|
||||
)}
|
||||
{r.status === '대기' && (
|
||||
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--red)' }}>✕ 요청 취소</button>
|
||||
{(r.status === '대기' || r.status === '촬영중') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`${r.id} (${r.zone}) 위성 촬영 요청을 취소하시겠습니까?`)) {
|
||||
setRequests(prev => prev.map(req => req.id === r.id ? { ...req, status: '취소' as const } : req))
|
||||
setSelectedRequest(null)
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors"
|
||||
style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--red)' }}
|
||||
>✕ 요청 취소</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -241,11 +328,36 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
onClick={() => setShowMoreCompleted(!showMoreCompleted)}
|
||||
className="text-center py-2.5 text-[10px] text-text-3 font-korean cursor-pointer hover:text-text-2 transition-colors"
|
||||
>
|
||||
{showMoreCompleted ? '▲ 완료 목록 접기' : '▼ 이전 완료 목록 더보기 (6건)'}
|
||||
{/* 페이징 */}
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<div className="text-[9px] text-text-3 font-korean">
|
||||
총 {filtered.length}건 중 {(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, filtered.length)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage <= 1}
|
||||
className="px-2 py-1 rounded text-[10px] font-mono cursor-pointer border transition-colors"
|
||||
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: currentPage <= 1 ? 'var(--t3)' : 'var(--t1)', opacity: currentPage <= 1 ? 0.5 : 1 }}
|
||||
>◀</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className="w-7 h-7 rounded text-[10px] font-bold font-mono cursor-pointer border transition-colors"
|
||||
style={currentPage === p
|
||||
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.3)', color: 'var(--blue)' }
|
||||
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
}
|
||||
>{p}</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="px-2 py-1 rounded text-[10px] font-mono cursor-pointer border transition-colors"
|
||||
style={{ background: 'var(--bg3)', borderColor: 'var(--bd)', color: currentPage >= totalPages ? 'var(--t3)' : 'var(--t1)', opacity: currentPage >= totalPages ? 0.5 : 1 }}
|
||||
>▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -288,6 +400,179 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{/* ═══ 촬영 히스토리 지도 뷰 ═══ */}
|
||||
{mainTab === 'map' && (() => {
|
||||
const dateFiltered = requests.filter(r => r.dateKey === mapSelectedDate)
|
||||
const dateHasDots = [...new Set(requests.map(r => r.dateKey).filter(Boolean))]
|
||||
return (
|
||||
<div className="bg-bg-2 border border-border rounded-md overflow-hidden relative" style={{ height: 'calc(100vh - 160px)' }}>
|
||||
<Map
|
||||
initialViewState={{ longitude: 127.5, latitude: 34.5, zoom: 7 }}
|
||||
mapStyle={SAT_MAP_STYLE}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
>
|
||||
{/* 선택된 날짜의 촬영 구역 폴리곤 */}
|
||||
{dateFiltered.map(r => {
|
||||
const coord = parseCoord(r.zoneCoord)
|
||||
if (!coord) return null
|
||||
const areaKm = parseFloat(r.zoneArea) || 10
|
||||
const delta = Math.sqrt(areaKm) * 0.005
|
||||
const isSelected = mapSelectedItem?.id === r.id
|
||||
const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6'
|
||||
return (
|
||||
<Source key={r.id} id={`zone-${r.id}`} type="geojson" data={{
|
||||
type: 'Feature', properties: {},
|
||||
geometry: { type: 'Polygon', coordinates: [[
|
||||
[coord.lon - delta, coord.lat - delta],
|
||||
[coord.lon + delta, coord.lat - delta],
|
||||
[coord.lon + delta, coord.lat + delta],
|
||||
[coord.lon - delta, coord.lat + delta],
|
||||
[coord.lon - delta, coord.lat - delta],
|
||||
]] },
|
||||
}}>
|
||||
<Layer id={`zone-fill-${r.id}`} type="fill" paint={{ 'fill-color': statusColor, 'fill-opacity': isSelected ? 0.35 : 0.12 }} />
|
||||
<Layer id={`zone-line-${r.id}`} type="line" paint={{ 'line-color': statusColor, 'line-width': isSelected ? 3 : 1.5 }} />
|
||||
</Source>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 선택된 완료 항목: VWorld 위성 영상 오버레이 (BlackSky 스타일) */}
|
||||
{mapSelectedItem && mapSelectedItem.status === '완료' && satShowOverlay && VWORLD_API_KEY && (
|
||||
<Source
|
||||
id="sat-vworld-overlay"
|
||||
type="raster"
|
||||
tiles={[`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg`]}
|
||||
tileSize={256}
|
||||
>
|
||||
<Layer id="sat-vworld-layer" type="raster" paint={{
|
||||
'raster-opacity': satImgOpacity / 100,
|
||||
'raster-brightness-max': Math.min(satImgBrightness / 100 * 1.2, 1),
|
||||
'raster-brightness-min': Math.max((satImgBrightness - 100) / 200, 0),
|
||||
}} />
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* 선택된 항목 마커 */}
|
||||
{mapSelectedItem && (() => {
|
||||
const coord = parseCoord(mapSelectedItem.zoneCoord)
|
||||
if (!coord) return null
|
||||
return (
|
||||
<Marker longitude={coord.lon} latitude={coord.lat} anchor="center">
|
||||
<div className="relative">
|
||||
<div className="w-4 h-4 rounded-full" style={{ background: 'rgba(6,182,212,.6)', border: '2px solid #fff', boxShadow: '0 0 12px rgba(6,182,212,.5)' }} />
|
||||
<div className="absolute inset-0 w-4 h-4 rounded-full animate-ping" style={{ background: 'rgba(6,182,212,.3)' }} />
|
||||
</div>
|
||||
</Marker>
|
||||
)
|
||||
})()}
|
||||
</Map>
|
||||
|
||||
{/* 좌상단: 캘린더 + 날짜별 리스트 */}
|
||||
<div className="absolute top-3 left-3 w-[260px] rounded-lg border border-border z-10 overflow-hidden" style={{ background: 'rgba(18,25,41,.92)', backdropFilter: 'blur(8px)' }}>
|
||||
{/* 캘린더 헤더 */}
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📅 촬영 날짜 선택</div>
|
||||
<input
|
||||
type="date"
|
||||
value={mapSelectedDate}
|
||||
onChange={e => { setMapSelectedDate(e.target.value); setMapSelectedItem(null) }}
|
||||
className="w-full px-2.5 py-1.5 bg-bg-3 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-[var(--cyan)] transition-colors"
|
||||
/>
|
||||
{/* 촬영 이력 있는 날짜 점 표시 */}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{dateHasDots.map(d => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => { setMapSelectedDate(d!); setMapSelectedItem(null) }}
|
||||
className="px-1.5 py-0.5 rounded text-[8px] font-mono cursor-pointer border transition-colors"
|
||||
style={mapSelectedDate === d
|
||||
? { background: 'rgba(6,182,212,.2)', borderColor: 'var(--cyan)', color: 'var(--cyan)' }
|
||||
: { background: 'var(--bg0)', borderColor: 'var(--bd)', color: 'var(--t3)' }
|
||||
}
|
||||
>{d?.slice(5)}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 날짜별 촬영 리스트 */}
|
||||
<div className="max-h-[35vh] overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
<div className="px-3 py-1.5 border-b border-border text-[9px] text-text-3 font-korean sticky top-0" style={{ background: 'rgba(18,25,41,.95)' }}>
|
||||
{mapSelectedDate} · {dateFiltered.length}건
|
||||
</div>
|
||||
{dateFiltered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-[10px] text-text-3 font-korean text-center">이 날짜에 촬영 이력이 없습니다</div>
|
||||
) : dateFiltered.map(r => {
|
||||
const statusColor = r.status === '촬영중' ? '#eab308' : r.status === '완료' ? '#22c55e' : r.status === '취소' ? '#ef4444' : '#3b82f6'
|
||||
const isSelected = mapSelectedItem?.id === r.id
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
onClick={() => setMapSelectedItem(isSelected ? null : r)}
|
||||
className="px-3 py-2 border-b cursor-pointer transition-colors"
|
||||
style={{
|
||||
borderColor: 'rgba(255,255,255,.04)',
|
||||
background: isSelected ? 'rgba(6,182,212,.1)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-[10px] font-mono text-text-2">{r.id}</span>
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: `${statusColor}20`, color: statusColor }}>{r.status}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-text-1 font-korean truncate">{r.zone}</div>
|
||||
<div className="text-[8px] text-text-3 font-mono mt-0.5">{r.satellite} · {r.resolution}</div>
|
||||
{r.status === '완료' && (
|
||||
<div className="mt-1 text-[8px] font-korean" style={{ color: 'var(--cyan)' }}>📷 클릭하여 영상 보기</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우상단: 범례 */}
|
||||
<div className="absolute top-3 right-3 px-3 py-2.5 rounded-lg border border-border z-10" style={{ background: 'rgba(18,25,41,.9)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">촬영 이력</div>
|
||||
{[
|
||||
{ label: '촬영중', color: '#eab308' },
|
||||
{ label: '대기', color: '#3b82f6' },
|
||||
{ label: '완료', color: '#22c55e' },
|
||||
{ label: '취소', color: '#ef4444' },
|
||||
].map(item => (
|
||||
<div key={item.label} className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ background: item.color, opacity: 0.4, border: `1px solid ${item.color}` }} />
|
||||
<span className="text-[9px] text-text-3 font-korean">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-[8px] text-text-3 font-korean mt-1.5 pt-1.5 border-t border-border">총 {requests.length}건</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 항목 상세 (하단) */}
|
||||
{mapSelectedItem && (
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-4 py-3 rounded-lg border border-border z-10 max-w-[500px]" style={{ background: 'rgba(18,25,41,.95)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-[11px] font-bold text-text-1 font-korean mb-0.5">{mapSelectedItem.zone}</div>
|
||||
<div className="text-[9px] text-text-3 font-mono">{mapSelectedItem.satellite} · {mapSelectedItem.resolution} · {mapSelectedItem.zoneCoord}</div>
|
||||
</div>
|
||||
<div className="text-center shrink-0">
|
||||
<div className="text-[8px] text-text-3 font-korean">요청</div>
|
||||
<div className="text-[10px] font-mono text-text-1">{mapSelectedItem.requestDate}</div>
|
||||
</div>
|
||||
{mapSelectedItem.status === '완료' && (
|
||||
<div className="px-2 py-1 rounded text-[9px] font-bold font-korean shrink-0" style={{ background: 'rgba(34,197,94,.15)', color: '#22c55e' }}>
|
||||
📷 영상 표출중
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setMapSelectedItem(null)} className="text-text-3 bg-transparent border-none cursor-pointer text-sm">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ═══ 모달: 제공자 선택 ═══ */}
|
||||
{modalPhase !== 'none' && (
|
||||
@ -596,7 +881,7 @@ export function SatelliteRequest() {
|
||||
|
||||
{/* ── UP42 카탈로그 주문 ── */}
|
||||
{modalPhase === 'up42' && (
|
||||
<div className="border rounded-[14px] w-[920px] max-h-[90vh] flex flex-col overflow-hidden border-[rgba(59,130,246,.3)]" style={{ background: '#0d1117', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
|
||||
<div className="border rounded-[14px] w-[920px] flex flex-col overflow-hidden border-[rgba(59,130,246,.3)]" style={{ background: '#0d1117', boxShadow: '0 24px 80px rgba(0,0,0,.7)', height: '85vh' }}>
|
||||
{/* 헤더 */}
|
||||
<div className="px-6 py-4 border-b border-[#21262d] flex items-center justify-between shrink-0 relative">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#3b82f6,#06b6d4,#22c55e)' }} />
|
||||
@ -672,83 +957,121 @@ export function SatelliteRequest() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 지도 + AOI + 패스 */}
|
||||
{/* 오른쪽: 궤도 지도 + 패스 목록 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
{/* 지도 영역 (placeholder) */}
|
||||
<div className="flex-1 relative" style={{ background: '#0a0e18' }}>
|
||||
{/* 검색바 */}
|
||||
<div className="absolute top-3 left-3 right-3 flex items-center gap-2 px-3 py-2 rounded-lg z-10 border border-[#21262d]" style={{ background: 'rgba(13,17,23,.9)', backdropFilter: 'blur(8px)' }}>
|
||||
<span className="text-[#8690a6] text-[13px]">🔍</span>
|
||||
<input type="text" placeholder="위치 또는 좌표 입력..." className="flex-1 bg-transparent border-none outline-none text-[11px] font-korean text-[#e2e8f0]" />
|
||||
</div>
|
||||
{/* 지도 영역 — 위성 궤도 표시 (최소 높이 보장) */}
|
||||
<div className="flex-1 relative" style={{ minHeight: 350 }}>
|
||||
<Map
|
||||
initialViewState={{ longitude: 128, latitude: 36, zoom: 5.5 }}
|
||||
mapStyle={SAT_MAP_STYLE}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
>
|
||||
{/* 한국 영역 AOI 박스 */}
|
||||
<Source id="korea-aoi" type="geojson" data={{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'Polygon', coordinates: [[[124, 33], [132, 33], [132, 39], [124, 39], [124, 33]]] },
|
||||
}}>
|
||||
<Layer id="korea-aoi-fill" type="fill" paint={{ 'fill-color': '#3b82f6', 'fill-opacity': 0.05 }} />
|
||||
<Layer id="korea-aoi-line" type="line" paint={{ 'line-color': '#3b82f6', 'line-width': 1.5, 'line-dasharray': [4, 3] }} />
|
||||
</Source>
|
||||
|
||||
{/* 지도 placeholder */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2 opacity-20">🗺</div>
|
||||
<div className="text-[11px] font-korean opacity-40 text-[#64748b]">지도 영역 — AOI를 그려 위성 패스를 확인하세요</div>
|
||||
{/* 위성 궤도 라인 */}
|
||||
{satPasses.map(pass => (
|
||||
<Source key={pass.id} id={`orbit-${pass.id}`} type="geojson" data={{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'LineString', coordinates: pass.orbit.map(p => [p.lon, p.lat]) },
|
||||
}}>
|
||||
<Layer
|
||||
id={`orbit-line-${pass.id}`}
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': pass.color,
|
||||
'line-width': up42SelPass === pass.id ? 3 : 1.5,
|
||||
'line-opacity': up42SelPass === pass.id ? 1 : up42SelPass ? 0.2 : 0.6,
|
||||
'line-dasharray': pass.type === 'sar' ? [6, 4] : [1],
|
||||
}}
|
||||
/>
|
||||
{/* 궤도 위 위성 위치 (중간점) */}
|
||||
{(up42SelPass === pass.id || !up42SelPass) && (
|
||||
<Layer
|
||||
id={`orbit-point-${pass.id}`}
|
||||
type="circle"
|
||||
filter={['==', '$type', 'LineString']}
|
||||
paint={{}}
|
||||
/>
|
||||
)}
|
||||
</Source>
|
||||
))}
|
||||
</Map>
|
||||
|
||||
{/* 범례 오버레이 */}
|
||||
<div className="absolute top-3 left-3 px-3 py-2 rounded-lg z-10 border border-[#21262d]" style={{ background: 'rgba(13,17,23,.9)', backdropFilter: 'blur(8px)' }}>
|
||||
<div className="text-[9px] font-bold text-[#64748b] mb-1.5">🛰 위성 궤도</div>
|
||||
{satPasses.slice(0, 4).map(p => (
|
||||
<div key={p.id} className="flex items-center gap-1.5 mb-1">
|
||||
<div className="w-3 h-[2px] rounded-sm" style={{ background: p.color }} />
|
||||
<span className="text-[8px] text-[#94a3b8]">{p.satellite}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5 mt-1.5 pt-1.5 border-t border-[#21262d]">
|
||||
<div className="w-3 h-3 rounded border border-[#3b82f6]" style={{ background: 'rgba(59,130,246,.1)' }} />
|
||||
<span className="text-[8px] text-[#64748b]">한국 영역 AOI</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AOI 도구 버튼 (오른쪽 사이드) */}
|
||||
<div className="absolute top-14 right-3 flex flex-col gap-1 p-1.5 rounded-lg z-10 border border-[#21262d]" style={{ background: 'rgba(13,17,23,.9)' }}>
|
||||
<div className="text-[7px] font-bold text-center mb-0.5 text-[#64748b]">ADD</div>
|
||||
{[
|
||||
{ icon: '⬜', title: '사각형 AOI' },
|
||||
{ icon: '🔷', title: '다각형 AOI' },
|
||||
{ icon: '⭕', title: '원형 AOI' },
|
||||
{ icon: '📁', title: '파일 업로드' },
|
||||
].map((t, i) => (
|
||||
<button key={i} className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none bg-[#161b22] text-[#8690a6]" title={t.title}>{t.icon}</button>
|
||||
))}
|
||||
<div className="h-px my-0.5 bg-[#21262d]" />
|
||||
<div className="text-[7px] font-bold text-center mb-0.5 text-[#64748b]">AOI</div>
|
||||
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none bg-[#161b22] text-[#8690a6]" title="저장된 AOI">💾</button>
|
||||
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none bg-[#161b22] text-[#ef4444]" title="AOI 삭제">🗑</button>
|
||||
</div>
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<div className="absolute bottom-3 right-3 flex flex-col rounded-md overflow-hidden z-10 border border-[#21262d]">
|
||||
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none bg-[#161b22] text-[#8690a6]">+</button>
|
||||
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none border-t border-t-[#21262d] bg-[#161b22] text-[#8690a6]">−</button>
|
||||
</div>
|
||||
|
||||
{/* 이 지역 검색 버튼 */}
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10">
|
||||
<button className="px-4 py-2 rounded-full text-[10px] font-semibold cursor-pointer font-korean text-white border-none" style={{ background: 'rgba(59,130,246,.9)', boxShadow: '0 2px 12px rgba(59,130,246,.3)' }}>🔍 이 지역 검색</button>
|
||||
</div>
|
||||
{/* 로딩 */}
|
||||
{satPassesLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: 'rgba(0,0,0,.5)' }}>
|
||||
<div className="text-[11px] text-[#60a5fa] font-korean animate-pulse">🛰 위성 패스 조회 중...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 위성 패스 타임라인 */}
|
||||
<div className="border-t border-[#21262d] px-4 py-3 shrink-0" style={{ background: 'rgba(13,17,23,.95)' }}>
|
||||
<div className="text-[10px] font-bold font-korean mb-2 text-[#e2e8f0]">🛰 오늘 가용 위성 패스 — 선택된 AOI 통과 예정</div>
|
||||
<div className="border-t border-[#21262d] px-4 py-3 shrink-0 max-h-[200px] overflow-y-auto" style={{ background: 'rgba(13,17,23,.95)', scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
|
||||
<div className="text-[10px] font-bold font-korean mb-2 text-[#e2e8f0]">
|
||||
🛰 한국 주변 실시간 위성 패스 ({satPasses.length}개)
|
||||
<span className="text-[8px] text-[#64748b] font-normal ml-2">클릭하여 궤도 확인</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{up42Passes.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setUp42SelPass(up42SelPass === i ? null : i)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: up42SelPass === i ? 'rgba(59,130,246,.1)' : '#161b22',
|
||||
border: up42SelPass === i ? '1px solid rgba(59,130,246,.3)' : '1px solid #21262d',
|
||||
}}
|
||||
>
|
||||
<div className="w-1.5 h-5 rounded-full shrink-0" style={{ background: p.color }} />
|
||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||
<span className="text-[10px] font-bold font-korean min-w-[100px] text-[#e2e8f0]">{p.sat}</span>
|
||||
<span className="text-[9px] font-bold font-mono min-w-[110px] text-[#60a5fa]">{p.time}</span>
|
||||
<span className="text-[9px] font-mono text-cyan-500">{p.res}</span>
|
||||
<span className="text-[8px] font-mono text-[#64748b]">{p.cloud}</span>
|
||||
</div>
|
||||
{p.note && (
|
||||
{satPasses.map(pass => {
|
||||
const start = new Date(pass.startTime)
|
||||
const timeStr = `${start.getHours().toString().padStart(2, '0')}:${start.getMinutes().toString().padStart(2, '0')}`
|
||||
const diffH = Math.max(0, (start.getTime() - Date.now()) / 3600000)
|
||||
const urgency = diffH < 3 ? '긴급' : diffH < 8 ? '예정' : '내일'
|
||||
return (
|
||||
<div
|
||||
key={pass.id}
|
||||
onClick={() => setUp42SelPass(up42SelPass === pass.id ? null : pass.id)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: up42SelPass === pass.id ? 'rgba(59,130,246,.1)' : '#161b22',
|
||||
border: up42SelPass === pass.id ? `1px solid ${pass.color}40` : '1px solid #21262d',
|
||||
}}
|
||||
>
|
||||
<div className="w-1.5 h-6 rounded-full shrink-0" style={{ background: pass.color }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold font-korean text-[#e2e8f0]">{pass.satellite}</span>
|
||||
<span className="text-[8px] text-[#64748b]">{pass.provider}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[9px] font-bold font-mono text-[#60a5fa]">{timeStr}</span>
|
||||
<span className="text-[9px] font-mono" style={{ color: pass.color }}>{pass.resolution}</span>
|
||||
<span className="text-[8px] font-mono text-[#64748b]">EL {pass.maxElevation}° · {pass.direction === 'ascending' ? '↗ 상승' : '↘ 하강'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-1.5 py-px rounded text-[8px] font-bold shrink-0" style={{
|
||||
background: p.note === '최우선 추천' ? 'rgba(34,197,94,.1)' : p.note === '초고해상도' ? 'rgba(6,182,212,.1)' : p.note === 'SAR' ? 'rgba(245,158,11,.1)' : 'rgba(99,102,241,.1)',
|
||||
color: p.note === '최우선 추천' ? '#22c55e' : p.note === '초고해상도' ? '#06b6d4' : p.note === 'SAR' ? '#f59e0b' : '#818cf8',
|
||||
}}>{p.note}</span>
|
||||
)}
|
||||
{up42SelPass === i && <span className="text-xs text-blue-500">✓</span>}
|
||||
</div>
|
||||
))}
|
||||
background: urgency === '긴급' ? 'rgba(239,68,68,.1)' : urgency === '예정' ? 'rgba(6,182,212,.1)' : 'rgba(100,116,139,.1)',
|
||||
color: urgency === '긴급' ? '#ef4444' : urgency === '예정' ? '#06b6d4' : '#64748b',
|
||||
}}>{urgency}</span>
|
||||
{up42SelPass === pass.id && <span className="text-xs text-blue-500">✓</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -759,7 +1082,7 @@ export function SatelliteRequest() {
|
||||
<div className="text-[9px] font-korean text-[#64748b]">원하는 위성을 찾지 못했나요? <span className="text-[#60a5fa] cursor-pointer">태스킹 주문 생성</span> 또는 <span className="text-[#60a5fa] cursor-pointer">자세히 보기 ↗</span></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-korean mr-1.5 text-[#8690a6]">
|
||||
선택: {up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'}
|
||||
선택: {up42SelPass ? satPasses.find(p => p.id === up42SelPass)?.satellite : up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'}
|
||||
</span>
|
||||
<button onClick={() => setModalPhase('provider')} className="px-4 py-2 rounded-lg border border-[#21262d] text-[11px] font-semibold cursor-pointer font-korean text-[#94a3b8] bg-[#161b22]">← 뒤로</button>
|
||||
<button
|
||||
|
||||
1137
frontend/src/tabs/aerial/components/WingAI.tsx
Normal file
1137
frontend/src/tabs/aerial/components/WingAI.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -159,3 +159,26 @@ export async function stopDroneStreamApi(id: string): Promise<{ success: boolean
|
||||
const response = await api.post<{ success: boolean }>(`/aerial/drone/streams/${id}/stop`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UP42 위성 패스 조회
|
||||
// ============================================================
|
||||
|
||||
export interface SatellitePass {
|
||||
id: string;
|
||||
satellite: string;
|
||||
provider: string;
|
||||
type: 'optical' | 'sar' | 'elevation';
|
||||
resolution: string;
|
||||
color: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
maxElevation: number;
|
||||
direction: 'ascending' | 'descending';
|
||||
orbit: Array<{ lat: number; lon: number }>;
|
||||
}
|
||||
|
||||
export async function fetchSatellitePasses(): Promise<SatellitePass[]> {
|
||||
const response = await api.get<{ passes: SatellitePass[] }>('/aerial/satellite/passes');
|
||||
return response.data.passes;
|
||||
}
|
||||
|
||||
202
frontend/src/tabs/incidents/components/DischargeZonePanel.tsx
Normal file
202
frontend/src/tabs/incidents/components/DischargeZonePanel.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* 해양환경관리법 제22조 기반 선박 발생 오염물 배출 규정
|
||||
* 영해기선으로부터의 거리에 따라 배출 가능 여부 결정
|
||||
*
|
||||
* 법률 근거:
|
||||
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
|
||||
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||
*/
|
||||
|
||||
type Status = 'forbidden' | 'allowed' | 'conditional'
|
||||
|
||||
interface DischargeRule {
|
||||
category: string
|
||||
item: string
|
||||
zones: [Status, Status, Status, Status] // [~3NM, 3~12NM, 12~25NM, 25NM+]
|
||||
condition?: string
|
||||
}
|
||||
|
||||
const RULES: DischargeRule[] = [
|
||||
// 폐기물
|
||||
{ category: '폐기물', item: '플라스틱 제품', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||
{ category: '폐기물', item: '포장유해물질·용기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||
{ category: '폐기물', item: '중금속 포함 쓰레기', zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'] },
|
||||
// 화물잔류물
|
||||
{ category: '화물잔류물', item: '부유성 화물잔류물', zones: ['forbidden', 'forbidden', 'forbidden', 'allowed'] },
|
||||
{ category: '화물잔류물', item: '침강성 화물잔류물', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
|
||||
{ category: '화물잔류물', item: '화물창 세정수', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '해양환경에 해롭지 않은 일반세제 사용시' },
|
||||
// 음식물 찌꺼기
|
||||
{ category: '음식물찌꺼기', item: '미분쇄', zones: ['forbidden', 'forbidden', 'allowed', 'allowed'] },
|
||||
{ category: '음식물찌꺼기', item: '분쇄·연마', zones: ['forbidden', 'conditional', 'allowed', 'allowed'], condition: '크기 25mm 이하시' },
|
||||
// 분뇨
|
||||
{ category: '분뇨', item: '분뇨저장장치', zones: ['forbidden', 'forbidden', 'conditional', 'conditional'], condition: '항속 4노트 이상시 서서히 배출' },
|
||||
{ category: '분뇨', item: '분뇨마쇄소독장치', zones: ['forbidden', 'conditional', 'conditional', 'conditional'], condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능' },
|
||||
{ category: '분뇨', item: '분뇨처리장치', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면 및 육성수면은 불가' },
|
||||
// 중수
|
||||
{ category: '중수', item: '거주구역 중수', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가' },
|
||||
// 수산동식물
|
||||
{ category: '수산동식물', item: '자연기원물질', zones: ['allowed', 'allowed', 'allowed', 'allowed'], condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면' },
|
||||
]
|
||||
|
||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25해리+']
|
||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e']
|
||||
|
||||
function getZoneIndex(distanceNm: number): number {
|
||||
if (distanceNm < 3) return 0
|
||||
if (distanceNm < 12) return 1
|
||||
if (distanceNm < 25) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: Status }) {
|
||||
if (status === 'forbidden') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(239,68,68,0.15)', color: '#ef4444' }}>배출불가</span>
|
||||
if (status === 'allowed') return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(34,197,94,0.15)', color: '#22c55e' }}>배출가능</span>
|
||||
return <span className="text-[8px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(234,179,8,0.15)', color: '#eab308' }}>조건부</span>
|
||||
}
|
||||
|
||||
interface DischargeZonePanelProps {
|
||||
lat: number
|
||||
lon: number
|
||||
distanceNm: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) {
|
||||
const zoneIdx = getZoneIndex(distanceNm)
|
||||
const [expandedCat, setExpandedCat] = useState<string | null>(null)
|
||||
|
||||
const categories = [...new Set(RULES.map(r => r.category))]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-4 right-4 z-[1000] rounded-lg overflow-hidden flex flex-col"
|
||||
style={{
|
||||
width: 320,
|
||||
maxHeight: 'calc(100% - 32px)',
|
||||
background: 'rgba(13,17,23,0.95)',
|
||||
border: '1px solid #30363d',
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid #30363d',
|
||||
background: 'linear-gradient(135deg, #1c2333, #161b22)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-[#f0f6fc] font-korean">🚢 오염물 배출 규정</div>
|
||||
<div className="text-[8px] text-[#8b949e] font-korean">해양환경관리법 제22조</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="text-[14px] cursor-pointer text-[#8b949e] hover:text-[#f0f6fc]">✕</span>
|
||||
</div>
|
||||
|
||||
{/* Location Info */}
|
||||
<div className="shrink-0" style={{ padding: '8px 14px', borderBottom: '1px solid #21262d' }}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[9px] text-[#8b949e] font-korean">선택 위치</span>
|
||||
<span className="text-[9px] text-[#c9d1d9] font-mono">{lat.toFixed(4)}°N, {lon.toFixed(4)}°E</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[9px] text-[#8b949e] font-korean">영해기선 거리 (추정)</span>
|
||||
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
||||
{distanceNm.toFixed(1)} NM
|
||||
</span>
|
||||
</div>
|
||||
{/* Zone indicator */}
|
||||
<div className="flex gap-1">
|
||||
{ZONE_LABELS.map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex-1 text-center rounded-sm"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
fontSize: 8,
|
||||
fontWeight: i === zoneIdx ? 700 : 400,
|
||||
color: i === zoneIdx ? '#fff' : '#8b949e',
|
||||
background: i === zoneIdx ? ZONE_COLORS[i] : 'rgba(255,255,255,0.04)',
|
||||
border: i === zoneIdx ? 'none' : '1px solid #21262d',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#30363d transparent' }}>
|
||||
{categories.map(cat => {
|
||||
const catRules = RULES.filter(r => r.category === cat)
|
||||
const isExpanded = expandedCat === cat
|
||||
const allForbidden = catRules.every(r => r.zones[zoneIdx] === 'forbidden')
|
||||
const allAllowed = catRules.every(r => r.zones[zoneIdx] === 'allowed')
|
||||
const summaryColor = allForbidden ? '#ef4444' : allAllowed ? '#22c55e' : '#eab308'
|
||||
|
||||
return (
|
||||
<div key={cat} style={{ borderBottom: '1px solid #21262d' }}>
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setExpandedCat(isExpanded ? null : cat)}
|
||||
style={{ padding: '8px 14px' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }} />
|
||||
<span className="text-[10px] font-bold text-[#c9d1d9] font-korean">{cat}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[8px] font-semibold" style={{ color: summaryColor }}>
|
||||
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
|
||||
</span>
|
||||
<span className="text-[9px] text-[#8b949e]">{isExpanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{ padding: '0 14px 10px' }}>
|
||||
{catRules.map((rule, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between"
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
marginBottom: 2,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] text-[#c9d1d9] font-korean">{rule.item}</span>
|
||||
<StatusBadge status={rule.zones[zoneIdx]} />
|
||||
</div>
|
||||
))}
|
||||
{catRules.some(r => r.condition && r.zones[zoneIdx] !== 'forbidden') && (
|
||||
<div className="mt-1" style={{ padding: '4px 8px' }}>
|
||||
{catRules.filter(r => r.condition && r.zones[zoneIdx] !== 'forbidden').map((r, i) => (
|
||||
<div key={i} className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
|
||||
💡 {r.item}: {r.condition}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0" style={{ padding: '6px 14px', borderTop: '1px solid #21262d' }}>
|
||||
<div className="text-[7px] text-[#8b949e] font-korean leading-relaxed">
|
||||
※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있을 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Map, Popup, useControl } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, IconLayer } from '@deck.gl/layers'
|
||||
import { ScatterplotLayer, IconLayer, PathLayer } from '@deck.gl/layers'
|
||||
import { PathStyleExtension } from '@deck.gl/extensions'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
|
||||
@ -9,23 +10,25 @@ import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './Inci
|
||||
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
|
||||
import { fetchIncidents } from '../services/incidentsApi'
|
||||
import type { IncidentCompat } from '../services/incidentsApi'
|
||||
import { DischargeZonePanel } from './DischargeZonePanel'
|
||||
import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData'
|
||||
|
||||
// ── CartoDB Dark Matter 베이스맵 ────────────────────────
|
||||
// ── CartoDB Positron 베이스맵 (밝은 테마) ────────────────
|
||||
const BASE_STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
'carto-light': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
||||
layers: [{ id: 'carto-light-layer', type: 'raster', source: 'carto-light' }],
|
||||
}
|
||||
|
||||
// ── DeckGLOverlay ──────────────────────────────────────
|
||||
@ -90,6 +93,10 @@ export function IncidentsView() {
|
||||
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null)
|
||||
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null)
|
||||
|
||||
// Discharge zone mode
|
||||
const [dischargeMode, setDischargeMode] = useState(false)
|
||||
const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number } | null>(null)
|
||||
|
||||
// Analysis view mode
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('overlay')
|
||||
const [analysisActive, setAnalysisActive] = useState(false)
|
||||
@ -223,10 +230,30 @@ export function IncidentsView() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── 배출 구역 경계선 레이어 ──
|
||||
const dischargeZoneLayers = useMemo(() => {
|
||||
if (!dischargeMode) return []
|
||||
const zoneLines = getDischargeZoneLines()
|
||||
return zoneLines.map((line, i) =>
|
||||
new PathLayer({
|
||||
id: `discharge-zone-${i}`,
|
||||
data: [line],
|
||||
getPath: (d: typeof line) => d.path,
|
||||
getColor: (d: typeof line) => d.color,
|
||||
getWidth: 2,
|
||||
widthUnits: 'pixels',
|
||||
getDashArray: [6, 3],
|
||||
dashJustified: true,
|
||||
extensions: [new PathStyleExtension({ dash: true })],
|
||||
pickable: false,
|
||||
})
|
||||
)
|
||||
}, [dischargeMode])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deckLayers: any[] = useMemo(
|
||||
() => [incidentLayer, vesselIconLayer],
|
||||
[incidentLayer, vesselIconLayer],
|
||||
() => [incidentLayer, vesselIconLayer, ...dischargeZoneLayers],
|
||||
[incidentLayer, vesselIconLayer, dischargeZoneLayers],
|
||||
)
|
||||
|
||||
return (
|
||||
@ -320,8 +347,17 @@ export function IncidentsView() {
|
||||
<Map
|
||||
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
|
||||
mapStyle={BASE_STYLE}
|
||||
style={{ width: '100%', height: '100%', background: '#0a0e1a' }}
|
||||
style={{ width: '100%', height: '100%', background: '#f0f0f0' }}
|
||||
attributionControl={false}
|
||||
onClick={(e) => {
|
||||
if (dischargeMode && e.lngLat) {
|
||||
const lat = e.lngLat.lat
|
||||
const lon = e.lngLat.lng
|
||||
const distanceNm = estimateDistanceFromCoast(lat, lon)
|
||||
setDischargeInfo({ lat, lon, distanceNm })
|
||||
}
|
||||
}}
|
||||
cursor={dischargeMode ? 'crosshair' : undefined}
|
||||
>
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
|
||||
@ -428,6 +464,57 @@ export function IncidentsView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오염물 배출 규정 토글 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setDischargeMode(!dischargeMode)
|
||||
if (dischargeMode) setDischargeInfo(null)
|
||||
}}
|
||||
className="absolute z-[500] cursor-pointer rounded-md text-[10px] font-bold font-korean"
|
||||
style={{
|
||||
top: 10,
|
||||
right: dischargeMode ? 340 : 180,
|
||||
padding: '6px 10px',
|
||||
background: dischargeMode ? 'rgba(6,182,212,0.15)' : 'rgba(13,17,23,0.88)',
|
||||
border: dischargeMode ? '1px solid rgba(6,182,212,0.4)' : '1px solid #30363d',
|
||||
color: dischargeMode ? '#22d3ee' : '#8b949e',
|
||||
backdropFilter: 'blur(8px)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
🚢 배출규정 {dischargeMode ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
|
||||
{/* 오염물 배출 규정 패널 */}
|
||||
{dischargeMode && dischargeInfo && (
|
||||
<DischargeZonePanel
|
||||
lat={dischargeInfo.lat}
|
||||
lon={dischargeInfo.lon}
|
||||
distanceNm={dischargeInfo.distanceNm}
|
||||
onClose={() => setDischargeInfo(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 배출규정 모드 안내 */}
|
||||
{dischargeMode && !dischargeInfo && (
|
||||
<div
|
||||
className="absolute z-[500] rounded-md text-[11px] font-korean font-semibold"
|
||||
style={{
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
padding: '12px 20px',
|
||||
background: 'rgba(13,17,23,0.9)',
|
||||
border: '1px solid rgba(6,182,212,0.3)',
|
||||
color: '#22d3ee',
|
||||
backdropFilter: 'blur(8px)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
📍 지도를 클릭하여 배출 규정을 확인하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AIS Live Badge */}
|
||||
<div
|
||||
className="absolute top-[10px] right-[10px] z-[500] rounded-md"
|
||||
|
||||
166
frontend/src/tabs/incidents/utils/dischargeZoneData.ts
Normal file
166
frontend/src/tabs/incidents/utils/dischargeZoneData.ts
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 해양환경관리법 제22조 기반 오염물 배출 구역 데이터 및 유틸리티
|
||||
*
|
||||
* 법률 근거:
|
||||
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
|
||||
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||
*/
|
||||
|
||||
// 한국 해안선 — OpenStreetMap Nominatim 기반 실측 좌표
|
||||
// [lat, lon] 형식, 시계방향 (동해→남해→서해→DMZ)
|
||||
const COASTLINE_POINTS: [number, number][] = [
|
||||
// 동해안 (북→남)
|
||||
[38.6177, 128.6560], [38.5504, 128.4092], [38.4032, 128.7767],
|
||||
[38.1904, 128.8902], [38.0681, 128.9977], [37.9726, 129.0715],
|
||||
[37.8794, 129.1721], [37.8179, 129.2397], [37.6258, 129.3669],
|
||||
[37.5053, 129.4577], [37.3617, 129.5700], [37.1579, 129.6538],
|
||||
[37.0087, 129.6706], [36.6618, 129.7210], [36.3944, 129.6827],
|
||||
[36.2052, 129.7641], [35.9397, 129.8124], [35.6272, 129.7121],
|
||||
[35.4732, 129.6908], [35.2843, 129.5924], [35.1410, 129.4656],
|
||||
[35.0829, 129.2125],
|
||||
// 남해안 (부산→여수→목포)
|
||||
[34.8950, 129.0658], [34.2050, 128.3063], [35.0220, 128.0362],
|
||||
[34.9663, 127.8732], [34.9547, 127.7148], [34.8434, 127.6625],
|
||||
[34.7826, 127.7422], [34.6902, 127.6324], [34.8401, 127.5236],
|
||||
[34.8230, 127.4043], [34.6882, 127.4234], [34.6252, 127.4791],
|
||||
[34.5525, 127.4012], [34.4633, 127.3246], [34.5461, 127.1734],
|
||||
[34.6617, 127.2605], [34.7551, 127.2471], [34.6069, 127.0308],
|
||||
[34.4389, 126.8975], [34.4511, 126.8263], [34.4949, 126.7965],
|
||||
[34.5119, 126.7548], [34.4035, 126.6108], [34.3175, 126.5844],
|
||||
[34.3143, 126.5314], [34.3506, 126.5083], [34.4284, 126.5064],
|
||||
[34.4939, 126.4817], [34.5896, 126.3326], [34.6732, 126.2645],
|
||||
// 서해안 (목포→인천)
|
||||
[34.7200, 126.3011], [34.6946, 126.4256], [34.6979, 126.5245],
|
||||
[34.7787, 126.5386], [34.8244, 126.5934], [34.8104, 126.4785],
|
||||
[34.8234, 126.4207], [34.9328, 126.3979], [35.0451, 126.3274],
|
||||
[35.1542, 126.2911], [35.2169, 126.3605], [35.3144, 126.3959],
|
||||
[35.4556, 126.4604], [35.5013, 126.4928], [35.5345, 126.5822],
|
||||
[35.5710, 126.6141], [35.5897, 126.5649], [35.6063, 126.4865],
|
||||
[35.6471, 126.4885], [35.6693, 126.5419], [35.7142, 126.6016],
|
||||
[35.7688, 126.7174], [35.8720, 126.7530], [35.8979, 126.7196],
|
||||
[35.9225, 126.6475], [35.9745, 126.6637], [36.0142, 126.6935],
|
||||
[36.0379, 126.6823], [36.1050, 126.5971], [36.1662, 126.5404],
|
||||
[36.2358, 126.5572], [36.3412, 126.5442], [36.4297, 126.5520],
|
||||
[36.4776, 126.5482], [36.5856, 126.5066], [36.6938, 126.4877],
|
||||
[36.6780, 126.4330], [36.6512, 126.3888], [36.6893, 126.2307],
|
||||
[36.6916, 126.1809], [36.7719, 126.1605], [36.8709, 126.2172],
|
||||
[36.9582, 126.3516], [36.9690, 126.4287], [37.0075, 126.4870],
|
||||
[37.0196, 126.5777], [36.9604, 126.6867], [36.9484, 126.7845],
|
||||
[36.8461, 126.8388], [36.8245, 126.8721], [36.8621, 126.8791],
|
||||
[36.9062, 126.9580], [36.9394, 126.9769], [36.9576, 126.9598],
|
||||
[36.9757, 126.8689], [37.1027, 126.7874], [37.1582, 126.7761],
|
||||
[37.1936, 126.7464], [37.2949, 126.7905], [37.4107, 126.6962],
|
||||
[37.4471, 126.6503], [37.5512, 126.6568], [37.6174, 126.6076],
|
||||
[37.6538, 126.5802], [37.7165, 126.5634], [37.7447, 126.5777],
|
||||
[37.7555, 126.6207], [37.7818, 126.6339], [37.8007, 126.6646],
|
||||
[37.8279, 126.6665], [37.9172, 126.6668], [37.9790, 126.7543],
|
||||
// DMZ (간소화)
|
||||
[38.1066, 126.8789], [38.1756, 126.9400], [38.2405, 127.0097],
|
||||
[38.2839, 127.0903], [38.3045, 127.1695], [38.3133, 127.2940],
|
||||
[38.3244, 127.5469], [38.3353, 127.7299], [38.3469, 127.7858],
|
||||
[38.3066, 127.8207], [38.3250, 127.9001], [38.3150, 128.0083],
|
||||
[38.3107, 128.0314], [38.3189, 128.0887], [38.3317, 128.1269],
|
||||
[38.3481, 128.1606], [38.3748, 128.2054], [38.4032, 128.2347],
|
||||
[38.4797, 128.3064], [38.5339, 128.6952], [38.6177, 128.6560],
|
||||
]
|
||||
|
||||
// 제주도 — OpenStreetMap 기반 (26 points)
|
||||
const JEJU_POINTS: [number, number][] = [
|
||||
[33.5168, 126.0128], [33.5067, 126.0073], [33.1190, 126.0102],
|
||||
[33.0938, 126.0176], [33.0748, 126.0305], [33.0556, 126.0355],
|
||||
[33.0280, 126.0492], [33.0159, 126.4783], [33.0115, 126.5186],
|
||||
[33.0143, 126.5572], [33.0231, 126.5970], [33.0182, 126.6432],
|
||||
[33.0201, 126.7129], [33.0458, 126.7847], [33.0662, 126.8169],
|
||||
[33.0979, 126.8512], [33.1192, 126.9292], [33.1445, 126.9783],
|
||||
[33.1683, 127.0129], [33.1974, 127.0430], [33.2226, 127.0634],
|
||||
[33.2436, 127.0723], [33.4646, 127.2106], [33.5440, 126.0355],
|
||||
[33.5808, 126.0814], [33.5168, 126.0128],
|
||||
]
|
||||
|
||||
const ALL_COASTLINE = [...COASTLINE_POINTS, ...JEJU_POINTS]
|
||||
|
||||
/** 두 좌표 간 대략적 해리(NM) 계산 (Haversine) */
|
||||
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 3440.065
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
/** 클릭 지점에서 가장 가까운 해안선까지의 거리 (NM) */
|
||||
export function estimateDistanceFromCoast(lat: number, lon: number): number {
|
||||
let minDist = Infinity
|
||||
for (const [cLat, cLon] of ALL_COASTLINE) {
|
||||
const dist = haversineNm(lat, lon, cLat, cLon)
|
||||
if (dist < minDist) minDist = dist
|
||||
}
|
||||
return minDist
|
||||
}
|
||||
|
||||
/**
|
||||
* 해안선을 주어진 해리(NM) 만큼 바깥(바다쪽)으로 오프셋한 경계선 생성
|
||||
*/
|
||||
function offsetCoastline(points: [number, number][], distanceNm: number, outwardSign: number = 1): [number, number][] {
|
||||
const degPerNm = 1 / 60
|
||||
const result: [number, number][] = []
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const prev = points[(i - 1 + points.length) % points.length]
|
||||
const curr = points[i]
|
||||
const next = points[(i + 1) % points.length]
|
||||
|
||||
const cosLat = Math.cos(curr[0] * Math.PI / 180)
|
||||
const dx0 = (curr[1] - prev[1]) * cosLat
|
||||
const dy0 = curr[0] - prev[0]
|
||||
const dx1 = (next[1] - curr[1]) * cosLat
|
||||
const dy1 = next[0] - curr[0]
|
||||
|
||||
let nx = -(dy0 + dy1) / 2
|
||||
let ny = (dx0 + dx1) / 2
|
||||
const len = Math.sqrt(nx * nx + ny * ny) || 1
|
||||
nx /= len
|
||||
ny /= len
|
||||
|
||||
const latOff = outwardSign * nx * distanceNm * degPerNm
|
||||
const lonOff = outwardSign * ny * distanceNm * degPerNm / cosLat
|
||||
|
||||
result.push([curr[0] + latOff, curr[1] + lonOff])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export interface ZoneLine {
|
||||
path: [number, number][]
|
||||
color: [number, number, number, number]
|
||||
label: string
|
||||
distanceNm: number
|
||||
}
|
||||
|
||||
export function getDischargeZoneLines(): ZoneLine[] {
|
||||
const zones = [
|
||||
{ nm: 3, color: [239, 68, 68, 180] as [number, number, number, number], label: '3해리' },
|
||||
{ nm: 12, color: [249, 115, 22, 160] as [number, number, number, number], label: '12해리' },
|
||||
{ nm: 25, color: [234, 179, 8, 140] as [number, number, number, number], label: '25해리' },
|
||||
]
|
||||
|
||||
const lines: ZoneLine[] = []
|
||||
for (const zone of zones) {
|
||||
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1)
|
||||
lines.push({
|
||||
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: zone.label,
|
||||
distanceNm: zone.nm,
|
||||
})
|
||||
const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1)
|
||||
lines.push({
|
||||
path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: `${zone.label} (제주)`,
|
||||
distanceNm: zone.nm,
|
||||
})
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@ -193,6 +193,7 @@ export function OilSpillView() {
|
||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
||||
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
||||
const [summaryByModel, setSummaryByModel] = useState<Record<string, SimulationSummary>>({})
|
||||
const [stepSummariesByModel, setStepSummariesByModel] = useState<Record<string, SimulationSummary[]>>({})
|
||||
|
||||
// 오염분석 상태
|
||||
const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon')
|
||||
@ -405,15 +406,19 @@ export function OilSpillView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// windHydrModel이 visibleModels에 없으면 자동으로 적절한 모델로 전환
|
||||
// visibleModels 변경 시 windHydrModel 동기화
|
||||
useEffect(() => {
|
||||
if (visibleModels.size === 0) return;
|
||||
if (!visibleModels.has(windHydrModel as PredictionModel)) {
|
||||
if (visibleModels.size === 1) {
|
||||
// 단일 모델 → 항상 해당 모델로 동기화
|
||||
setWindHydrModel(Array.from(visibleModels)[0]);
|
||||
} else if (!visibleModels.has(windHydrModel as PredictionModel)) {
|
||||
// 다중 모델이지만 현재 선택이 사라진 경우 → fallback
|
||||
const preferred: PredictionModel[] = ['OpenDrift', 'POSEIDON', 'KOSPS'];
|
||||
const next = preferred.find(m => visibleModels.has(m)) ?? Array.from(visibleModels)[0];
|
||||
setWindHydrModel(next);
|
||||
}
|
||||
}, [visibleModels, windHydrModel]);
|
||||
}, [visibleModels]);
|
||||
|
||||
// 플레이어 재생 애니메이션 (1x = 1초/스텝, 2x = 0.5초/스텝, 4x = 0.25초/스텝)
|
||||
const timeSteps = useMemo(() => {
|
||||
@ -502,7 +507,7 @@ export function OilSpillView() {
|
||||
analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed';
|
||||
if (hasCompletedModel) {
|
||||
try {
|
||||
const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel } = await fetchAnalysisTrajectory(analysis.acdntSn)
|
||||
const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn)
|
||||
if (trajectory && trajectory.length > 0) {
|
||||
setOilTrajectory(trajectory)
|
||||
if (summary) setSimulationSummary(summary)
|
||||
@ -510,6 +515,7 @@ export function OilSpillView() {
|
||||
setWindDataByModel(wdByModel ?? {});
|
||||
setHydrDataByModel(hdByModel ?? {});
|
||||
if (sbModel) setSummaryByModel(sbModel);
|
||||
if (stepSbModel) setStepSummariesByModel(stepSbModel);
|
||||
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||
@ -529,6 +535,7 @@ export function OilSpillView() {
|
||||
setWindDataByModel({})
|
||||
setHydrDataByModel({})
|
||||
setSummaryByModel({})
|
||||
setStepSummariesByModel({})
|
||||
const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48)
|
||||
setOilTrajectory(demoTrajectory)
|
||||
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||
@ -825,7 +832,13 @@ export function OilSpillView() {
|
||||
incident: {
|
||||
name: accidentName,
|
||||
occurTime,
|
||||
location: selectedAnalysis?.location || analysisDetail?.acdnt?.location || '',
|
||||
location: selectedAnalysis?.location || analysisDetail?.acdnt?.location || (() => {
|
||||
const _lat = incidentCoord?.lat ?? selectedAnalysis?.lat ?? null;
|
||||
const _lon = incidentCoord?.lon ?? selectedAnalysis?.lon ?? null;
|
||||
return (_lat != null && _lon != null)
|
||||
? `위도 ${Number(_lat).toFixed(4)}, 경도 ${Number(_lon).toFixed(4)}`
|
||||
: '';
|
||||
})(),
|
||||
lat: incidentCoord?.lat ?? selectedAnalysis?.lat ?? null,
|
||||
lon: incidentCoord?.lon ?? selectedAnalysis?.lon ?? null,
|
||||
pollutant: OIL_TYPE_CODE[oilType] || oilType,
|
||||
@ -859,6 +872,17 @@ export function OilSpillView() {
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
})(),
|
||||
},
|
||||
spreadSteps: (() => {
|
||||
const steps = stepSummariesByModel[windHydrModel] ?? [];
|
||||
const toRow = (elapsed: string, s: typeof steps[0] | undefined) => ({
|
||||
elapsed,
|
||||
weathered: s ? s.weatheredVolume.toFixed(2) : '',
|
||||
seaRemain: s ? s.remainingVolume.toFixed(2) : '',
|
||||
coastAttach: s ? s.beachedVolume.toFixed(2) : '',
|
||||
area: s ? s.pollutionArea.toFixed(2) : '',
|
||||
});
|
||||
return [toRow('3시간', steps[3]), toRow('6시간', steps[6])];
|
||||
})(),
|
||||
hasSimulation: simulationSummary !== null,
|
||||
mapData: incidentCoord ? {
|
||||
center: [incidentCoord.lat, incidentCoord.lon],
|
||||
@ -1125,16 +1149,21 @@ export function OilSpillView() {
|
||||
})()}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '14px' }}>
|
||||
{[
|
||||
{ label: '풍화율', value: `${Math.min(99, Math.round(progressPct * 0.4))}%` },
|
||||
{ label: '면적', value: `${(progressPct * 0.08).toFixed(1)} km²` },
|
||||
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--boom)' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
|
||||
<span className="text-text-3">{s.label}</span>
|
||||
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--fM)' }}>{s.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const stepSummary = stepSummariesByModel[windHydrModel]?.[currentStep] ?? null;
|
||||
const weatheredVal = stepSummary ? `${stepSummary.weatheredVolume.toFixed(2)} m³` : '—';
|
||||
const areaVal = stepSummary ? `${stepSummary.pollutionArea.toFixed(1)} km²` : '—';
|
||||
return [
|
||||
{ label: '풍화량', value: weatheredVal },
|
||||
{ label: '면적', value: areaVal },
|
||||
{ label: '차단율', value: boomLines.length > 0 ? `${Math.min(95, 70 + Math.round(progressPct * 0.2))}%` : '—', color: 'var(--boom)' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px' }}>
|
||||
<span className="text-text-3">{s.label}</span>
|
||||
<span style={{ color: s.color, fontWeight: 600, fontFamily: 'var(--fM)' }}>{s.value}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1167,7 +1196,7 @@ export function OilSpillView() {
|
||||
onOpenRecalc={() => setRecalcModalOpen(true)}
|
||||
onOpenReport={handleOpenReport}
|
||||
detail={analysisDetail}
|
||||
summary={simulationSummary}
|
||||
summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary}
|
||||
displayControls={displayControls}
|
||||
onDisplayControlsChange={setDisplayControls}
|
||||
windHydrModel={windHydrModel}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { decimalToDMS } from '@common/utils/coordinates'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { ComboBox } from '@common/components/ui/ComboBox'
|
||||
import type { PredictionModel } from './OilSpillView'
|
||||
import { analyzeImage } from '../services/predictionApi'
|
||||
@ -267,54 +266,33 @@ const PredictionInputSection = ({
|
||||
{/* 사고 발생 시각 */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-text-3 font-korean">사고 발생 시각 (KST)</label>
|
||||
<input
|
||||
className="prd-i"
|
||||
type="datetime-local"
|
||||
<DateTimeInput
|
||||
value={accidentTime}
|
||||
onChange={(e) => onAccidentTimeChange(e.target.value)}
|
||||
style={{ colorScheme: 'dark' }}
|
||||
onChange={onAccidentTimeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Coordinates + Map Button */}
|
||||
{/* Coordinates (DMS) + Map Button */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
||||
<input
|
||||
className="prd-i"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lat ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
||||
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: isNaN(value) ? 0 : value })
|
||||
}}
|
||||
placeholder="위도°"
|
||||
/>
|
||||
<input
|
||||
className="prd-i"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lon ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
||||
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: isNaN(value) ? 0 : value })
|
||||
}}
|
||||
placeholder="경도°"
|
||||
<div className="grid grid-cols-[1fr_auto] gap-x-1 gap-y-1">
|
||||
<DmsCoordInput
|
||||
label="위도"
|
||||
isLatitude={true}
|
||||
decimal={incidentCoord?.lat ?? 0}
|
||||
onChange={(val) => onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: val })}
|
||||
/>
|
||||
<button
|
||||
className={`prd-map-btn${isSelectingLocation ? ' active' : ''}`}
|
||||
onClick={onMapSelectClick}
|
||||
>📍 지도</button>
|
||||
style={{ gridRow: '1 / 3', gridColumn: 2, whiteSpace: 'nowrap', height: '100%', minWidth: 48, padding: '0 10px' }}
|
||||
>📍<br/>지도</button>
|
||||
<DmsCoordInput
|
||||
label="경도"
|
||||
isLatitude={false}
|
||||
decimal={incidentCoord?.lon ?? 0}
|
||||
onChange={(val) => onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: val })}
|
||||
/>
|
||||
</div>
|
||||
{/* 도분초 표시 */}
|
||||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
|
||||
<div
|
||||
className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
||||
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}
|
||||
>
|
||||
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Oil Type + Oil Kind */}
|
||||
@ -384,7 +362,7 @@ const PredictionInputSection = ({
|
||||
|
||||
{/* Model Selection (다중 선택) */}
|
||||
{/* POSEIDON: 엔진 연동 완료. KOSPS: 준비 중 (ready: false) */}
|
||||
<div className="flex flex-wrap gap-[3px]">
|
||||
<div className="grid grid-cols-3 gap-[3px]">
|
||||
{([
|
||||
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false },
|
||||
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true },
|
||||
@ -392,7 +370,7 @@ const PredictionInputSection = ({
|
||||
] as const).map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer`}
|
||||
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer text-center`}
|
||||
onClick={() => {
|
||||
if (!m.ready) {
|
||||
alert(`${m.id} 모델은 현재 준비중입니다.`)
|
||||
@ -445,4 +423,294 @@ const PredictionInputSection = ({
|
||||
)
|
||||
}
|
||||
|
||||
// ── 커스텀 날짜/시간 선택 컴포넌트 ─────────────────────
|
||||
function DateTimeInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [showCal, setShowCal] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const datePart = value ? value.split('T')[0] : ''
|
||||
const timePart = value && value.includes('T') ? value.split('T')[1] : '00:00'
|
||||
const [hh, mm] = timePart.split(':').map(Number)
|
||||
|
||||
const parsed = datePart ? new Date(datePart + 'T00:00:00') : new Date()
|
||||
const [viewYear, setViewYear] = useState(parsed.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed.getMonth())
|
||||
|
||||
const selY = datePart ? parsed.getFullYear() : -1
|
||||
const selM = datePart ? parsed.getMonth() : -1
|
||||
const selD = datePart ? parsed.getDate() : -1
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setShowCal(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
|
||||
const firstDay = new Date(viewYear, viewMonth, 1).getDay()
|
||||
const days: (number | null)[] = []
|
||||
for (let i = 0; i < firstDay; i++) days.push(null)
|
||||
for (let i = 1; i <= daysInMonth; i++) days.push(i)
|
||||
|
||||
const pickDate = (day: number) => {
|
||||
const m = String(viewMonth + 1).padStart(2, '0')
|
||||
const d = String(day).padStart(2, '0')
|
||||
onChange(`${viewYear}-${m}-${d}T${timePart}`)
|
||||
setShowCal(false)
|
||||
}
|
||||
|
||||
const updateTime = (newHH: number, newMM: number) => {
|
||||
const date = datePart || new Date().toISOString().split('T')[0]
|
||||
onChange(`${date}T${String(newHH).padStart(2, '0')}:${String(newMM).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11) }
|
||||
else setViewMonth(viewMonth - 1)
|
||||
}
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) { setViewYear(viewYear + 1); setViewMonth(0) }
|
||||
else setViewMonth(viewMonth + 1)
|
||||
}
|
||||
|
||||
const displayDate = datePart
|
||||
? `${selY}.${String(selM + 1).padStart(2, '0')}.${String(selD).padStart(2, '0')}`
|
||||
: '날짜 선택'
|
||||
|
||||
const today = new Date()
|
||||
const todayY = today.getFullYear()
|
||||
const todayM = today.getMonth()
|
||||
const todayD = today.getDate()
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex items-center gap-1 relative">
|
||||
{/* 날짜 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCal(!showCal)}
|
||||
className="prd-i flex-1 flex items-center justify-between cursor-pointer"
|
||||
style={{ padding: '5px 8px', fontSize: 10 }}
|
||||
>
|
||||
<span className="font-mono" style={{ color: datePart ? 'var(--t1)' : 'var(--t3)' }}>{displayDate}</span>
|
||||
<span className="text-[9px] opacity-60">📅</span>
|
||||
</button>
|
||||
|
||||
{/* 시 */}
|
||||
<TimeDropdown value={hh} max={24} onChange={(v) => updateTime(v, mm)} />
|
||||
<span className="text-[8px] text-text-3 font-bold">:</span>
|
||||
{/* 분 */}
|
||||
<TimeDropdown value={mm} max={60} onChange={(v) => updateTime(hh, v)} />
|
||||
|
||||
{/* 캘린더 팝업 */}
|
||||
{showCal && (
|
||||
<div
|
||||
className="absolute z-[9999] rounded-md overflow-hidden"
|
||||
style={{
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: 4,
|
||||
width: 200,
|
||||
background: 'var(--bg3)',
|
||||
border: '1px solid var(--bd)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between" style={{ padding: '6px 8px', borderBottom: '1px solid var(--bd)' }}>
|
||||
<button type="button" onClick={prevMonth} className="text-[10px] text-text-3 cursor-pointer px-1 hover:text-text-1">◀</button>
|
||||
<span className="text-[10px] font-bold text-text-1 font-korean">{viewYear}년 {viewMonth + 1}월</span>
|
||||
<button type="button" onClick={nextMonth} className="text-[10px] text-text-3 cursor-pointer px-1 hover:text-text-1">▶</button>
|
||||
</div>
|
||||
{/* 요일 */}
|
||||
<div className="grid grid-cols-7 text-center" style={{ padding: '3px 4px 0' }}>
|
||||
{['일', '월', '화', '수', '목', '금', '토'].map((d) => (
|
||||
<span key={d} className="text-[8px] text-text-3 font-korean" style={{ padding: '2px 0' }}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
{/* 날짜 */}
|
||||
<div className="grid grid-cols-7 text-center" style={{ padding: '2px 4px 6px' }}>
|
||||
{days.map((day, i) => {
|
||||
if (day === null) return <span key={`e-${i}`} />
|
||||
const isSelected = viewYear === selY && viewMonth === selM && day === selD
|
||||
const isToday = viewYear === todayY && viewMonth === todayM && day === todayD
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => pickDate(day)}
|
||||
className="cursor-pointer rounded-sm"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
fontSize: 9,
|
||||
fontFamily: 'var(--fM)',
|
||||
fontWeight: isSelected ? 700 : 400,
|
||||
color: isSelected ? '#fff' : isToday ? 'var(--cyan)' : 'var(--t2)',
|
||||
background: isSelected ? 'var(--cyan)' : 'transparent',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* 오늘 버튼 */}
|
||||
<div style={{ padding: '0 8px 6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setViewYear(todayY)
|
||||
setViewMonth(todayM)
|
||||
pickDate(todayD)
|
||||
}}
|
||||
className="w-full text-[8px] font-korean font-semibold cursor-pointer rounded-sm"
|
||||
style={{
|
||||
padding: '3px 0',
|
||||
background: 'rgba(6,182,212,0.08)',
|
||||
border: '1px solid rgba(6,182,212,0.2)',
|
||||
color: 'var(--cyan)',
|
||||
}}
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 커스텀 시간 드롭다운 (다크 테마) ───────────────────
|
||||
function TimeDropdown({ value, max, onChange }: { value: number; max: number; onChange: (v: number) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && listRef.current) {
|
||||
const activeEl = listRef.current.querySelector('[data-active="true"]')
|
||||
if (activeEl) activeEl.scrollIntoView({ block: 'center' })
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={dropRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="prd-i text-center font-mono cursor-pointer"
|
||||
style={{ width: 38, padding: '5px 2px', fontSize: 9 }}
|
||||
>
|
||||
{String(value).padStart(2, '0')}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-[9999] overflow-y-auto rounded-md"
|
||||
style={{
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginTop: 2,
|
||||
width: 42,
|
||||
maxHeight: 160,
|
||||
background: 'var(--bg3)',
|
||||
border: '1px solid var(--bd)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--bd) transparent',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
data-active={i === value}
|
||||
onClick={() => { onChange(i); setOpen(false) }}
|
||||
className="w-full text-center font-mono cursor-pointer"
|
||||
style={{
|
||||
padding: '4px 0',
|
||||
fontSize: 9,
|
||||
color: i === value ? 'var(--cyan)' : 'var(--t2)',
|
||||
background: i === value ? 'rgba(6,182,212,0.15)' : 'transparent',
|
||||
fontWeight: i === value ? 700 : 400,
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{String(i).padStart(2, '0')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 도분초 좌표 입력 컴포넌트 ──────────────────────────
|
||||
function DmsCoordInput({
|
||||
label,
|
||||
isLatitude,
|
||||
decimal,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
isLatitude: boolean
|
||||
decimal: number
|
||||
onChange: (val: number) => void
|
||||
}) {
|
||||
const abs = Math.abs(decimal)
|
||||
const d = Math.floor(abs)
|
||||
const mDec = (abs - d) * 60
|
||||
const m = Math.floor(mDec)
|
||||
const s = parseFloat(((mDec - m) * 60).toFixed(2))
|
||||
const dir = isLatitude ? (decimal >= 0 ? 'N' : 'S') : (decimal >= 0 ? 'E' : 'W')
|
||||
|
||||
const update = (deg: number, min: number, sec: number, direction: string) => {
|
||||
let val = deg + min / 60 + sec / 3600
|
||||
if (direction === 'S' || direction === 'W') val = -val
|
||||
onChange(val)
|
||||
}
|
||||
|
||||
const fieldStyle = { padding: '5px 2px', fontSize: 10, minWidth: 0 }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[8px] text-text-3 font-korean">{label}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<select
|
||||
className="prd-i text-center"
|
||||
value={dir}
|
||||
onChange={(e) => update(d, m, s, e.target.value)}
|
||||
style={{ width: 32, padding: '5px 1px', fontSize: 10, appearance: 'none', WebkitAppearance: 'none', backgroundImage: 'none' }}
|
||||
>
|
||||
{isLatitude ? (
|
||||
<><option value="N">N</option><option value="S">S</option></>
|
||||
) : (
|
||||
<><option value="E">E</option><option value="W">W</option></>
|
||||
)}
|
||||
</select>
|
||||
<input className="prd-i text-center flex-1" type="number" min={0} max={isLatitude ? 90 : 180}
|
||||
value={d} onChange={(e) => update(parseInt(e.target.value) || 0, m, s, dir)} style={fieldStyle} />
|
||||
<span className="text-[9px] text-text-3">°</span>
|
||||
<input className="prd-i text-center flex-1" type="number" min={0} max={59}
|
||||
value={m} onChange={(e) => update(d, parseInt(e.target.value) || 0, s, dir)} style={fieldStyle} />
|
||||
<span className="text-[9px] text-text-3">'</span>
|
||||
<input className="prd-i text-center flex-1" type="number" min={0} max={59.99} step={0.01}
|
||||
value={s} onChange={(e) => update(d, m, parseFloat(e.target.value) || 0, dir)} style={fieldStyle} />
|
||||
<span className="text-[9px] text-text-3">"</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PredictionInputSection
|
||||
|
||||
@ -210,6 +210,7 @@ export interface TrajectoryResponse {
|
||||
windDataByModel?: Record<string, WindPoint[][]>;
|
||||
hydrDataByModel?: Record<string, (HydrDataStep | null)[]>;
|
||||
summaryByModel?: Record<string, SimulationSummary>;
|
||||
stepSummariesByModel?: Record<string, SimulationSummary[]>;
|
||||
}
|
||||
|
||||
export const fetchAnalysisTrajectory = async (acdntSn: number): Promise<TrajectoryResponse> => {
|
||||
|
||||
@ -71,6 +71,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
report.author = '시스템 자동생성'
|
||||
if (activeCat === 0) {
|
||||
if (oilPayload) {
|
||||
// 사고 기본정보
|
||||
report.incident.name = oilPayload.incident.name;
|
||||
report.incident.occurTime = oilPayload.incident.occurTime;
|
||||
report.incident.location = oilPayload.incident.location;
|
||||
@ -79,6 +80,49 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
report.incident.shipName = oilPayload.incident.shipName;
|
||||
report.incident.pollutant = oilPayload.pollution.oilType;
|
||||
report.incident.spillAmount = oilPayload.pollution.spillAmount;
|
||||
// 해안부착 시간·오염길이 (oil-coastal)
|
||||
report.incident.agent = [
|
||||
oilPayload.coastal?.firstTime ? `해안도달: ${oilPayload.coastal.firstTime}` : '',
|
||||
oilPayload.pollution.coastLength ? `오염해안: ${oilPayload.pollution.coastLength}km` : '',
|
||||
].filter(Boolean).join(', ');
|
||||
|
||||
// 조석·기상정보 (oil-tide)
|
||||
if (oilPayload.weather) {
|
||||
report.weather = [{
|
||||
time: '',
|
||||
sunrise: '',
|
||||
sunset: '',
|
||||
windDir: oilPayload.weather.windDir,
|
||||
windSpeed: oilPayload.weather.windSpeed,
|
||||
currentDir: '',
|
||||
currentSpeed: '',
|
||||
waveHeight: oilPayload.weather.waveHeight,
|
||||
}];
|
||||
}
|
||||
|
||||
// 오염종합상황 + 해안부착 현황 (oil-pollution, oil-coastal)
|
||||
report.result = {
|
||||
spillTotal: oilPayload.pollution.spillAmount,
|
||||
weatheredTotal: oilPayload.pollution.weathered,
|
||||
recoveredTotal: '',
|
||||
seaRemainTotal: oilPayload.pollution.seaRemain,
|
||||
coastAttachTotal: oilPayload.pollution.coastAttach,
|
||||
};
|
||||
|
||||
// 유출유확산예측 결과 — 모델별 비교 (oil-spread)
|
||||
const spreadLines = [
|
||||
oilPayload.spread.kosps ? `KOSPS: ${oilPayload.spread.kosps}` : '',
|
||||
oilPayload.spread.openDrift ? `OpenDrift: ${oilPayload.spread.openDrift}` : '',
|
||||
oilPayload.spread.poseidon ? `POSEIDON: ${oilPayload.spread.poseidon}` : '',
|
||||
].filter(Boolean);
|
||||
if (spreadLines.length > 0) {
|
||||
report.analysis = spreadLines.join('\n');
|
||||
}
|
||||
|
||||
// 스텝별 오염종합 상황 (3h/6h) → report.spread
|
||||
if (oilPayload.spreadSteps) {
|
||||
report.spread = oilPayload.spreadSteps;
|
||||
}
|
||||
} else {
|
||||
report.incident.pollutant = '';
|
||||
report.incident.spillAmount = '';
|
||||
|
||||
@ -344,6 +344,12 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa
|
||||
}
|
||||
}
|
||||
|
||||
// location이 비어있고 좌표가 있으면 좌표 문자열로 대체 (기존 보고서 대응)
|
||||
if (!reportData.incident.location && reportData.incident.lat && reportData.incident.lon) {
|
||||
reportData.incident.location =
|
||||
`위도 ${parseFloat(reportData.incident.lat).toFixed(4)}, 경도 ${parseFloat(reportData.incident.lon).toFixed(4)}`;
|
||||
}
|
||||
|
||||
if (detail.mapCaptureImg) {
|
||||
reportData.capturedMapImage = detail.mapCaptureImg;
|
||||
}
|
||||
|
||||
@ -99,9 +99,9 @@ function StatCard({ value, label, valueClass = 'text-primary-cyan' }: { value: s
|
||||
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
if (!weatherData) {
|
||||
return (
|
||||
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[380px] shrink-0">
|
||||
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[320px] shrink-0">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-text-3 text-sm">지도에서 해양 지점을 클릭하세요</p>
|
||||
<p className="text-text-3 text-[13px] font-korean">지도에서 해양 지점을 클릭하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -396,99 +396,74 @@ export function WeatherView() {
|
||||
</Map>
|
||||
|
||||
{/* 레이어 컨트롤 */}
|
||||
<div className="absolute top-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm z-10">
|
||||
<div className="text-sm font-semibold text-text-1 mb-3">기상 레이어</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="absolute top-4 left-4 bg-bg-1/85 border border-border rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px' }}>
|
||||
<div className="text-[9px] font-semibold text-text-1 mb-1.5 font-korean">기상 레이어</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('windParticle')}
|
||||
onChange={() => toggleLayer('windParticle')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌬️ 바람 흐름</span>
|
||||
<span className="text-[9px] text-text-2">🌬️ 바람 흐름</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('wind')}
|
||||
onChange={() => toggleLayer('wind')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌬️ 바람 벡터</span>
|
||||
<span className="text-[9px] text-text-2">🌬️ 바람 벡터</span>
|
||||
</label>
|
||||
{/* 기상 데이터 레이어 — 임시 비활성화
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('labels')}
|
||||
onChange={() => toggleLayer('labels')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
/>
|
||||
<span className="text-xs text-text-2">📊 기상 데이터</span>
|
||||
</label>
|
||||
*/}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('waves')}
|
||||
onChange={() => toggleLayer('waves')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌊 파고 분포</span>
|
||||
<span className="text-[9px] text-text-2">🌊 파고 분포</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('temperature')}
|
||||
onChange={() => toggleLayer('temperature')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌡️ 수온 분포</span>
|
||||
<span className="text-[9px] text-text-2">🌡️ 수온 분포</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('oceanCurrentParticle')}
|
||||
onChange={() => toggleLayer('oceanCurrentParticle')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌊 해류 흐름</span>
|
||||
<span className="text-[9px] text-text-2">🌊 해류 흐름</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('waterTemperature')}
|
||||
onChange={() => toggleLayer('waterTemperature')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
className="w-3 h-3 rounded border-border bg-bg-2 text-primary-cyan accent-[var(--cyan)]"
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌡️ 수온 색상도</span>
|
||||
<span className="text-[9px] text-text-2">🌡️ 수온 색상도</span>
|
||||
</label>
|
||||
|
||||
{/* 해황예보도 레이어 — 임시 비활성화
|
||||
<div className="pt-2 mt-2 border-t border-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLayers.has('oceanForecast')}
|
||||
onChange={() => toggleLayer('oceanForecast')}
|
||||
className="w-4 h-4 rounded border-border bg-bg-2 text-primary-cyan focus:ring-primary-cyan"
|
||||
/>
|
||||
<span className="text-xs text-text-2">🌊 해황예보도</span>
|
||||
</label>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="absolute bottom-6 left-6 bg-bg-1/90 border border-border rounded-lg p-4 backdrop-blur-sm z-10">
|
||||
<div className="text-sm font-semibold text-text-1 mb-3">기상 범례</div>
|
||||
<div className="space-y-3 text-xs">
|
||||
{/* 바람 (Windy 스타일) */}
|
||||
<div className="absolute bottom-4 left-4 bg-bg-1/85 border border-border rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px', maxWidth: 180 }}>
|
||||
<div className="text-[9px] font-semibold text-text-1 mb-1.5 font-korean">기상 범례</div>
|
||||
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
|
||||
{/* 바람 */}
|
||||
<div>
|
||||
<div className="font-semibold text-text-2 mb-1">바람 (m/s)</div>
|
||||
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
|
||||
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}>바람 (m/s)</div>
|
||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#50d591' }} />
|
||||
@ -498,53 +473,38 @@ export function WeatherView() {
|
||||
<div className="flex-1 h-full" style={{ background: '#f05421' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-text-3 text-[9px]">
|
||||
<span>3</span>
|
||||
<span>5</span>
|
||||
<span>7</span>
|
||||
<span>10</span>
|
||||
<span>13</span>
|
||||
<span>16</span>
|
||||
<span>20+</span>
|
||||
<div className="flex justify-between text-text-3" style={{ fontSize: 7 }}>
|
||||
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 해류 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-1">해류 (m/s)</div>
|
||||
<div className="flex items-center gap-1 h-3 rounded-sm overflow-hidden mb-1">
|
||||
<div className="pt-1 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}>해류 (m/s)</div>
|
||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(34, 197, 94)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-text-3 text-[9px]">
|
||||
<span>0.2</span>
|
||||
<span>0.4</span>
|
||||
<span>0.6</span>
|
||||
<span>0.6+</span>
|
||||
<div className="flex justify-between text-text-3" style={{ fontSize: 7 }}>
|
||||
<span>0.2</span><span>0.4</span><span>0.6</span><span>0.6+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 파고 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-1">파고 (m)</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-text-3">< 1.5: 낮음</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500" />
|
||||
<span className="text-text-3">1.5-2.5: 보통</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="text-text-3">> 2.5: 높음</span>
|
||||
<div className="pt-1 border-t border-border">
|
||||
<div className="font-semibold text-text-2 mb-0.5" style={{ fontSize: 8 }}>파고 (m)</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-text-3"><1.5 낮음</span>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500 ml-1" />
|
||||
<span className="text-text-3">~2.5</span>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 ml-1" />
|
||||
<span className="text-text-3">>2.5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-border text-xs text-text-3">
|
||||
💡 지도를 클릭하여 해당 지점의 기상 예보를 확인하세요
|
||||
<div className="mt-1 pt-1 border-t border-border text-text-3 font-korean" style={{ fontSize: 7 }}>
|
||||
💡 지도 클릭 → 기상 예보 확인
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user