feat(scat): SCAT Mock → API 전환 + PostGIS GEOMETRY 일괄 적용 #41
62
backend/src/scat/scatRouter.ts
Normal file
62
backend/src/scat/scatRouter.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
|
import { listZones, listSections, getSection } from './scatService.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/scat/zones — 조사구역 목록
|
||||||
|
// ============================================================
|
||||||
|
router.get('/zones', requireAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const zones = await listZones();
|
||||||
|
res.json(zones);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[scat] 조사구역 목록 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '조사구역 목록 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/scat/sections — 해안구간 목록 (필터링)
|
||||||
|
// ============================================================
|
||||||
|
router.get('/sections', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { zone, status, sensitivity, jurisdiction, search } = req.query as {
|
||||||
|
zone?: string;
|
||||||
|
status?: string;
|
||||||
|
sensitivity?: string;
|
||||||
|
jurisdiction?: string;
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search });
|
||||||
|
res.json(sections);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[scat] 해안구간 목록 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '해안구간 목록 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/scat/sections/:sn — 해안구간 상세
|
||||||
|
// ============================================================
|
||||||
|
router.get('/sections/:sn', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sn = parseInt(req.params.sn as string, 10);
|
||||||
|
if (isNaN(sn)) {
|
||||||
|
res.status(400).json({ error: '유효하지 않은 구간 번호입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const section = await getSection(sn);
|
||||||
|
if (!section) {
|
||||||
|
res.status(404).json({ error: '해안구간을 찾을 수 없습니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(section);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[scat] 해안구간 상세 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '해안구간 상세 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
212
backend/src/scat/scatService.ts
Normal file
212
backend/src/scat/scatService.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { wingPool } from '../db/wingDb.js';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 인터페이스
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface ZoneItem {
|
||||||
|
cstSrvyZoneSn: number;
|
||||||
|
zoneCd: string;
|
||||||
|
zoneNm: string;
|
||||||
|
jrsdNm: string;
|
||||||
|
sectCnt: number;
|
||||||
|
latCenter: number;
|
||||||
|
lngCenter: number;
|
||||||
|
latRange: number;
|
||||||
|
lngRange: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionListItem {
|
||||||
|
cstSectSn: number;
|
||||||
|
sectCd: string;
|
||||||
|
sectNm: string;
|
||||||
|
cstTpCd: string;
|
||||||
|
esiCd: string;
|
||||||
|
esiNum: number;
|
||||||
|
lenM: number;
|
||||||
|
snstvtCd: string;
|
||||||
|
srvySttsCd: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
tags: string[];
|
||||||
|
zoneCd: string;
|
||||||
|
zoneNm: string;
|
||||||
|
jrsdNm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionDetail {
|
||||||
|
cstSectSn: number;
|
||||||
|
sectCd: string;
|
||||||
|
sectNm: string;
|
||||||
|
cstTpCd: string;
|
||||||
|
esiCd: string;
|
||||||
|
esiNum: number;
|
||||||
|
lenM: number;
|
||||||
|
snstvtCd: string;
|
||||||
|
srvySttsCd: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
tags: string[];
|
||||||
|
geomJson: Record<string, unknown> | null;
|
||||||
|
zoneCd: string;
|
||||||
|
zoneNm: string;
|
||||||
|
jrsdNm: string;
|
||||||
|
shoreTp: string | null;
|
||||||
|
accessDc: string | null;
|
||||||
|
accessPt: string | null;
|
||||||
|
sensitiveInfo: { t: string; v: string }[];
|
||||||
|
cleanupMethods: string[];
|
||||||
|
endCriteria: string[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 조사구역 목록 조회
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function listZones(): Promise<ZoneItem[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT CST_SRVY_ZONE_SN, ZONE_CD, ZONE_NM, JRSD_NM,
|
||||||
|
SECT_CNT, LAT_CENTER, LNG_CENTER, LAT_RANGE, LNG_RANGE
|
||||||
|
FROM wing.CST_SRVY_ZONE
|
||||||
|
WHERE USE_YN = 'Y'
|
||||||
|
ORDER BY CST_SRVY_ZONE_SN
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(sql);
|
||||||
|
|
||||||
|
// pg QueryResult rows — NUMERIC은 string 반환, 타입 단언 불가피
|
||||||
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
|
cstSrvyZoneSn: r.cst_srvy_zone_sn as number,
|
||||||
|
zoneCd: r.zone_cd as string,
|
||||||
|
zoneNm: r.zone_nm as string,
|
||||||
|
jrsdNm: r.jrsd_nm as string,
|
||||||
|
sectCnt: r.sect_cnt as number,
|
||||||
|
latCenter: parseFloat(r.lat_center as string),
|
||||||
|
lngCenter: parseFloat(r.lng_center as string),
|
||||||
|
latRange: parseFloat(r.lat_range as string),
|
||||||
|
lngRange: parseFloat(r.lng_range as string),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 해안구간 목록 조회 (필터링)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function listSections(filters: {
|
||||||
|
zone?: string;
|
||||||
|
status?: string;
|
||||||
|
sensitivity?: string;
|
||||||
|
jurisdiction?: string;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<SectionListItem[]> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filters.zone) {
|
||||||
|
conditions.push(`z.ZONE_CD = $${idx++}`);
|
||||||
|
params.push(filters.zone);
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
conditions.push(`s.SRVY_STTS_CD = $${idx++}`);
|
||||||
|
params.push(filters.status);
|
||||||
|
}
|
||||||
|
if (filters.sensitivity) {
|
||||||
|
conditions.push(`s.SNSTVT_CD = $${idx++}`);
|
||||||
|
params.push(filters.sensitivity);
|
||||||
|
}
|
||||||
|
if (filters.jurisdiction) {
|
||||||
|
conditions.push(`z.JRSD_NM ILIKE '%' || $${idx++} || '%'`);
|
||||||
|
params.push(filters.jurisdiction);
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`);
|
||||||
|
params.push(filters.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push("s.USE_YN = 'Y'");
|
||||||
|
conditions.push("z.USE_YN = 'Y'");
|
||||||
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT s.CST_SECT_SN, s.SECT_CD, s.SECT_NM, s.CST_TP_CD,
|
||||||
|
s.ESI_CD, s.ESI_NUM, s.LEN_M, s.SNSTVT_CD, s.SRVY_STTS_CD,
|
||||||
|
s.LAT, s.LNG, s.TAGS,
|
||||||
|
z.ZONE_CD, z.ZONE_NM, z.JRSD_NM
|
||||||
|
FROM wing.CST_SECT s
|
||||||
|
JOIN wing.CST_SRVY_ZONE z ON z.CST_SRVY_ZONE_SN = s.CST_SRVY_ZONE_SN
|
||||||
|
${where}
|
||||||
|
ORDER BY s.CST_SECT_SN
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(sql, params);
|
||||||
|
|
||||||
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
|
cstSectSn: r.cst_sect_sn as number,
|
||||||
|
sectCd: r.sect_cd as string,
|
||||||
|
sectNm: r.sect_nm as string,
|
||||||
|
cstTpCd: r.cst_tp_cd as string,
|
||||||
|
esiCd: r.esi_cd as string,
|
||||||
|
esiNum: r.esi_num as number,
|
||||||
|
lenM: r.len_m as number,
|
||||||
|
snstvtCd: r.snstvt_cd as string,
|
||||||
|
srvySttsCd: r.srvy_stts_cd as string,
|
||||||
|
lat: parseFloat(r.lat as string),
|
||||||
|
lng: parseFloat(r.lng as string),
|
||||||
|
tags: (r.tags as string[]) ?? [],
|
||||||
|
zoneCd: r.zone_cd as string,
|
||||||
|
zoneNm: r.zone_nm as string,
|
||||||
|
jrsdNm: r.jrsd_nm as string,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 해안구간 단건 상세 조회 (JSONB 포함)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function getSection(sn: number): Promise<SectionDetail | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT s.CST_SECT_SN, s.SECT_CD, s.SECT_NM, s.CST_TP_CD,
|
||||||
|
s.ESI_CD, s.ESI_NUM, s.LEN_M, s.SNSTVT_CD, s.SRVY_STTS_CD,
|
||||||
|
s.LAT, s.LNG, s.TAGS,
|
||||||
|
ST_AsGeoJSON(s.GEOM)::jsonb AS geom_json,
|
||||||
|
s.SHORE_TP, s.ACCESS_DC, s.ACCESS_PT,
|
||||||
|
s.SENSITIVE_INFO, s.CLEANUP_METHODS, s.END_CRITERIA, s.NOTES,
|
||||||
|
z.ZONE_CD, z.ZONE_NM, z.JRSD_NM
|
||||||
|
FROM wing.CST_SECT s
|
||||||
|
JOIN wing.CST_SRVY_ZONE z ON z.CST_SRVY_ZONE_SN = s.CST_SRVY_ZONE_SN
|
||||||
|
WHERE s.CST_SECT_SN = $1 AND s.USE_YN = 'Y'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { rows } = await wingPool.query(sql, [sn]);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const r = rows[0] as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cstSectSn: r.cst_sect_sn as number,
|
||||||
|
sectCd: r.sect_cd as string,
|
||||||
|
sectNm: r.sect_nm as string,
|
||||||
|
cstTpCd: r.cst_tp_cd as string,
|
||||||
|
esiCd: r.esi_cd as string,
|
||||||
|
esiNum: r.esi_num as number,
|
||||||
|
lenM: r.len_m as number,
|
||||||
|
snstvtCd: r.snstvt_cd as string,
|
||||||
|
srvySttsCd: r.srvy_stts_cd as string,
|
||||||
|
lat: parseFloat(r.lat as string),
|
||||||
|
lng: parseFloat(r.lng as string),
|
||||||
|
tags: (r.tags as string[]) ?? [],
|
||||||
|
geomJson: (r.geom_json as Record<string, unknown>) ?? null,
|
||||||
|
zoneCd: r.zone_cd as string,
|
||||||
|
zoneNm: r.zone_nm as string,
|
||||||
|
jrsdNm: r.jrsd_nm as string,
|
||||||
|
shoreTp: (r.shore_tp as string) ?? null,
|
||||||
|
accessDc: (r.access_dc as string) ?? null,
|
||||||
|
accessPt: (r.access_pt as string) ?? null,
|
||||||
|
sensitiveInfo: (r.sensitive_info as { t: string; v: string }[]) ?? [],
|
||||||
|
cleanupMethods: (r.cleanup_methods as string[]) ?? [],
|
||||||
|
endCriteria: (r.end_criteria as string[]) ?? [],
|
||||||
|
notes: (r.notes as string[]) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ import hnsRouter from './hns/hnsRouter.js'
|
|||||||
import reportsRouter from './reports/reportsRouter.js'
|
import reportsRouter from './reports/reportsRouter.js'
|
||||||
import assetsRouter from './assets/assetsRouter.js'
|
import assetsRouter from './assets/assetsRouter.js'
|
||||||
import incidentsRouter from './incidents/incidentsRouter.js'
|
import incidentsRouter from './incidents/incidentsRouter.js'
|
||||||
|
import scatRouter from './scat/scatRouter.js'
|
||||||
import {
|
import {
|
||||||
sanitizeBody,
|
sanitizeBody,
|
||||||
sanitizeQuery,
|
sanitizeQuery,
|
||||||
@ -147,6 +148,7 @@ app.use('/api/hns', hnsRouter)
|
|||||||
app.use('/api/reports', reportsRouter)
|
app.use('/api/reports', reportsRouter)
|
||||||
app.use('/api/assets', assetsRouter)
|
app.use('/api/assets', assetsRouter)
|
||||||
app.use('/api/incidents', incidentsRouter)
|
app.use('/api/incidents', incidentsRouter)
|
||||||
|
app.use('/api/scat', scatRouter)
|
||||||
|
|
||||||
// 헬스 체크
|
// 헬스 체크
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
|
|||||||
31
database/migration/010_postgis_geom.sql
Normal file
31
database/migration/010_postgis_geom.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 010: PostGIS GEOMETRY 컬럼 일괄 추가
|
||||||
|
-- 기존 NUMERIC LAT/LNG 유지 + GEOMETRY(Point, 4326) 추가
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET search_path TO wing, public;
|
||||||
|
|
||||||
|
-- PostGIS 익스텐션 확인
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. ASSET_ORG — 방제자산 기관 위치
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE ASSET_ORG ADD COLUMN IF NOT EXISTS GEOM GEOMETRY(Point, 4326);
|
||||||
|
|
||||||
|
UPDATE ASSET_ORG
|
||||||
|
SET GEOM = ST_SetSRID(ST_MakePoint(LNG, LAT), 4326)
|
||||||
|
WHERE LAT IS NOT NULL AND LNG IS NOT NULL AND GEOM IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_ASSET_ORG_GEOM ON ASSET_ORG USING GIST(GEOM);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. ACDNT — 사고 위치
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE ACDNT ADD COLUMN IF NOT EXISTS LOC_GEOM GEOMETRY(Point, 4326);
|
||||||
|
|
||||||
|
UPDATE ACDNT
|
||||||
|
SET LOC_GEOM = ST_SetSRID(ST_MakePoint(LNG, LAT), 4326)
|
||||||
|
WHERE LAT IS NOT NULL AND LNG IS NOT NULL AND LOC_GEOM IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS IDX_ACDNT_LOC_GEOM ON ACDNT USING GIST(LOC_GEOM);
|
||||||
1410
database/migration/011_scat.sql
Normal file
1410
database/migration/011_scat.sql
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,56 +1,120 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import type { ScatSegment, ScatDetail } from './scatTypes'
|
import type { ScatSegment, ScatDetail } from './scatTypes';
|
||||||
import { allSegments, scatDetailData } from './scatConstants'
|
import { fetchSections, fetchSectionDetail, fetchZones } from '../services/scatApi';
|
||||||
import ScatLeftPanel from './ScatLeftPanel'
|
import type { ApiZoneItem } from '../services/scatApi';
|
||||||
import ScatMap from './ScatMap'
|
import ScatLeftPanel from './ScatLeftPanel';
|
||||||
import ScatTimeline from './ScatTimeline'
|
import ScatMap from './ScatMap';
|
||||||
import ScatPopup from './ScatPopup'
|
import ScatTimeline from './ScatTimeline';
|
||||||
|
import ScatPopup from './ScatPopup';
|
||||||
|
|
||||||
// ═══ Main PreScatView ═══
|
// ═══ Main PreScatView ═══
|
||||||
|
|
||||||
export function PreScatView() {
|
export function PreScatView() {
|
||||||
const [selectedSeg, setSelectedSeg] = useState<ScatSegment>(allSegments[0])
|
const [segments, setSegments] = useState<ScatSegment[]>([]);
|
||||||
const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)')
|
const [zones, setZones] = useState<ApiZoneItem[]>([]);
|
||||||
const [areaFilter, setAreaFilter] = useState('전체')
|
const [loading, setLoading] = useState(true);
|
||||||
const [phaseFilter, setPhaseFilter] = useState('Pre-SCAT (사전조사)')
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [statusFilter, setStatusFilter] = useState('전체')
|
const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)');
|
||||||
const [popupData, setPopupData] = useState<ScatDetail | null>(null)
|
const [areaFilter, setAreaFilter] = useState('전체');
|
||||||
const [timelineIdx, setTimelineIdx] = useState(6)
|
const [phaseFilter, setPhaseFilter] = useState('Pre-SCAT (사전조사)');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('전체');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [popupData, setPopupData] = useState<ScatDetail | null>(null);
|
||||||
|
const [timelineIdx, setTimelineIdx] = useState(6);
|
||||||
|
|
||||||
|
// API에서 구역 및 구간 데이터 로딩
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [zonesData, sectionsData] = await Promise.all([fetchZones(), fetchSections()]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setZones(zonesData);
|
||||||
|
setSegments(sectionsData);
|
||||||
|
if (sectionsData.length > 0) {
|
||||||
|
setSelectedSeg(sectionsData[0]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SCAT] 데이터 로딩 오류:', err);
|
||||||
|
if (!cancelled) setError('데이터를 불러오지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 관할 기반 세그먼트 필터링
|
// 관할 기반 세그먼트 필터링
|
||||||
const segments = allSegments.filter(s => {
|
const filteredSegments = segments.filter((s) => {
|
||||||
if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포'
|
if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포';
|
||||||
if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주'
|
if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주';
|
||||||
return true // 전체
|
return true; // 전체
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleOpenPopup = useCallback((idx: number) => {
|
const handleOpenPopup = useCallback(async (sn: number) => {
|
||||||
setPopupData(scatDetailData[idx] || scatDetailData[0])
|
try {
|
||||||
}, [])
|
const detail = await fetchSectionDetail(sn);
|
||||||
|
setPopupData(detail);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SCAT] 상세 데이터 로딩 오류:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClosePopup = useCallback(() => {
|
const handleClosePopup = useCallback(() => {
|
||||||
setPopupData(null)
|
setPopupData(null);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleTimelineSeek = useCallback((idx: number) => {
|
const handleTimelineSeek = useCallback(
|
||||||
if (idx === -1) {
|
(idx: number) => {
|
||||||
// advance signal from play
|
if (idx === -1) {
|
||||||
setTimelineIdx(prev => {
|
// advance signal from play
|
||||||
const next = (prev + 1) % Math.min(segments.length, 12)
|
setTimelineIdx((prev) => {
|
||||||
if (segments[next]) setSelectedSeg(segments[next])
|
const next = (prev + 1) % Math.min(filteredSegments.length, 12);
|
||||||
return next
|
if (filteredSegments[next]) setSelectedSeg(filteredSegments[next]);
|
||||||
})
|
return next;
|
||||||
} else {
|
});
|
||||||
setTimelineIdx(idx)
|
} else {
|
||||||
if (segments[idx]) setSelectedSeg(segments[idx])
|
setTimelineIdx(idx);
|
||||||
}
|
if (filteredSegments[idx]) setSelectedSeg(filteredSegments[idx]);
|
||||||
}, [segments])
|
}
|
||||||
|
},
|
||||||
|
[filteredSegments],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full h-full bg-bg-0 items-center justify-center flex-col gap-3">
|
||||||
|
<div className="text-status-red text-sm font-korean">{error}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setError(null); setLoading(true); }}
|
||||||
|
className="px-4 py-1.5 bg-primary-cyan text-white text-xs rounded font-korean"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !selectedSeg) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full h-full bg-bg-0 items-center justify-center">
|
||||||
|
<div className="text-text-2 text-sm font-korean">SCAT 데이터 로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full h-full bg-bg-0 overflow-hidden">
|
<div className="flex w-full h-full bg-bg-0 overflow-hidden">
|
||||||
<ScatLeftPanel
|
<ScatLeftPanel
|
||||||
segments={segments}
|
segments={filteredSegments}
|
||||||
|
zones={zones}
|
||||||
selectedSeg={selectedSeg}
|
selectedSeg={selectedSeg}
|
||||||
onSelectSeg={setSelectedSeg}
|
onSelectSeg={setSelectedSeg}
|
||||||
onOpenPopup={handleOpenPopup}
|
onOpenPopup={handleOpenPopup}
|
||||||
@ -68,13 +132,13 @@ export function PreScatView() {
|
|||||||
|
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<ScatMap
|
<ScatMap
|
||||||
segments={segments}
|
segments={filteredSegments}
|
||||||
selectedSeg={selectedSeg}
|
selectedSeg={selectedSeg}
|
||||||
onSelectSeg={setSelectedSeg}
|
onSelectSeg={setSelectedSeg}
|
||||||
onOpenPopup={handleOpenPopup}
|
onOpenPopup={handleOpenPopup}
|
||||||
/>
|
/>
|
||||||
<ScatTimeline
|
<ScatTimeline
|
||||||
segments={segments}
|
segments={filteredSegments}
|
||||||
currentIdx={timelineIdx}
|
currentIdx={timelineIdx}
|
||||||
onSeek={handleTimelineSeek}
|
onSeek={handleTimelineSeek}
|
||||||
/>
|
/>
|
||||||
@ -84,5 +148,5 @@ export function PreScatView() {
|
|||||||
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />
|
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,28 @@
|
|||||||
import type { ScatSegment } from './scatTypes'
|
import type { ScatSegment } from './scatTypes';
|
||||||
import { esiColor, sensColor, statusColor, esiLevel, scatAreas, scatDetailData } from './scatConstants'
|
import type { ApiZoneItem } from '../services/scatApi';
|
||||||
|
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
|
||||||
|
|
||||||
interface ScatLeftPanelProps {
|
interface ScatLeftPanelProps {
|
||||||
segments: ScatSegment[]
|
segments: ScatSegment[];
|
||||||
selectedSeg: ScatSegment
|
zones: ApiZoneItem[];
|
||||||
onSelectSeg: (s: ScatSegment) => void
|
selectedSeg: ScatSegment;
|
||||||
onOpenPopup: (idx: number) => void
|
onSelectSeg: (s: ScatSegment) => void;
|
||||||
jurisdictionFilter: string
|
onOpenPopup: (sn: number) => void;
|
||||||
onJurisdictionChange: (v: string) => void
|
jurisdictionFilter: string;
|
||||||
areaFilter: string
|
onJurisdictionChange: (v: string) => void;
|
||||||
onAreaChange: (v: string) => void
|
areaFilter: string;
|
||||||
phaseFilter: string
|
onAreaChange: (v: string) => void;
|
||||||
onPhaseChange: (v: string) => void
|
phaseFilter: string;
|
||||||
statusFilter: string
|
onPhaseChange: (v: string) => void;
|
||||||
onStatusChange: (v: string) => void
|
statusFilter: string;
|
||||||
searchTerm: string
|
onStatusChange: (v: string) => void;
|
||||||
onSearchChange: (v: string) => void
|
searchTerm: string;
|
||||||
|
onSearchChange: (v: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScatLeftPanel({
|
function ScatLeftPanel({
|
||||||
segments,
|
segments,
|
||||||
|
zones,
|
||||||
selectedSeg,
|
selectedSeg,
|
||||||
onSelectSeg,
|
onSelectSeg,
|
||||||
onOpenPopup,
|
onOpenPopup,
|
||||||
@ -34,12 +37,18 @@ function ScatLeftPanel({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
}: ScatLeftPanelProps) {
|
}: ScatLeftPanelProps) {
|
||||||
const filtered = segments.filter(s => {
|
const filtered = segments.filter((s) => {
|
||||||
if (areaFilter !== '전체' && !s.area.includes(areaFilter.replace('서귀포시 ', '').replace('제주시 ', '').replace(' 해안', ''))) return false
|
if (
|
||||||
if (statusFilter !== '전체' && s.status !== statusFilter) return false
|
areaFilter !== '전체' &&
|
||||||
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false
|
!s.area.includes(
|
||||||
return true
|
areaFilter.replace('서귀포시 ', '').replace('제주시 ', '').replace(' 해안', ''),
|
||||||
})
|
)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (statusFilter !== '전체' && s.status !== statusFilter) return false;
|
||||||
|
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden">
|
<div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden">
|
||||||
@ -51,8 +60,14 @@ function ScatLeftPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2.5">
|
<div className="mb-2.5">
|
||||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">관할 해경</label>
|
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
|
||||||
<select value={jurisdictionFilter} onChange={e => onJurisdictionChange(e.target.value)} className="prd-i w-full">
|
관할 해경
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={jurisdictionFilter}
|
||||||
|
onChange={(e) => onJurisdictionChange(e.target.value)}
|
||||||
|
className="prd-i w-full"
|
||||||
|
>
|
||||||
<option>전체 (제주도)</option>
|
<option>전체 (제주도)</option>
|
||||||
<option>서귀포해양경비안전서</option>
|
<option>서귀포해양경비안전서</option>
|
||||||
<option>제주해양경비안전서</option>
|
<option>제주해양경비안전서</option>
|
||||||
@ -60,18 +75,32 @@ function ScatLeftPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2.5">
|
<div className="mb-2.5">
|
||||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">해안 구역</label>
|
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
|
||||||
<select value={areaFilter} onChange={e => onAreaChange(e.target.value)} className="prd-i w-full">
|
해안 구역
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={areaFilter}
|
||||||
|
onChange={(e) => onAreaChange(e.target.value)}
|
||||||
|
className="prd-i w-full"
|
||||||
|
>
|
||||||
<option>전체</option>
|
<option>전체</option>
|
||||||
{scatAreas.map(a => (
|
{zones.map((z) => (
|
||||||
<option key={a.code}>{a.jurisdiction === '서귀포' ? '서귀포시' : '제주시'} {a.area} 해안</option>
|
<option key={z.zoneCd}>
|
||||||
|
{z.jrsdNm === '서귀포' ? '서귀포시' : '제주시'} {z.zoneNm} 해안
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2.5">
|
<div className="mb-2.5">
|
||||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">조사 단계</label>
|
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
|
||||||
<select value={phaseFilter} onChange={e => onPhaseChange(e.target.value)} className="prd-i w-full">
|
조사 단계
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={phaseFilter}
|
||||||
|
onChange={(e) => onPhaseChange(e.target.value)}
|
||||||
|
className="prd-i w-full"
|
||||||
|
>
|
||||||
<option>Pre-SCAT (사전조사)</option>
|
<option>Pre-SCAT (사전조사)</option>
|
||||||
<option>SCAT (사고 시 조사)</option>
|
<option>SCAT (사고 시 조사)</option>
|
||||||
<option>Post-SCAT (사후 확인)</option>
|
<option>Post-SCAT (사후 확인)</option>
|
||||||
@ -83,10 +112,14 @@ function ScatLeftPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="🔍 구간 검색..."
|
placeholder="🔍 구간 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="prd-i flex-1"
|
className="prd-i flex-1"
|
||||||
/>
|
/>
|
||||||
<select value={statusFilter} onChange={e => onStatusChange(e.target.value)} className="prd-i w-[70px]">
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => onStatusChange(e.target.value)}
|
||||||
|
className="prd-i w-[70px]"
|
||||||
|
>
|
||||||
<option>전체</option>
|
<option>전체</option>
|
||||||
<option>완료</option>
|
<option>완료</option>
|
||||||
<option>진행중</option>
|
<option>진행중</option>
|
||||||
@ -102,54 +135,83 @@ function ScatLeftPanel({
|
|||||||
<span className="w-[3px] h-2.5 bg-status-green rounded-sm" />
|
<span className="w-[3px] h-2.5 bg-status-green rounded-sm" />
|
||||||
해안 구간 목록
|
해안 구간 목록
|
||||||
</span>
|
</span>
|
||||||
<span className="text-primary-cyan font-mono text-[10px]">총 {filtered.length}개 구간</span>
|
<span className="text-primary-cyan font-mono text-[10px]">
|
||||||
|
총 {filtered.length}개 구간
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5">
|
<div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5">
|
||||||
{filtered.map(seg => {
|
{filtered.map((seg) => {
|
||||||
const lvl = esiLevel(seg.esiNum)
|
const lvl = esiLevel(seg.esiNum);
|
||||||
const borderColor = lvl === 'h' ? 'border-l-status-red' : lvl === 'm' ? 'border-l-status-orange' : 'border-l-status-green'
|
const borderColor =
|
||||||
const isSelected = selectedSeg.id === seg.id
|
lvl === 'h'
|
||||||
|
? 'border-l-status-red'
|
||||||
|
: lvl === 'm'
|
||||||
|
? 'border-l-status-orange'
|
||||||
|
: 'border-l-status-green';
|
||||||
|
const isSelected = selectedSeg.id === seg.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={seg.id}
|
key={seg.id}
|
||||||
onClick={() => { onSelectSeg(seg); onOpenPopup(seg.id % scatDetailData.length) }}
|
onClick={() => {
|
||||||
|
onSelectSeg(seg);
|
||||||
|
onOpenPopup(seg.id);
|
||||||
|
}}
|
||||||
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
|
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
|
||||||
isSelected ? 'border-status-green bg-[rgba(34,197,94,0.05)]' : 'hover:border-border-light hover:bg-bg-hover'
|
isSelected
|
||||||
|
? 'border-status-green bg-[rgba(34,197,94,0.05)]'
|
||||||
|
: 'hover:border-border-light hover:bg-bg-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
|
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
|
||||||
📍 {seg.code} {seg.area}
|
📍 {seg.code} {seg.area}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white" style={{ background: esiColor(seg.esiNum) }}>
|
<span
|
||||||
|
className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white"
|
||||||
|
style={{ background: esiColor(seg.esiNum) }}
|
||||||
|
>
|
||||||
ESI {seg.esi}
|
ESI {seg.esi}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||||
<div className="flex justify-between text-[11px]">
|
<div className="flex justify-between text-[11px]">
|
||||||
<span className="text-text-2 font-korean">유형</span>
|
<span className="text-text-2 font-korean">유형</span>
|
||||||
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.type}</span>
|
<span className="text-text-1 font-medium font-mono text-[11px]">
|
||||||
|
{seg.type}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-[11px]">
|
<div className="flex justify-between text-[11px]">
|
||||||
<span className="text-text-2 font-korean">길이</span>
|
<span className="text-text-2 font-korean">길이</span>
|
||||||
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.length}</span>
|
<span className="text-text-1 font-medium font-mono text-[11px]">
|
||||||
|
{seg.length}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-[11px]">
|
<div className="flex justify-between text-[11px]">
|
||||||
<span className="text-text-2 font-korean">민감</span>
|
<span className="text-text-2 font-korean">민감</span>
|
||||||
<span className="font-medium font-mono text-[11px]" style={{ color: sensColor[seg.sensitivity] }}>{seg.sensitivity}</span>
|
<span
|
||||||
|
className="font-medium font-mono text-[11px]"
|
||||||
|
style={{ color: sensColor[seg.sensitivity] }}
|
||||||
|
>
|
||||||
|
{seg.sensitivity}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-[11px]">
|
<div className="flex justify-between text-[11px]">
|
||||||
<span className="text-text-2 font-korean">현황</span>
|
<span className="text-text-2 font-korean">현황</span>
|
||||||
<span className="font-medium font-mono text-[11px]" style={{ color: statusColor[seg.status] }}>{seg.status}</span>
|
<span
|
||||||
|
className="font-medium font-mono text-[11px]"
|
||||||
|
style={{ color: statusColor[seg.status] }}
|
||||||
|
>
|
||||||
|
{seg.status}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ScatLeftPanel
|
export default ScatLeftPanel;
|
||||||
|
|||||||
@ -1,108 +1,106 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import L from 'leaflet'
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { ScatSegment } from './scatTypes'
|
import type { ScatSegment } from './scatTypes';
|
||||||
import { esiColor, jejuCoastCoords, scatDetailData } from './scatConstants'
|
import { esiColor, jejuCoastCoords } from './scatConstants';
|
||||||
|
|
||||||
interface ScatMapProps {
|
interface ScatMapProps {
|
||||||
segments: ScatSegment[]
|
segments: ScatSegment[];
|
||||||
selectedSeg: ScatSegment
|
selectedSeg: ScatSegment;
|
||||||
onSelectSeg: (s: ScatSegment) => void
|
onSelectSeg: (s: ScatSegment) => void;
|
||||||
onOpenPopup: (idx: number) => void
|
onOpenPopup: (sn: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScatMap({
|
function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapProps) {
|
||||||
segments,
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
selectedSeg,
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
onSelectSeg,
|
const markersRef = useRef<L.LayerGroup | null>(null);
|
||||||
onOpenPopup,
|
const [zoom, setZoom] = useState(10);
|
||||||
}: ScatMapProps) {
|
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const mapRef = useRef<L.Map | null>(null)
|
|
||||||
const markersRef = useRef<L.LayerGroup | null>(null)
|
|
||||||
const [zoom, setZoom] = useState(10)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapContainerRef.current || mapRef.current) return
|
if (!mapContainerRef.current || mapRef.current) return;
|
||||||
|
|
||||||
const map = L.map(mapContainerRef.current, {
|
const map = L.map(mapContainerRef.current, {
|
||||||
center: [33.38, 126.55],
|
center: [33.38, 126.55],
|
||||||
zoom: 10,
|
zoom: 10,
|
||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
}).addTo(map)
|
}).addTo(map);
|
||||||
|
|
||||||
L.control.zoom({ position: 'bottomright' }).addTo(map)
|
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||||
L.control.attribution({ position: 'bottomleft' }).addAttribution(
|
L.control
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>'
|
.attribution({ position: 'bottomleft' })
|
||||||
).addTo(map)
|
.addAttribution(
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
|
)
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
map.on('zoomend', () => setZoom(map.getZoom()))
|
map.on('zoomend', () => setZoom(map.getZoom()));
|
||||||
|
|
||||||
mapRef.current = map
|
mapRef.current = map;
|
||||||
markersRef.current = L.layerGroup().addTo(map)
|
markersRef.current = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
setTimeout(() => map.invalidateSize(), 100)
|
setTimeout(() => map.invalidateSize(), 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
map.remove()
|
map.remove();
|
||||||
mapRef.current = null
|
mapRef.current = null;
|
||||||
markersRef.current = null
|
markersRef.current = null;
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || !markersRef.current) return
|
if (!mapRef.current || !markersRef.current) return;
|
||||||
markersRef.current.clearLayers()
|
markersRef.current.clearLayers();
|
||||||
|
|
||||||
// 줌 기반 스케일 계수 (zoom 10=아일랜드뷰 → 14+=클로즈업)
|
// 줌 기반 스케일 계수 (zoom 10=아일랜드뷰 → 14+=클로즈업)
|
||||||
const zScale = Math.max(0, (zoom - 9)) / 5 // 0 at z9, 1 at z14
|
const zScale = Math.max(0, zoom - 9) / 5; // 0 at z9, 1 at z14
|
||||||
const polyWeight = 1 + zScale * 4 // 1 ~ 5
|
const polyWeight = 1 + zScale * 4; // 1 ~ 5
|
||||||
const selPolyWeight = 2 + zScale * 5 // 2 ~ 7
|
const selPolyWeight = 2 + zScale * 5; // 2 ~ 7
|
||||||
const glowWeight = 4 + zScale * 14 // 4 ~ 18
|
const glowWeight = 4 + zScale * 14; // 4 ~ 18
|
||||||
const halfLenScale = 0.15 + zScale * 0.85 // 0.15 ~ 1.0
|
const halfLenScale = 0.15 + zScale * 0.85; // 0.15 ~ 1.0
|
||||||
const markerSize = Math.round(6 + zScale * 16) // 6px ~ 22px
|
const markerSize = Math.round(6 + zScale * 16); // 6px ~ 22px
|
||||||
const markerBorder = zoom >= 13 ? 2 : 1
|
const markerBorder = zoom >= 13 ? 2 : 1;
|
||||||
const markerFontSize = Math.round(4 + zScale * 6) // 4px ~ 10px
|
const markerFontSize = Math.round(4 + zScale * 6); // 4px ~ 10px
|
||||||
const showStatusMarker = zoom >= 11
|
const showStatusMarker = zoom >= 11;
|
||||||
const showStatusText = zoom >= 13
|
const showStatusText = zoom >= 13;
|
||||||
|
|
||||||
// 제주도 해안선 레퍼런스 라인
|
// 제주도 해안선 레퍼런스 라인
|
||||||
const coastline = L.polyline(jejuCoastCoords as [number, number][], {
|
const coastline = L.polyline(jejuCoastCoords as [number, number][], {
|
||||||
color: 'rgba(6, 182, 212, 0.18)',
|
color: 'rgba(6, 182, 212, 0.18)',
|
||||||
weight: 1.5,
|
weight: 1.5,
|
||||||
dashArray: '8, 6',
|
dashArray: '8, 6',
|
||||||
})
|
});
|
||||||
markersRef.current.addLayer(coastline)
|
markersRef.current.addLayer(coastline);
|
||||||
|
|
||||||
segments.forEach(seg => {
|
segments.forEach((seg, segIdx) => {
|
||||||
const isSelected = selectedSeg.id === seg.id
|
const isSelected = selectedSeg.id === seg.id;
|
||||||
const color = esiColor(seg.esiNum)
|
const color = esiColor(seg.esiNum);
|
||||||
|
|
||||||
// 해안선 방향 계산 (세그먼트 폴리라인 각도 결정)
|
// 해안선 방향 계산 (세그먼트 폴리라인 각도 결정)
|
||||||
const coastIdx = seg.id % (jejuCoastCoords.length - 1)
|
const coastIdx = segIdx % (jejuCoastCoords.length - 1);
|
||||||
const [clat1, clng1] = jejuCoastCoords[coastIdx]
|
const [clat1, clng1] = jejuCoastCoords[coastIdx];
|
||||||
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
|
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length];
|
||||||
|
|
||||||
const dlat = clat2 - clat1
|
const dlat = clat2 - clat1;
|
||||||
const dlng = clng2 - clng1
|
const dlng = clng2 - clng1;
|
||||||
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
|
const dist = Math.sqrt(dlat * dlat + dlng * dlng);
|
||||||
const nDlat = dist > 0 ? dlat / dist : 0
|
const nDlat = dist > 0 ? dlat / dist : 0;
|
||||||
const nDlng = dist > 0 ? dlng / dist : 1
|
const nDlng = dist > 0 ? dlng / dist : 1;
|
||||||
|
|
||||||
// 구간 길이를 위경도 단위로 변환 (줌 레벨에 따라 스케일링)
|
// 구간 길이를 위경도 단위로 변환 (줌 레벨에 따라 스케일링)
|
||||||
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale
|
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale;
|
||||||
|
|
||||||
// 해안선 방향을 따라 폴리라인 좌표 생성
|
// 해안선 방향을 따라 폴리라인 좌표 생성
|
||||||
const segCoords: [number, number][] = [
|
const segCoords: [number, number][] = [
|
||||||
[seg.lat - nDlat * halfLen, seg.lng - nDlng * halfLen],
|
[seg.lat - nDlat * halfLen, seg.lng - nDlng * halfLen],
|
||||||
[seg.lat, seg.lng],
|
[seg.lat, seg.lng],
|
||||||
[seg.lat + nDlat * halfLen, seg.lng + nDlng * halfLen],
|
[seg.lat + nDlat * halfLen, seg.lng + nDlng * halfLen],
|
||||||
]
|
];
|
||||||
|
|
||||||
// 선택된 구간 글로우 효과
|
// 선택된 구간 글로우 효과
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@ -111,8 +109,8 @@ function ScatMap({
|
|||||||
weight: glowWeight,
|
weight: glowWeight,
|
||||||
opacity: 0.15,
|
opacity: 0.15,
|
||||||
lineCap: 'round',
|
lineCap: 'round',
|
||||||
})
|
});
|
||||||
markersRef.current!.addLayer(glow)
|
markersRef.current!.addLayer(glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESI 색상 구간 폴리라인
|
// ESI 색상 구간 폴리라인
|
||||||
@ -122,9 +120,9 @@ function ScatMap({
|
|||||||
opacity: isSelected ? 0.95 : 0.7,
|
opacity: isSelected ? 0.95 : 0.7,
|
||||||
lineCap: 'round',
|
lineCap: 'round',
|
||||||
lineJoin: 'round',
|
lineJoin: 'round',
|
||||||
})
|
});
|
||||||
|
|
||||||
const statusIcon = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'
|
const statusIcon = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—';
|
||||||
|
|
||||||
polyline.bindTooltip(
|
polyline.bindTooltip(
|
||||||
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
|
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
|
||||||
@ -136,21 +134,27 @@ function ScatMap({
|
|||||||
direction: 'top',
|
direction: 'top',
|
||||||
offset: [0, -10],
|
offset: [0, -10],
|
||||||
className: 'scat-map-tooltip',
|
className: 'scat-map-tooltip',
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
polyline.on('click', () => {
|
polyline.on('click', () => {
|
||||||
onSelectSeg(seg)
|
onSelectSeg(seg);
|
||||||
onOpenPopup(seg.id % scatDetailData.length)
|
onOpenPopup(seg.id);
|
||||||
})
|
});
|
||||||
markersRef.current!.addLayer(polyline)
|
markersRef.current!.addLayer(polyline);
|
||||||
|
|
||||||
// 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절
|
// 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절
|
||||||
if (showStatusMarker) {
|
if (showStatusMarker) {
|
||||||
const stColor = seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b'
|
const stColor =
|
||||||
const stBg = seg.status === '완료' ? 'rgba(34,197,94,0.2)' : seg.status === '진행중' ? 'rgba(234,179,8,0.2)' : 'rgba(100,116,139,0.2)'
|
seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b';
|
||||||
const stText = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'
|
const stBg =
|
||||||
const half = Math.round(markerSize / 2)
|
seg.status === '완료'
|
||||||
|
? 'rgba(34,197,94,0.2)'
|
||||||
|
: seg.status === '진행중'
|
||||||
|
? 'rgba(234,179,8,0.2)'
|
||||||
|
: 'rgba(100,116,139,0.2)';
|
||||||
|
const stText = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—';
|
||||||
|
const half = Math.round(markerSize / 2);
|
||||||
|
|
||||||
const statusMarker = L.marker([seg.lat, seg.lng], {
|
const statusMarker = L.marker([seg.lat, seg.lng], {
|
||||||
icon: L.divIcon({
|
icon: L.divIcon({
|
||||||
@ -158,30 +162,32 @@ function ScatMap({
|
|||||||
html: `<div style="width:${markerSize}px;height:${markerSize}px;border-radius:50%;background:${stBg};border:${markerBorder}px solid ${stColor};display:flex;align-items:center;justify-content:center;font-size:${markerFontSize}px;color:${stColor};transform:translate(-${half}px,-${half}px);backdrop-filter:blur(4px);box-shadow:0 0 ${Math.round(markerSize / 3)}px ${stBg}">${showStatusText ? stText : ''}</div>`,
|
html: `<div style="width:${markerSize}px;height:${markerSize}px;border-radius:50%;background:${stBg};border:${markerBorder}px solid ${stColor};display:flex;align-items:center;justify-content:center;font-size:${markerFontSize}px;color:${stColor};transform:translate(-${half}px,-${half}px);backdrop-filter:blur(4px);box-shadow:0 0 ${Math.round(markerSize / 3)}px ${stBg}">${showStatusText ? stText : ''}</div>`,
|
||||||
iconSize: [0, 0],
|
iconSize: [0, 0],
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
statusMarker.on('click', () => {
|
statusMarker.on('click', () => {
|
||||||
onSelectSeg(seg)
|
onSelectSeg(seg);
|
||||||
onOpenPopup(seg.id % scatDetailData.length)
|
onOpenPopup(seg.id);
|
||||||
})
|
});
|
||||||
markersRef.current!.addLayer(statusMarker)
|
markersRef.current!.addLayer(statusMarker);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom])
|
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current) return
|
if (!mapRef.current) return;
|
||||||
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 })
|
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 });
|
||||||
}, [selectedSeg])
|
}, [selectedSeg]);
|
||||||
|
|
||||||
const doneCount = segments.filter(s => s.status === '완료').length
|
const doneCount = segments.filter((s) => s.status === '완료').length;
|
||||||
const progCount = segments.filter(s => s.status === '진행중').length
|
const progCount = segments.filter((s) => s.status === '진행중').length;
|
||||||
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
|
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0);
|
||||||
const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0)
|
const doneLen = segments.filter((s) => s.status === '완료').reduce((a, s) => a + s.lengthM, 0);
|
||||||
const highSens = segments.filter(s => s.sensitivity === '최상' || s.sensitivity === '상').reduce((a, s) => a + s.lengthM, 0)
|
const highSens = segments
|
||||||
const donePct = Math.round(doneCount / segments.length * 100)
|
.filter((s) => s.sensitivity === '최상' || s.sensitivity === '상')
|
||||||
const progPct = Math.round(progCount / segments.length * 100)
|
.reduce((a, s) => a + s.lengthM, 0);
|
||||||
const notPct = 100 - donePct - progPct
|
const donePct = segments.length > 0 ? Math.round((doneCount / segments.length) * 100) : 0;
|
||||||
|
const progPct = segments.length > 0 ? Math.round((progCount / segments.length) * 100) : 0;
|
||||||
|
const notPct = 100 - donePct - progPct;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
@ -215,7 +221,9 @@ function ScatMap({
|
|||||||
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
|
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
|
||||||
{/* ESI Legend */}
|
{/* ESI Legend */}
|
||||||
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">ESI 민감도 분류 범례</div>
|
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">
|
||||||
|
ESI 민감도 분류 범례
|
||||||
|
</div>
|
||||||
{[
|
{[
|
||||||
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
|
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
|
||||||
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
|
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
|
||||||
@ -227,7 +235,10 @@ function ScatMap({
|
|||||||
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
|
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
|
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
|
||||||
<span className="w-3.5 h-1.5 rounded-sm flex-shrink-0" style={{ background: item.color }} />
|
<span
|
||||||
|
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
|
||||||
|
style={{ background: item.color }}
|
||||||
|
/>
|
||||||
<span className="text-text-2 font-korean">{item.label}</span>
|
<span className="text-text-2 font-korean">{item.label}</span>
|
||||||
<span className="ml-auto font-mono text-[10px] text-text-1">{item.esi}</span>
|
<span className="ml-auto font-mono text-[10px] text-text-1">{item.esi}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -236,15 +247,30 @@ function ScatMap({
|
|||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
||||||
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">조사 진행률</div>
|
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">
|
||||||
|
조사 진행률
|
||||||
|
</div>
|
||||||
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
|
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
|
||||||
<div className="h-full transition-all duration-500" style={{ width: `${donePct}%`, background: 'var(--green)' }} />
|
<div
|
||||||
<div className="h-full transition-all duration-500" style={{ width: `${progPct}%`, background: 'var(--orange)' }} />
|
className="h-full transition-all duration-500"
|
||||||
<div className="h-full transition-all duration-500" style={{ width: `${notPct}%`, background: 'var(--bd)' }} />
|
style={{ width: `${donePct}%`, background: 'var(--green)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full transition-all duration-500"
|
||||||
|
style={{ width: `${progPct}%`, background: 'var(--orange)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full transition-all duration-500"
|
||||||
|
style={{ width: `${notPct}%`, background: 'var(--bd)' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-1">
|
<div className="flex justify-between mt-1">
|
||||||
<span className="text-[9px] font-mono" style={{ color: 'var(--green)' }}>완료 {donePct}%</span>
|
<span className="text-[9px] font-mono" style={{ color: 'var(--green)' }}>
|
||||||
<span className="text-[9px] font-mono" style={{ color: 'var(--orange)' }}>진행 {progPct}%</span>
|
완료 {donePct}%
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-mono" style={{ color: 'var(--orange)' }}>
|
||||||
|
진행 {progPct}%
|
||||||
|
</span>
|
||||||
<span className="text-[9px] font-mono text-text-3">미조사 {notPct}%</span>
|
<span className="text-[9px] font-mono text-text-3">미조사 {notPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2.5">
|
<div className="mt-2.5">
|
||||||
@ -252,11 +278,23 @@ function ScatMap({
|
|||||||
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
||||||
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'],
|
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'],
|
||||||
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--red)'],
|
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--red)'],
|
||||||
['방제 우선 구간', `${segments.filter(s => s.sensitivity === '최상').length}개`, 'var(--orange)'],
|
[
|
||||||
|
'방제 우선 구간',
|
||||||
|
`${segments.filter((s) => s.sensitivity === '최상').length}개`,
|
||||||
|
'var(--orange)',
|
||||||
|
],
|
||||||
].map(([label, val, color], i) => (
|
].map(([label, val, color], i) => (
|
||||||
<div key={i} className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]"
|
||||||
|
>
|
||||||
<span className="text-text-2 font-korean">{label}</span>
|
<span className="text-text-2 font-korean">{label}</span>
|
||||||
<span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>{val}</span>
|
<span
|
||||||
|
className="font-mono font-medium text-[11px]"
|
||||||
|
style={{ color: color || undefined }}
|
||||||
|
>
|
||||||
|
{val}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -265,12 +303,18 @@ function ScatMap({
|
|||||||
|
|
||||||
{/* Coordinates */}
|
{/* Coordinates */}
|
||||||
<div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-border rounded-sm px-3 py-1.5 font-mono text-[11px] text-text-2 flex gap-3.5">
|
<div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-border rounded-sm px-3 py-1.5 font-mono text-[11px] text-text-2 flex gap-3.5">
|
||||||
<span>위도 <span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</span></span>
|
<span>
|
||||||
<span>경도 <span className="text-status-green font-medium">{selectedSeg.lng.toFixed(4)}°E</span></span>
|
위도 <span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
|
||||||
<span>축척 <span className="text-status-green font-medium">1:25,000</span></span>
|
</span>
|
||||||
|
<span>
|
||||||
|
경도 <span className="text-status-green font-medium">{selectedSeg.lng.toFixed(4)}°E</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
축척 <span className="text-status-green font-medium">1:25,000</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ScatMap
|
export default ScatMap;
|
||||||
|
|||||||
@ -1,387 +1,104 @@
|
|||||||
import type { ScatSegment, ScatDetail } from './scatTypes'
|
|
||||||
|
|
||||||
// ═══ ESI 색상 ═══
|
// ═══ ESI 색상 ═══
|
||||||
|
|
||||||
export const esiColor = (n: number): string => {
|
export const esiColor = (n: number): string => {
|
||||||
if (n >= 10) return '#991b1b'
|
if (n >= 10) return '#991b1b';
|
||||||
if (n >= 9) return '#b91c1c'
|
if (n >= 9) return '#b91c1c';
|
||||||
if (n >= 8) return '#dc2626'
|
if (n >= 8) return '#dc2626';
|
||||||
if (n >= 7) return '#ef4444'
|
if (n >= 7) return '#ef4444';
|
||||||
if (n >= 6) return '#f97316'
|
if (n >= 6) return '#f97316';
|
||||||
if (n >= 5) return '#fb923c'
|
if (n >= 5) return '#fb923c';
|
||||||
if (n >= 4) return '#facc15'
|
if (n >= 4) return '#facc15';
|
||||||
if (n >= 3) return '#a3e635'
|
if (n >= 3) return '#a3e635';
|
||||||
if (n >= 2) return '#22c55e'
|
if (n >= 2) return '#22c55e';
|
||||||
return '#4ade80'
|
return '#4ade80';
|
||||||
}
|
};
|
||||||
|
|
||||||
export const sensColor: Record<string, string> = { '최상': 'var(--red)', '상': 'var(--red)', '중': 'var(--orange)', '하': 'var(--green)' }
|
export const sensColor: Record<string, string> = {
|
||||||
export const statusColor: Record<string, string> = { '완료': 'var(--green)', '진행중': 'var(--orange)', '미조사': 'var(--t3)' }
|
최상: 'var(--red)',
|
||||||
export const esiLevel = (n: number) => n >= 8 ? 'h' : n >= 5 ? 'm' : 'l'
|
상: 'var(--red)',
|
||||||
|
중: 'var(--orange)',
|
||||||
|
하: 'var(--green)',
|
||||||
|
};
|
||||||
|
export const statusColor: Record<string, string> = {
|
||||||
|
완료: 'var(--green)',
|
||||||
|
진행중: 'var(--orange)',
|
||||||
|
미조사: 'var(--t3)',
|
||||||
|
};
|
||||||
|
export const esiLevel = (n: number) => (n >= 8 ? 'h' : n >= 5 ? 'm' : 'l');
|
||||||
|
|
||||||
// ═══ Mock Data ═══
|
// ═══ 제주도 해안선 좌표 (시계방향) ═══
|
||||||
|
|
||||||
// --- 서귀포시 (서귀포해양경비안전서 관할) ---
|
|
||||||
export const sgAreas = [
|
|
||||||
{ area: '성산읍', code: 'SGSS', cnt: 99, villages: ['시흥리', '오조리', '성산리', '고성리', '온평리', '신산리', '삼달리', '신풍리', '신천리'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '표선면', code: 'SGPS', cnt: 41, villages: ['하천리', '표선리', '세화리'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '남원읍', code: 'SGNW', cnt: 73, villages: ['신흥리', '태흥리', '남원리', '위미리', '신례리'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '하효동·보목동', code: 'SGHY', cnt: 8, villages: ['하효동', '보목동'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '토평동·동흥동', code: 'SGTP', cnt: 12, villages: ['토평동', '동흥동'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '서귀동·서홍동', code: 'SGSG', cnt: 20, villages: ['서귀동', '서홍동'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '호근동·법환동', code: 'SGHG', cnt: 6, villages: ['호근동', '서호동', '법환동'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '강정동', code: 'SGGJ', cnt: 21, villages: ['강정동'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '월평동·대포동', code: 'SGWP', cnt: 4, villages: ['월평동', '하원동', '대포동'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '중문동', code: 'SGJM', cnt: 8, villages: ['중문동'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '색달동·하예동', code: 'SGSE', cnt: 8, villages: ['색달동', '하예동'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '안덕면', code: 'SGAD', cnt: 38, villages: ['감산리', '사계리', '덕수리', '창천리', '대평리', '화순리'], jurisdiction: '서귀포' },
|
|
||||||
{ area: '대정읍', code: 'SGDJ', cnt: 79, villages: ['상모리', '하모리', '영락리', '인성리', '보성리', '무릉리', '신도리'], jurisdiction: '서귀포' },
|
|
||||||
]
|
|
||||||
// --- 제주시 (제주해양경비안전서 관할) ---
|
|
||||||
export const jjAreas = [
|
|
||||||
{ area: '한경면', code: 'JJHG', cnt: 81, villages: ['고산리', '금등리', '두모리', '신창리', '용수리', '판포리'], jurisdiction: '제주' },
|
|
||||||
{ area: '한림읍', code: 'JJHL', cnt: 87, villages: ['귀덕리', '금능리', '수원리', '옹포리', '월령리', '한림리', '한수리', '협재리'], jurisdiction: '제주' },
|
|
||||||
{ area: '애월읍', code: 'JJAW', cnt: 89, villages: ['고내리', '곽지리', '구엄리', '금성리', '신엄리', '애월리', '하귀1리', '하귀2리'], jurisdiction: '제주' },
|
|
||||||
{ area: '외도이동', code: 'JJOD', cnt: 19, villages: ['외도이동'], jurisdiction: '제주' },
|
|
||||||
{ area: '내도동', code: 'JJND', cnt: 7, villages: ['내도동'], jurisdiction: '제주' },
|
|
||||||
{ area: '이호일동', code: 'JJIH', cnt: 20, villages: ['이호일동'], jurisdiction: '제주' },
|
|
||||||
{ area: '도두동', code: 'JJDD', cnt: 17, villages: ['도두일동', '도두이동'], jurisdiction: '제주' },
|
|
||||||
{ area: '용담동', code: 'JJYD', cnt: 19, villages: ['용담삼동', '용담이동', '용담일동'], jurisdiction: '제주' },
|
|
||||||
{ area: '삼도이동', code: 'JJSD', cnt: 2, villages: ['삼도2동'], jurisdiction: '제주' },
|
|
||||||
{ area: '건입동', code: 'JJGI', cnt: 26, villages: ['건입동'], jurisdiction: '제주' },
|
|
||||||
{ area: '화북일동', code: 'JJHB', cnt: 23, villages: ['화북일동'], jurisdiction: '제주' },
|
|
||||||
{ area: '삼양삼동', code: 'JJYN', cnt: 19, villages: ['삼양삼동', '삼양이동', '삼양일동'], jurisdiction: '제주' },
|
|
||||||
{ area: '삼양일동', code: 'JJSY', cnt: 24, villages: ['삼양이동', '삼양일동'], jurisdiction: '제주' },
|
|
||||||
{ area: '조천읍', code: 'JJJC', cnt: 95, villages: ['북촌리', '신촌리', '신흥리', '조천리', '함덕리'], jurisdiction: '제주' },
|
|
||||||
{ area: '구좌읍', code: 'JJGJ', cnt: 147, villages: ['김녕리', '동복리', '상도리', '월정리', '종달리', '평대리', '하도리', '한동리', '행원리'], jurisdiction: '제주' },
|
|
||||||
]
|
|
||||||
export const scatAreas = [...sgAreas, ...jjAreas]
|
|
||||||
|
|
||||||
export const scatSubstrates = ['투과성 인공호안', '수직호안', '모래', '모래자갈혼합', '자갈·왕자갈', '수평암반', '수직암반']
|
|
||||||
export const substrateESI: Record<string, { esi: string; n: number }> = {
|
|
||||||
'투과성 인공호안': { esi: '6B', n: 6 }, '수직호안': { esi: '1B', n: 1 },
|
|
||||||
'모래': { esi: '3A', n: 3 }, '모래자갈혼합': { esi: '5', n: 5 },
|
|
||||||
'자갈·왕자갈': { esi: '6A', n: 6 }, '수평암반': { esi: '8A', n: 8 }, '수직암반': { esi: '1A', n: 1 },
|
|
||||||
}
|
|
||||||
export const scatTagSets = [['🦪 양식장'], ['🏖 해수욕장'], ['⛵ 항구'], ['🪸 산호'], ['🌿 보호구역'], ['🐢 생태보전'], ['🏛 문화재'], ['⛰ 해안절벽'], ['🔧 인공구조물'], ['🌊 올레길']]
|
|
||||||
const sensFromESI = (n: number): ScatSegment['sensitivity'] => n >= 9 ? '최상' : n >= 7 ? '상' : n >= 5 ? '중' : '하'
|
|
||||||
const statusArr: ScatSegment['status'][] = ['완료', '완료', '완료', '완료', '진행중', '미조사']
|
|
||||||
|
|
||||||
// 지역별 좌표 범위 (제주도 전체 해안)
|
|
||||||
export const areaCoords: Record<string, { latC: number; lngC: number; latR: number; lngR: number }> = {
|
|
||||||
// 서귀포시 (남부 해안)
|
|
||||||
SGSS: { latC: 33.39, lngC: 126.89, latR: 0.07, lngR: 0.05 },
|
|
||||||
SGPS: { latC: 33.33, lngC: 126.81, latR: 0.03, lngR: 0.04 },
|
|
||||||
SGNW: { latC: 33.26, lngC: 126.63, latR: 0.02, lngR: 0.05 },
|
|
||||||
SGHY: { latC: 33.245, lngC: 126.59, latR: 0.005, lngR: 0.02 },
|
|
||||||
SGTP: { latC: 33.245, lngC: 126.555, latR: 0.005, lngR: 0.015 },
|
|
||||||
SGSG: { latC: 33.245, lngC: 126.53, latR: 0.005, lngR: 0.015 },
|
|
||||||
SGHG: { latC: 33.245, lngC: 126.50, latR: 0.005, lngR: 0.02 },
|
|
||||||
SGGJ: { latC: 33.245, lngC: 126.45, latR: 0.005, lngR: 0.03 },
|
|
||||||
SGWP: { latC: 33.245, lngC: 126.40, latR: 0.005, lngR: 0.02 },
|
|
||||||
SGJM: { latC: 33.245, lngC: 126.37, latR: 0.005, lngR: 0.015 },
|
|
||||||
SGSE: { latC: 33.245, lngC: 126.34, latR: 0.005, lngR: 0.015 },
|
|
||||||
SGAD: { latC: 33.24, lngC: 126.29, latR: 0.01, lngR: 0.035 },
|
|
||||||
SGDJ: { latC: 33.25, lngC: 126.21, latR: 0.035, lngR: 0.05 },
|
|
||||||
// 제주시 (북부 해안)
|
|
||||||
JJHG: { latC: 33.31, lngC: 126.19, latR: 0.04, lngR: 0.04 },
|
|
||||||
JJHL: { latC: 33.39, lngC: 126.26, latR: 0.04, lngR: 0.05 },
|
|
||||||
JJAW: { latC: 33.46, lngC: 126.35, latR: 0.04, lngR: 0.06 },
|
|
||||||
JJOD: { latC: 33.505, lngC: 126.43, latR: 0.005, lngR: 0.015 },
|
|
||||||
JJND: { latC: 33.505, lngC: 126.44, latR: 0.003, lngR: 0.008 },
|
|
||||||
JJIH: { latC: 33.50, lngC: 126.46, latR: 0.005, lngR: 0.012 },
|
|
||||||
JJDD: { latC: 33.51, lngC: 126.49, latR: 0.005, lngR: 0.012 },
|
|
||||||
JJYD: { latC: 33.515, lngC: 126.52, latR: 0.005, lngR: 0.015 },
|
|
||||||
JJSD: { latC: 33.515, lngC: 126.525, latR: 0.003, lngR: 0.005 },
|
|
||||||
JJGI: { latC: 33.52, lngC: 126.545, latR: 0.005, lngR: 0.015 },
|
|
||||||
JJHB: { latC: 33.52, lngC: 126.565, latR: 0.005, lngR: 0.012 },
|
|
||||||
JJYN: { latC: 33.52, lngC: 126.585, latR: 0.005, lngR: 0.012 },
|
|
||||||
JJSY: { latC: 33.52, lngC: 126.59, latR: 0.005, lngR: 0.012 },
|
|
||||||
JJJC: { latC: 33.535, lngC: 126.64, latR: 0.015, lngR: 0.04 },
|
|
||||||
JJGJ: { latC: 33.53, lngC: 126.78, latR: 0.03, lngR: 0.10 },
|
|
||||||
}
|
|
||||||
|
|
||||||
// 제주도 전체 해안선 좌표 (시계방향: 대정읍→서귀포→성산→조천→구좌→한경)
|
|
||||||
export const jejuCoastCoords: [number, number][] = [
|
export const jejuCoastCoords: [number, number][] = [
|
||||||
// 서부 (대정읍~한경면)
|
// 서부 (대정읍~한경면)
|
||||||
[33.2800, 126.1600], [33.2600, 126.1800], [33.2400, 126.2000],
|
[33.28, 126.16],
|
||||||
|
[33.26, 126.18],
|
||||||
|
[33.24, 126.2],
|
||||||
// 남부 (서귀포시 해안: 대정→안덕→중문→강정→서귀→남원→표선→성산)
|
// 남부 (서귀포시 해안: 대정→안덕→중문→강정→서귀→남원→표선→성산)
|
||||||
[33.2300, 126.2300], [33.2350, 126.2600], [33.2400, 126.2900], [33.2450, 126.3200],
|
[33.23, 126.23],
|
||||||
[33.2470, 126.3500], [33.2460, 126.3700], [33.2450, 126.4000], [33.2440, 126.4300],
|
[33.235, 126.26],
|
||||||
[33.2430, 126.4600], [33.2420, 126.4900], [33.2410, 126.5100], [33.2400, 126.5300],
|
[33.24, 126.29],
|
||||||
[33.2400, 126.5500], [33.2410, 126.5700], [33.2430, 126.5900], [33.2450, 126.6200],
|
[33.245, 126.32],
|
||||||
[33.2500, 126.6600], [33.2600, 126.7000], [33.2800, 126.7400], [33.3100, 126.7800],
|
[33.247, 126.35],
|
||||||
[33.3300, 126.8200], [33.3600, 126.8400], [33.3900, 126.8600], [33.4200, 126.8800],
|
[33.246, 126.37],
|
||||||
[33.4400, 126.9000], [33.4530, 126.9100], [33.4580, 126.9200], [33.4610, 126.9310],
|
[33.245, 126.4],
|
||||||
|
[33.244, 126.43],
|
||||||
|
[33.243, 126.46],
|
||||||
|
[33.242, 126.49],
|
||||||
|
[33.241, 126.51],
|
||||||
|
[33.24, 126.53],
|
||||||
|
[33.24, 126.55],
|
||||||
|
[33.241, 126.57],
|
||||||
|
[33.243, 126.59],
|
||||||
|
[33.245, 126.62],
|
||||||
|
[33.25, 126.66],
|
||||||
|
[33.26, 126.7],
|
||||||
|
[33.28, 126.74],
|
||||||
|
[33.31, 126.78],
|
||||||
|
[33.33, 126.82],
|
||||||
|
[33.36, 126.84],
|
||||||
|
[33.39, 126.86],
|
||||||
|
[33.42, 126.88],
|
||||||
|
[33.44, 126.9],
|
||||||
|
[33.453, 126.91],
|
||||||
|
[33.458, 126.92],
|
||||||
|
[33.461, 126.931],
|
||||||
// 동부 (성산~구좌)
|
// 동부 (성산~구좌)
|
||||||
[33.4700, 126.9200], [33.4900, 126.9100], [33.5100, 126.8700],
|
[33.47, 126.92],
|
||||||
[33.5200, 126.8500], [33.5350, 126.8200], [33.5450, 126.7900],
|
[33.49, 126.91],
|
||||||
|
[33.51, 126.87],
|
||||||
|
[33.52, 126.85],
|
||||||
|
[33.535, 126.82],
|
||||||
|
[33.545, 126.79],
|
||||||
// 북부 (제주시 해안: 구좌→조천→건입→이호→애월→한림→한경)
|
// 북부 (제주시 해안: 구좌→조천→건입→이호→애월→한림→한경)
|
||||||
[33.5500, 126.7600], [33.5500, 126.7300], [33.5450, 126.7000],
|
[33.55, 126.76],
|
||||||
[33.5400, 126.6800], [33.5350, 126.6600], [33.5300, 126.6400], [33.5250, 126.6200],
|
[33.55, 126.73],
|
||||||
[33.5200, 126.6000], [33.5200, 126.5800], [33.5200, 126.5600], [33.5180, 126.5400],
|
[33.545, 126.7],
|
||||||
[33.5160, 126.5200], [33.5140, 126.5000], [33.5120, 126.4800], [33.5100, 126.4600],
|
[33.54, 126.68],
|
||||||
[33.5050, 126.4400], [33.5000, 126.4200], [33.4950, 126.4000], [33.4850, 126.3800],
|
[33.535, 126.66],
|
||||||
[33.4700, 126.3500], [33.4550, 126.3300], [33.4400, 126.3100], [33.4200, 126.2900],
|
[33.53, 126.64],
|
||||||
[33.4000, 126.2700], [33.3800, 126.2500], [33.3600, 126.2350], [33.3400, 126.2200],
|
[33.525, 126.62],
|
||||||
[33.3200, 126.2050], [33.3100, 126.1900], [33.3000, 126.1750], [33.2930, 126.1620],
|
[33.52, 126.6],
|
||||||
]
|
[33.52, 126.58],
|
||||||
|
[33.52, 126.56],
|
||||||
function seededRandom(seed: number) {
|
[33.518, 126.54],
|
||||||
const x = Math.sin(seed) * 10000
|
[33.516, 126.52],
|
||||||
return x - Math.floor(x)
|
[33.514, 126.5],
|
||||||
}
|
[33.512, 126.48],
|
||||||
|
[33.51, 126.46],
|
||||||
const generateSegments = (): ScatSegment[] => {
|
[33.505, 126.44],
|
||||||
const segs: ScatSegment[] = []
|
[33.5, 126.42],
|
||||||
let idx = 0
|
[33.495, 126.4],
|
||||||
scatAreas.forEach(a => {
|
[33.485, 126.38],
|
||||||
const ac = areaCoords[a.code]
|
[33.47, 126.35],
|
||||||
for (let i = 0; i < a.cnt; i++) {
|
[33.455, 126.33],
|
||||||
const seed = idx * 137 + 42
|
[33.44, 126.31],
|
||||||
const village = a.villages[Math.floor(seededRandom(seed) * a.villages.length)]
|
[33.42, 126.29],
|
||||||
const substrate = scatSubstrates[Math.floor(seededRandom(seed + 1) * scatSubstrates.length)]
|
[33.4, 126.27],
|
||||||
const { esi: esiStr, n: esiNum } = substrateESI[substrate]
|
[33.38, 126.25],
|
||||||
const lengthM = Math.floor(seededRandom(seed + 3) * 900) + 100
|
[33.36, 126.235],
|
||||||
// 지역 좌표 범위 내 분포
|
[33.34, 126.22],
|
||||||
const progress = a.cnt > 1 ? i / (a.cnt - 1) : 0.5
|
[33.32, 126.205],
|
||||||
const lat = ac.latC + (progress - 0.5) * ac.latR * 2 + (seededRandom(seed + 6) - 0.5) * 0.003
|
[33.31, 126.19],
|
||||||
const lng = ac.lngC + (progress - 0.5) * ac.lngR * 2 + (seededRandom(seed + 7) - 0.5) * 0.003
|
[33.3, 126.175],
|
||||||
segs.push({
|
[33.293, 126.162],
|
||||||
id: idx,
|
];
|
||||||
code: `${a.code}-${i + 1}`,
|
|
||||||
area: a.area,
|
|
||||||
name: `${village} 해안`,
|
|
||||||
type: substrate,
|
|
||||||
esi: esiStr,
|
|
||||||
esiNum,
|
|
||||||
length: `${lengthM.toLocaleString()}.0 m`,
|
|
||||||
lengthM,
|
|
||||||
sensitivity: sensFromESI(esiNum),
|
|
||||||
status: statusArr[Math.floor(seededRandom(seed + 5) * statusArr.length)],
|
|
||||||
lat, lng,
|
|
||||||
tags: scatTagSets[Math.floor(seededRandom(seed + 8) * scatTagSets.length)],
|
|
||||||
jurisdiction: a.jurisdiction,
|
|
||||||
})
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return segs
|
|
||||||
}
|
|
||||||
|
|
||||||
export const allSegments = generateSegments()
|
|
||||||
|
|
||||||
export const scatDetailData: ScatDetail[] = [
|
|
||||||
// ═══ 서귀포시 (남부 해안) ═══
|
|
||||||
// SGSS-1: 성산읍 시흥리 — 투과성 인공호안
|
|
||||||
{
|
|
||||||
code: 'SGSS-1', name: '서귀포시 성산읍 시흥리', esi: '6B', esiColor: '#f97316', lat: 33.4610, lng: 126.9310,
|
|
||||||
type: '폐쇄형', substrate: '투과성 인공호안', length: '846.4m', sensitivity: '중', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1270-1, 12-64',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '올레길1코스, 파래양식장' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
|
||||||
},
|
|
||||||
// SGSS-6: 성산읍 시흥리 — 모래
|
|
||||||
{
|
|
||||||
code: 'SGSS-6', name: '서귀포시 성산읍 시흥리', esi: '3A', esiColor: '#a3e635', lat: 33.4580, lng: 126.9200,
|
|
||||||
type: '폐쇄형', substrate: '모래', length: '131.3m', sensitivity: '하', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '숙박시설, 조가비박물관' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
|
||||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
|
|
||||||
},
|
|
||||||
// SGSS-10: 성산읍 오조리 — 수평암반
|
|
||||||
{
|
|
||||||
code: 'SGSS-10', name: '서귀포시 성산읍 오조리', esi: '8A', esiColor: '#dc2626', lat: 33.4500, lng: 126.9050,
|
|
||||||
type: '개방형', substrate: '수평암반', length: '433.6m', sensitivity: '상', status: '완료',
|
|
||||||
access: '도보로 접근 가능, 인근구획에서 접근', accessPt: '서귀포시 성산읍 오조리 391',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '교육시설(성산고등학교)' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
|
||||||
},
|
|
||||||
// SGPS-6: 표선면 표선리 — 모래 (표선해수욕장)
|
|
||||||
{
|
|
||||||
code: 'SGPS-6', name: '서귀포시 표선면 표선리', esi: '3A', esiColor: '#a3e635', lat: 33.3270, lng: 126.8320,
|
|
||||||
type: '폐쇄형', substrate: '모래', length: '827.9m', sensitivity: '하', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 표선면 표선리 464-1',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '표선해수욕장, 올레길3코스, 숙박시설, 민가' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
|
||||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
|
|
||||||
},
|
|
||||||
// SGNW-5: 남원읍 태흥리 — 수평암반
|
|
||||||
{
|
|
||||||
code: 'SGNW-5', name: '서귀포시 남원읍 태흥리', esi: '8A', esiColor: '#dc2626', lat: 33.2510, lng: 126.6650,
|
|
||||||
type: '개방형', substrate: '수평암반', length: '432.8m', sensitivity: '상', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 7',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '육상양식장' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
|
||||||
},
|
|
||||||
// SGNW-12: 남원읍 태흥리 — 모래자갈혼합
|
|
||||||
{
|
|
||||||
code: 'SGNW-12', name: '서귀포시 남원읍 태흥리', esi: '5', esiColor: '#fb923c', lat: 33.2480, lng: 126.6400,
|
|
||||||
type: '폐쇄형', substrate: '모래자갈혼합', length: '237.3m', sensitivity: '중', status: '진행중',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 364-2',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '올레길4코스, 민가' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
|
||||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능'],
|
|
||||||
},
|
|
||||||
// SGTP-5: 서귀동 — 투과성 인공호안 (서귀포항)
|
|
||||||
{
|
|
||||||
code: 'SGTP-5', name: '서귀포시 서귀동', esi: '6B', esiColor: '#f97316', lat: 33.2400, lng: 126.5550,
|
|
||||||
type: '개방형', substrate: '투과성 인공호안', length: '701.6m', sensitivity: '중', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 서귀동 758-5',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '서귀포항' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
|
||||||
},
|
|
||||||
// SGGJ-5: 강정동 — 수직호안
|
|
||||||
{
|
|
||||||
code: 'SGGJ-5', name: '서귀포시 강정동', esi: '1B', esiColor: '#4ade80', lat: 33.2430, lng: 126.4500,
|
|
||||||
type: '폐쇄형', substrate: '수직호안', length: '380.0m', sensitivity: '하', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 강정동 산1',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '강정항' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적'],
|
|
||||||
},
|
|
||||||
// SGAD-5: 안덕면 감산리 — 수직호안 (대평항)
|
|
||||||
{
|
|
||||||
code: 'SGAD-5', name: '서귀포시 안덕면 감산리', esi: '1B', esiColor: '#4ade80', lat: 33.2400, lng: 126.2950,
|
|
||||||
type: '폐쇄형', substrate: '수직호안', length: '246.9m', sensitivity: '하', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 982-1',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '대평항' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
|
||||||
},
|
|
||||||
// SGAD-7: 안덕면 감산리 — 자갈·왕자갈
|
|
||||||
{
|
|
||||||
code: 'SGAD-7', name: '서귀포시 안덕면 감산리', esi: '6A', esiColor: '#f97316', lat: 33.2380, lng: 126.2850,
|
|
||||||
type: '폐쇄형', substrate: '자갈·왕자갈', length: '154.2m', sensitivity: '중', status: '진행중',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 985',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '올레길8코스(해안로), 민가' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
|
||||||
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
|
|
||||||
},
|
|
||||||
// SGDJ-5: 대정읍 상모리 — 수직호안 (산이수동항)
|
|
||||||
{
|
|
||||||
code: 'SGDJ-5', name: '서귀포시 대정읍 상모리', esi: '1B', esiColor: '#4ade80', lat: 33.2300, lng: 126.2350,
|
|
||||||
type: '개방형', substrate: '수직호안', length: '202.0m', sensitivity: '하', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 대정읍 상모리 133',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '산이수동항' }, { t: '생물자원', v: '마라해안군립공원' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
|
||||||
},
|
|
||||||
// SGDJ-7: 대정읍 상모리 — 모래 (송악산)
|
|
||||||
{
|
|
||||||
code: 'SGDJ-7', name: '서귀포시 대정읍 상모리', esi: '3A', esiColor: '#a3e635', lat: 33.2280, lng: 126.2280,
|
|
||||||
type: '개방형', substrate: '모래', length: '179.6m', sensitivity: '하', status: '미조사',
|
|
||||||
access: '도보로 접근 가능', accessPt: '서귀포시 대정읍 상모리 179-3',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
|
||||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
|
|
||||||
},
|
|
||||||
// SGDJ-8: 대정읍 상모리 — 수직암반 (송악산)
|
|
||||||
{
|
|
||||||
code: 'SGDJ-8', name: '서귀포시 대정읍 상모리', esi: '1A', esiColor: '#4ade80', lat: 33.2260, lng: 126.2200,
|
|
||||||
type: '개방형', substrate: '수직암반', length: '585.1m', sensitivity: '하', status: '완료',
|
|
||||||
access: '선박을 이용하여 접근', accessPt: '서귀포시 대정읍 상모리 179-3',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
|
||||||
},
|
|
||||||
// ═══ 제주시 (북부 해안) ═══
|
|
||||||
// JJHG-1: 한경면 고산리 — 수평암반
|
|
||||||
{
|
|
||||||
code: 'JJHG-1', name: '제주시 한경면 고산리', esi: '8A', esiColor: '#dc2626', lat: 33.2930, lng: 126.1620,
|
|
||||||
type: '개방형', substrate: '수평암반', length: '306.0m', sensitivity: '상', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한경면 고산리 3987',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '육상양식장(도로 주변 농사구역)' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이', '부분적으로 모래와 암반이 형성되어 있음'],
|
|
||||||
},
|
|
||||||
// JJHG-8: 한경면 고산리 — 투과성 인공호안 (차귀도항)
|
|
||||||
{
|
|
||||||
code: 'JJHG-8', name: '제주시 한경면 고산리', esi: '6B', esiColor: '#f97316', lat: 33.3100, lng: 126.1750,
|
|
||||||
type: '개방형', substrate: '투과성 인공호안', length: '201.8m', sensitivity: '중', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 소형선박 이용 방제작업 가능', accessPt: '제주시 한경면 고산리 3616-10',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '차귀도항, 잠수함 매표소' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업(흡착제,걸레)에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
|
||||||
},
|
|
||||||
// JJHL-4: 한림읍 월령리 — 모래
|
|
||||||
{
|
|
||||||
code: 'JJHL-4', name: '제주시 한림읍 월령리', esi: '3A', esiColor: '#a3e635', lat: 33.3900, lng: 126.2400,
|
|
||||||
type: '폐쇄형', substrate: '모래', length: '100.2m', sensitivity: '하', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한림읍 월령리 3855',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '월령항, 숙박시설' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
|
||||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
|
|
||||||
},
|
|
||||||
// JJAW-8: 애월읍 곽지리 — 모래 (곽지해수욕장)
|
|
||||||
{
|
|
||||||
code: 'JJAW-8', name: '제주시 애월읍 곽지리', esi: '3A', esiColor: '#a3e635', lat: 33.4700, lng: 126.3400,
|
|
||||||
type: '개방형', substrate: '모래', length: '573.6m', sensitivity: '하', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 애월읍 곽지리 3855',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '곽지해수욕장, 캠핑장' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
|
||||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
|
|
||||||
},
|
|
||||||
// JJGI-3: 건입동 — 수직호안 (제주항)
|
|
||||||
{
|
|
||||||
code: 'JJGI-3', name: '제주시 건입동', esi: '1B', esiColor: '#4ade80', lat: 33.5200, lng: 126.5450,
|
|
||||||
type: '폐쇄형', substrate: '수직호안', length: '365.8m', sensitivity: '하', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 건입동 3855',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '제주항, 제주조선' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 호안의 거친 표면에 쉽게 표착될 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
|
||||||
},
|
|
||||||
// JJJC-4: 조천읍 신촌리 — 자갈·왕자갈
|
|
||||||
{
|
|
||||||
code: 'JJJC-4', name: '제주시 조천읍 신촌리', esi: '6A', esiColor: '#f97316', lat: 33.5380, lng: 126.6400,
|
|
||||||
type: '폐쇄형', substrate: '자갈·왕자갈', length: '360.4m', sensitivity: '중', status: '진행중',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 조천읍 신촌리 3855',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '정치망어장(전면 270m)' }, { t: '생물자원', v: '폐류 서식지' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
|
||||||
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
|
|
||||||
},
|
|
||||||
// JJGJ-2: 구좌읍 동복리 — 투과성 인공호안
|
|
||||||
{
|
|
||||||
code: 'JJGJ-2', name: '제주시 구좌읍 동복리', esi: '6B', esiColor: '#f97316', lat: 33.5500, lng: 126.7300,
|
|
||||||
type: '폐쇄형', substrate: '투과성 인공호안', length: '219.2m', sensitivity: '중', status: '완료',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '접안시설' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
|
||||||
},
|
|
||||||
// JJGJ-3: 구좌읍 동복리 — 수평암반
|
|
||||||
{
|
|
||||||
code: 'JJGJ-3', name: '제주시 구좌읍 동복리', esi: '8A', esiColor: '#dc2626', lat: 33.5480, lng: 126.7350,
|
|
||||||
type: '개방형', substrate: '수평암반', length: '197.4m', sensitivity: '상', status: '미조사',
|
|
||||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
|
|
||||||
sensitive: [{ t: '사회·경제적', v: '산책로, 민가' }, { t: '생물자원', v: '없음' }],
|
|
||||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
|
||||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
|
||||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
135
frontend/src/tabs/scat/services/scatApi.ts
Normal file
135
frontend/src/tabs/scat/services/scatApi.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { api } from '@common/services/api';
|
||||||
|
import type { ScatSegment, ScatDetail } from '../components/scatTypes';
|
||||||
|
import { esiColor } from '../components/scatConstants';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 백엔드 API 응답 타입
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface ApiZoneItem {
|
||||||
|
cstSrvyZoneSn: number;
|
||||||
|
zoneCd: string;
|
||||||
|
zoneNm: string;
|
||||||
|
jrsdNm: string;
|
||||||
|
sectCnt: number;
|
||||||
|
latCenter: number;
|
||||||
|
lngCenter: number;
|
||||||
|
latRange: number;
|
||||||
|
lngRange: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiSectionListItem {
|
||||||
|
cstSectSn: number;
|
||||||
|
sectCd: string;
|
||||||
|
sectNm: string;
|
||||||
|
cstTpCd: string;
|
||||||
|
esiCd: string;
|
||||||
|
esiNum: number;
|
||||||
|
lenM: number;
|
||||||
|
snstvtCd: string;
|
||||||
|
srvySttsCd: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
tags: string[];
|
||||||
|
zoneCd: string;
|
||||||
|
zoneNm: string;
|
||||||
|
jrsdNm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiSectionDetail extends ApiSectionListItem {
|
||||||
|
geomJson: Record<string, unknown> | null;
|
||||||
|
shoreTp: string | null;
|
||||||
|
accessDc: string | null;
|
||||||
|
accessPt: string | null;
|
||||||
|
sensitiveInfo: { t: string; v: string }[];
|
||||||
|
cleanupMethods: string[];
|
||||||
|
endCriteria: string[];
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 프론트 호환 변환 함수
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const sensFromESI = (n: number): ScatSegment['sensitivity'] =>
|
||||||
|
n >= 9 ? '최상' : n >= 7 ? '상' : n >= 5 ? '중' : '하';
|
||||||
|
|
||||||
|
function toScatSegment(item: ApiSectionListItem): ScatSegment {
|
||||||
|
const lengthM = Number(item.lenM) || 0;
|
||||||
|
return {
|
||||||
|
id: item.cstSectSn,
|
||||||
|
code: item.sectCd,
|
||||||
|
area: item.zoneNm,
|
||||||
|
name: item.sectNm,
|
||||||
|
type: item.cstTpCd,
|
||||||
|
esi: item.esiCd,
|
||||||
|
esiNum: item.esiNum,
|
||||||
|
length: `${lengthM.toLocaleString()}.0 m`,
|
||||||
|
lengthM,
|
||||||
|
sensitivity: (item.snstvtCd as ScatSegment['sensitivity']) || sensFromESI(item.esiNum),
|
||||||
|
status: (item.srvySttsCd as ScatSegment['status']) || '미조사',
|
||||||
|
lat: item.lat,
|
||||||
|
lng: item.lng,
|
||||||
|
tags: item.tags ?? [],
|
||||||
|
jurisdiction: item.jrsdNm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toScatDetail(item: ApiSectionDetail): ScatDetail {
|
||||||
|
const lengthM = Number(item.lenM) || 0;
|
||||||
|
return {
|
||||||
|
code: item.sectCd,
|
||||||
|
name: item.sectNm,
|
||||||
|
esi: item.esiCd,
|
||||||
|
esiColor: esiColor(item.esiNum),
|
||||||
|
lat: item.lat,
|
||||||
|
lng: item.lng,
|
||||||
|
type: item.shoreTp ?? item.cstTpCd,
|
||||||
|
substrate: item.cstTpCd,
|
||||||
|
length: `${lengthM.toLocaleString()}.0 m`,
|
||||||
|
sensitivity: item.snstvtCd,
|
||||||
|
status: item.srvySttsCd,
|
||||||
|
access: item.accessDc ?? '',
|
||||||
|
accessPt: item.accessPt ?? '',
|
||||||
|
sensitive: item.sensitiveInfo ?? [],
|
||||||
|
cleanup: item.cleanupMethods ?? [],
|
||||||
|
endCriteria: item.endCriteria ?? [],
|
||||||
|
notes: item.notes ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// API 호출 함수
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function fetchZones(): Promise<ApiZoneItem[]> {
|
||||||
|
const { data } = await api.get<ApiZoneItem[]>('/scat/zones');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionFilters {
|
||||||
|
zone?: string;
|
||||||
|
status?: string;
|
||||||
|
sensitivity?: string;
|
||||||
|
jurisdiction?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSections(filters?: SectionFilters): Promise<ScatSegment[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.zone) params.set('zone', filters.zone);
|
||||||
|
if (filters?.status) params.set('status', filters.status);
|
||||||
|
if (filters?.sensitivity) params.set('sensitivity', filters.sensitivity);
|
||||||
|
if (filters?.jurisdiction) params.set('jurisdiction', filters.jurisdiction);
|
||||||
|
if (filters?.search) params.set('search', filters.search);
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
const url = query ? `/scat/sections?${query}` : '/scat/sections';
|
||||||
|
const { data } = await api.get<ApiSectionListItem[]>(url);
|
||||||
|
return data.map(toScatSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSectionDetail(sn: number): Promise<ScatDetail> {
|
||||||
|
const { data } = await api.get<ApiSectionDetail>(`/scat/sections/${sn}`);
|
||||||
|
return toScatDetail(data);
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user