docs: v1.0.0 버저닝 릴리즈 노트 작성 #65
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 assetsRouter from './assets/assetsRouter.js'
|
||||
import incidentsRouter from './incidents/incidentsRouter.js'
|
||||
import scatRouter from './scat/scatRouter.js'
|
||||
import {
|
||||
sanitizeBody,
|
||||
sanitizeQuery,
|
||||
@ -147,6 +148,7 @@ app.use('/api/hns', hnsRouter)
|
||||
app.use('/api/reports', reportsRouter)
|
||||
app.use('/api/assets', assetsRouter)
|
||||
app.use('/api/incidents', incidentsRouter)
|
||||
app.use('/api/scat', scatRouter)
|
||||
|
||||
// 헬스 체크
|
||||
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 type { ScatSegment, ScatDetail } from './scatTypes'
|
||||
import { allSegments, scatDetailData } from './scatConstants'
|
||||
import ScatLeftPanel from './ScatLeftPanel'
|
||||
import ScatMap from './ScatMap'
|
||||
import ScatTimeline from './ScatTimeline'
|
||||
import ScatPopup from './ScatPopup'
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { ScatSegment, ScatDetail } from './scatTypes';
|
||||
import { fetchSections, fetchSectionDetail, fetchZones } from '../services/scatApi';
|
||||
import type { ApiZoneItem } from '../services/scatApi';
|
||||
import ScatLeftPanel from './ScatLeftPanel';
|
||||
import ScatMap from './ScatMap';
|
||||
import ScatTimeline from './ScatTimeline';
|
||||
import ScatPopup from './ScatPopup';
|
||||
|
||||
// ═══ Main PreScatView ═══
|
||||
|
||||
export function PreScatView() {
|
||||
const [selectedSeg, setSelectedSeg] = useState<ScatSegment>(allSegments[0])
|
||||
const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)')
|
||||
const [areaFilter, setAreaFilter] = useState('전체')
|
||||
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)
|
||||
const [segments, setSegments] = useState<ScatSegment[]>([]);
|
||||
const [zones, setZones] = useState<ApiZoneItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
|
||||
const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)');
|
||||
const [areaFilter, setAreaFilter] = useState('전체');
|
||||
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 => {
|
||||
if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포'
|
||||
if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주'
|
||||
return true // 전체
|
||||
})
|
||||
const filteredSegments = segments.filter((s) => {
|
||||
if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포';
|
||||
if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주';
|
||||
return true; // 전체
|
||||
});
|
||||
|
||||
const handleOpenPopup = useCallback((idx: number) => {
|
||||
setPopupData(scatDetailData[idx] || scatDetailData[0])
|
||||
}, [])
|
||||
const handleOpenPopup = useCallback(async (sn: number) => {
|
||||
try {
|
||||
const detail = await fetchSectionDetail(sn);
|
||||
setPopupData(detail);
|
||||
} catch (err) {
|
||||
console.error('[SCAT] 상세 데이터 로딩 오류:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClosePopup = useCallback(() => {
|
||||
setPopupData(null)
|
||||
}, [])
|
||||
setPopupData(null);
|
||||
}, []);
|
||||
|
||||
const handleTimelineSeek = useCallback((idx: number) => {
|
||||
if (idx === -1) {
|
||||
// advance signal from play
|
||||
setTimelineIdx(prev => {
|
||||
const next = (prev + 1) % Math.min(segments.length, 12)
|
||||
if (segments[next]) setSelectedSeg(segments[next])
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setTimelineIdx(idx)
|
||||
if (segments[idx]) setSelectedSeg(segments[idx])
|
||||
}
|
||||
}, [segments])
|
||||
const handleTimelineSeek = useCallback(
|
||||
(idx: number) => {
|
||||
if (idx === -1) {
|
||||
// advance signal from play
|
||||
setTimelineIdx((prev) => {
|
||||
const next = (prev + 1) % Math.min(filteredSegments.length, 12);
|
||||
if (filteredSegments[next]) setSelectedSeg(filteredSegments[next]);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setTimelineIdx(idx);
|
||||
if (filteredSegments[idx]) setSelectedSeg(filteredSegments[idx]);
|
||||
}
|
||||
},
|
||||
[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 (
|
||||
<div className="flex w-full h-full bg-bg-0 overflow-hidden">
|
||||
<ScatLeftPanel
|
||||
segments={segments}
|
||||
segments={filteredSegments}
|
||||
zones={zones}
|
||||
selectedSeg={selectedSeg}
|
||||
onSelectSeg={setSelectedSeg}
|
||||
onOpenPopup={handleOpenPopup}
|
||||
@ -68,13 +132,13 @@ export function PreScatView() {
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<ScatMap
|
||||
segments={segments}
|
||||
segments={filteredSegments}
|
||||
selectedSeg={selectedSeg}
|
||||
onSelectSeg={setSelectedSeg}
|
||||
onOpenPopup={handleOpenPopup}
|
||||
/>
|
||||
<ScatTimeline
|
||||
segments={segments}
|
||||
segments={filteredSegments}
|
||||
currentIdx={timelineIdx}
|
||||
onSeek={handleTimelineSeek}
|
||||
/>
|
||||
@ -84,5 +148,5 @@ export function PreScatView() {
|
||||
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,25 +1,28 @@
|
||||
import type { ScatSegment } from './scatTypes'
|
||||
import { esiColor, sensColor, statusColor, esiLevel, scatAreas, scatDetailData } from './scatConstants'
|
||||
import type { ScatSegment } from './scatTypes';
|
||||
import type { ApiZoneItem } from '../services/scatApi';
|
||||
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
|
||||
|
||||
interface ScatLeftPanelProps {
|
||||
segments: ScatSegment[]
|
||||
selectedSeg: ScatSegment
|
||||
onSelectSeg: (s: ScatSegment) => void
|
||||
onOpenPopup: (idx: number) => void
|
||||
jurisdictionFilter: string
|
||||
onJurisdictionChange: (v: string) => void
|
||||
areaFilter: string
|
||||
onAreaChange: (v: string) => void
|
||||
phaseFilter: string
|
||||
onPhaseChange: (v: string) => void
|
||||
statusFilter: string
|
||||
onStatusChange: (v: string) => void
|
||||
searchTerm: string
|
||||
onSearchChange: (v: string) => void
|
||||
segments: ScatSegment[];
|
||||
zones: ApiZoneItem[];
|
||||
selectedSeg: ScatSegment;
|
||||
onSelectSeg: (s: ScatSegment) => void;
|
||||
onOpenPopup: (sn: number) => void;
|
||||
jurisdictionFilter: string;
|
||||
onJurisdictionChange: (v: string) => void;
|
||||
areaFilter: string;
|
||||
onAreaChange: (v: string) => void;
|
||||
phaseFilter: string;
|
||||
onPhaseChange: (v: string) => void;
|
||||
statusFilter: string;
|
||||
onStatusChange: (v: string) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function ScatLeftPanel({
|
||||
segments,
|
||||
zones,
|
||||
selectedSeg,
|
||||
onSelectSeg,
|
||||
onOpenPopup,
|
||||
@ -34,12 +37,18 @@ function ScatLeftPanel({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
}: ScatLeftPanelProps) {
|
||||
const filtered = segments.filter(s => {
|
||||
if (areaFilter !== '전체' && !s.area.includes(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
|
||||
})
|
||||
const filtered = segments.filter((s) => {
|
||||
if (
|
||||
areaFilter !== '전체' &&
|
||||
!s.area.includes(
|
||||
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 (
|
||||
<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 className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">관할 해경</label>
|
||||
<select value={jurisdictionFilter} onChange={e => onJurisdictionChange(e.target.value)} className="prd-i w-full">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
|
||||
관할 해경
|
||||
</label>
|
||||
<select
|
||||
value={jurisdictionFilter}
|
||||
onChange={(e) => onJurisdictionChange(e.target.value)}
|
||||
className="prd-i w-full"
|
||||
>
|
||||
<option>전체 (제주도)</option>
|
||||
<option>서귀포해양경비안전서</option>
|
||||
<option>제주해양경비안전서</option>
|
||||
@ -60,18 +75,32 @@ function ScatLeftPanel({
|
||||
</div>
|
||||
|
||||
<div className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">해안 구역</label>
|
||||
<select value={areaFilter} onChange={e => onAreaChange(e.target.value)} className="prd-i w-full">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
|
||||
해안 구역
|
||||
</label>
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => onAreaChange(e.target.value)}
|
||||
className="prd-i w-full"
|
||||
>
|
||||
<option>전체</option>
|
||||
{scatAreas.map(a => (
|
||||
<option key={a.code}>{a.jurisdiction === '서귀포' ? '서귀포시' : '제주시'} {a.area} 해안</option>
|
||||
{zones.map((z) => (
|
||||
<option key={z.zoneCd}>
|
||||
{z.jrsdNm === '서귀포' ? '서귀포시' : '제주시'} {z.zoneNm} 해안
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">조사 단계</label>
|
||||
<select value={phaseFilter} onChange={e => onPhaseChange(e.target.value)} className="prd-i w-full">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
|
||||
조사 단계
|
||||
</label>
|
||||
<select
|
||||
value={phaseFilter}
|
||||
onChange={(e) => onPhaseChange(e.target.value)}
|
||||
className="prd-i w-full"
|
||||
>
|
||||
<option>Pre-SCAT (사전조사)</option>
|
||||
<option>SCAT (사고 시 조사)</option>
|
||||
<option>Post-SCAT (사후 확인)</option>
|
||||
@ -83,10 +112,14 @@ function ScatLeftPanel({
|
||||
type="text"
|
||||
placeholder="🔍 구간 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
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>
|
||||
@ -102,54 +135,83 @@ function ScatLeftPanel({
|
||||
<span className="w-[3px] h-2.5 bg-status-green rounded-sm" />
|
||||
해안 구간 목록
|
||||
</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 className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5">
|
||||
{filtered.map(seg => {
|
||||
const lvl = esiLevel(seg.esiNum)
|
||||
const borderColor = lvl === 'h' ? 'border-l-status-red' : lvl === 'm' ? 'border-l-status-orange' : 'border-l-status-green'
|
||||
const isSelected = selectedSeg.id === seg.id
|
||||
{filtered.map((seg) => {
|
||||
const lvl = esiLevel(seg.esiNum);
|
||||
const borderColor =
|
||||
lvl === 'h'
|
||||
? 'border-l-status-red'
|
||||
: lvl === 'm'
|
||||
? 'border-l-status-orange'
|
||||
: 'border-l-status-green';
|
||||
const isSelected = selectedSeg.id === seg.id;
|
||||
return (
|
||||
<div
|
||||
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} ${
|
||||
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">
|
||||
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
|
||||
📍 {seg.code} {seg.area}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<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 className="flex justify-between text-[11px]">
|
||||
<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 className="flex justify-between text-[11px]">
|
||||
<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 className="flex justify-between text-[11px]">
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default ScatLeftPanel
|
||||
export default ScatLeftPanel;
|
||||
|
||||
@ -1,108 +1,106 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import type { ScatSegment } from './scatTypes'
|
||||
import { esiColor, jejuCoastCoords, scatDetailData } from './scatConstants'
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { ScatSegment } from './scatTypes';
|
||||
import { esiColor, jejuCoastCoords } from './scatConstants';
|
||||
|
||||
interface ScatMapProps {
|
||||
segments: ScatSegment[]
|
||||
selectedSeg: ScatSegment
|
||||
onSelectSeg: (s: ScatSegment) => void
|
||||
onOpenPopup: (idx: number) => void
|
||||
segments: ScatSegment[];
|
||||
selectedSeg: ScatSegment;
|
||||
onSelectSeg: (s: ScatSegment) => void;
|
||||
onOpenPopup: (sn: number) => void;
|
||||
}
|
||||
|
||||
function ScatMap({
|
||||
segments,
|
||||
selectedSeg,
|
||||
onSelectSeg,
|
||||
onOpenPopup,
|
||||
}: 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)
|
||||
function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: 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(() => {
|
||||
if (!mapContainerRef.current || mapRef.current) return
|
||||
if (!mapContainerRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
center: [33.38, 126.55],
|
||||
zoom: 10,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
}).addTo(map);
|
||||
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map)
|
||||
L.control.attribution({ position: 'bottomleft' }).addAttribution(
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>'
|
||||
).addTo(map)
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||
L.control
|
||||
.attribution({ position: 'bottomleft' })
|
||||
.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
|
||||
markersRef.current = L.layerGroup().addTo(map)
|
||||
mapRef.current = map;
|
||||
markersRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
setTimeout(() => map.invalidateSize(), 100)
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
markersRef.current = null
|
||||
}
|
||||
}, [])
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
markersRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !markersRef.current) return
|
||||
markersRef.current.clearLayers()
|
||||
if (!mapRef.current || !markersRef.current) return;
|
||||
markersRef.current.clearLayers();
|
||||
|
||||
// 줌 기반 스케일 계수 (zoom 10=아일랜드뷰 → 14+=클로즈업)
|
||||
const zScale = Math.max(0, (zoom - 9)) / 5 // 0 at z9, 1 at z14
|
||||
const polyWeight = 1 + zScale * 4 // 1 ~ 5
|
||||
const selPolyWeight = 2 + zScale * 5 // 2 ~ 7
|
||||
const glowWeight = 4 + zScale * 14 // 4 ~ 18
|
||||
const halfLenScale = 0.15 + zScale * 0.85 // 0.15 ~ 1.0
|
||||
const markerSize = Math.round(6 + zScale * 16) // 6px ~ 22px
|
||||
const markerBorder = zoom >= 13 ? 2 : 1
|
||||
const markerFontSize = Math.round(4 + zScale * 6) // 4px ~ 10px
|
||||
const showStatusMarker = zoom >= 11
|
||||
const showStatusText = zoom >= 13
|
||||
const zScale = Math.max(0, zoom - 9) / 5; // 0 at z9, 1 at z14
|
||||
const polyWeight = 1 + zScale * 4; // 1 ~ 5
|
||||
const selPolyWeight = 2 + zScale * 5; // 2 ~ 7
|
||||
const glowWeight = 4 + zScale * 14; // 4 ~ 18
|
||||
const halfLenScale = 0.15 + zScale * 0.85; // 0.15 ~ 1.0
|
||||
const markerSize = Math.round(6 + zScale * 16); // 6px ~ 22px
|
||||
const markerBorder = zoom >= 13 ? 2 : 1;
|
||||
const markerFontSize = Math.round(4 + zScale * 6); // 4px ~ 10px
|
||||
const showStatusMarker = zoom >= 11;
|
||||
const showStatusText = zoom >= 13;
|
||||
|
||||
// 제주도 해안선 레퍼런스 라인
|
||||
const coastline = L.polyline(jejuCoastCoords as [number, number][], {
|
||||
color: 'rgba(6, 182, 212, 0.18)',
|
||||
weight: 1.5,
|
||||
dashArray: '8, 6',
|
||||
})
|
||||
markersRef.current.addLayer(coastline)
|
||||
});
|
||||
markersRef.current.addLayer(coastline);
|
||||
|
||||
segments.forEach(seg => {
|
||||
const isSelected = selectedSeg.id === seg.id
|
||||
const color = esiColor(seg.esiNum)
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const isSelected = selectedSeg.id === seg.id;
|
||||
const color = esiColor(seg.esiNum);
|
||||
|
||||
// 해안선 방향 계산 (세그먼트 폴리라인 각도 결정)
|
||||
const coastIdx = seg.id % (jejuCoastCoords.length - 1)
|
||||
const [clat1, clng1] = jejuCoastCoords[coastIdx]
|
||||
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
|
||||
const coastIdx = segIdx % (jejuCoastCoords.length - 1);
|
||||
const [clat1, clng1] = jejuCoastCoords[coastIdx];
|
||||
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length];
|
||||
|
||||
const dlat = clat2 - clat1
|
||||
const dlng = clng2 - clng1
|
||||
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
|
||||
const nDlat = dist > 0 ? dlat / dist : 0
|
||||
const nDlng = dist > 0 ? dlng / dist : 1
|
||||
const dlat = clat2 - clat1;
|
||||
const dlng = clng2 - clng1;
|
||||
const dist = Math.sqrt(dlat * dlat + dlng * dlng);
|
||||
const nDlat = dist > 0 ? dlat / dist : 0;
|
||||
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][] = [
|
||||
[seg.lat - nDlat * halfLen, seg.lng - nDlng * halfLen],
|
||||
[seg.lat, seg.lng],
|
||||
[seg.lat + nDlat * halfLen, seg.lng + nDlng * halfLen],
|
||||
]
|
||||
];
|
||||
|
||||
// 선택된 구간 글로우 효과
|
||||
if (isSelected) {
|
||||
@ -111,8 +109,8 @@ function ScatMap({
|
||||
weight: glowWeight,
|
||||
opacity: 0.15,
|
||||
lineCap: 'round',
|
||||
})
|
||||
markersRef.current!.addLayer(glow)
|
||||
});
|
||||
markersRef.current!.addLayer(glow);
|
||||
}
|
||||
|
||||
// ESI 색상 구간 폴리라인
|
||||
@ -122,9 +120,9 @@ function ScatMap({
|
||||
opacity: isSelected ? 0.95 : 0.7,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
})
|
||||
});
|
||||
|
||||
const statusIcon = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'
|
||||
const statusIcon = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—';
|
||||
|
||||
polyline.bindTooltip(
|
||||
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
|
||||
@ -136,21 +134,27 @@ function ScatMap({
|
||||
direction: 'top',
|
||||
offset: [0, -10],
|
||||
className: 'scat-map-tooltip',
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
polyline.on('click', () => {
|
||||
onSelectSeg(seg)
|
||||
onOpenPopup(seg.id % scatDetailData.length)
|
||||
})
|
||||
markersRef.current!.addLayer(polyline)
|
||||
onSelectSeg(seg);
|
||||
onOpenPopup(seg.id);
|
||||
});
|
||||
markersRef.current!.addLayer(polyline);
|
||||
|
||||
// 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절
|
||||
if (showStatusMarker) {
|
||||
const stColor = seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b'
|
||||
const stBg = 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 stColor =
|
||||
seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b';
|
||||
const stBg =
|
||||
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], {
|
||||
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>`,
|
||||
iconSize: [0, 0],
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
statusMarker.on('click', () => {
|
||||
onSelectSeg(seg)
|
||||
onOpenPopup(seg.id % scatDetailData.length)
|
||||
})
|
||||
markersRef.current!.addLayer(statusMarker)
|
||||
onSelectSeg(seg);
|
||||
onOpenPopup(seg.id);
|
||||
});
|
||||
markersRef.current!.addLayer(statusMarker);
|
||||
}
|
||||
})
|
||||
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom])
|
||||
});
|
||||
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return
|
||||
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 })
|
||||
}, [selectedSeg])
|
||||
if (!mapRef.current) return;
|
||||
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 });
|
||||
}, [selectedSeg]);
|
||||
|
||||
const doneCount = 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 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 donePct = Math.round(doneCount / segments.length * 100)
|
||||
const progPct = Math.round(progCount / segments.length * 100)
|
||||
const notPct = 100 - donePct - progPct
|
||||
const doneCount = 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 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 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 (
|
||||
<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">
|
||||
{/* 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="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 9', label: '쉘터 갯벌', color: '#b91c1c' },
|
||||
@ -227,7 +235,10 @@ function ScatMap({
|
||||
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
|
||||
].map((item, i) => (
|
||||
<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="ml-auto font-mono text-[10px] text-text-1">{item.esi}</span>
|
||||
</div>
|
||||
@ -236,15 +247,30 @@ function ScatMap({
|
||||
|
||||
{/* 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="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="h-full transition-all duration-500" 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
|
||||
className="h-full transition-all duration-500"
|
||||
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 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(--orange)' }}>진행 {progPct}%</span>
|
||||
<span className="text-[9px] font-mono" style={{ color: 'var(--green)' }}>
|
||||
완료 {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>
|
||||
</div>
|
||||
<div className="mt-2.5">
|
||||
@ -252,11 +278,23 @@ function ScatMap({
|
||||
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
||||
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'],
|
||||
['고민감 구간', `${(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) => (
|
||||
<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="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>
|
||||
@ -265,12 +303,18 @@ function ScatMap({
|
||||
|
||||
{/* 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">
|
||||
<span>위도 <span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</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>
|
||||
<span>
|
||||
위도 <span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default ScatMap
|
||||
export default ScatMap;
|
||||
|
||||
@ -1,387 +1,104 @@
|
||||
import type { ScatSegment, ScatDetail } from './scatTypes'
|
||||
|
||||
// ═══ ESI 색상 ═══
|
||||
|
||||
export const esiColor = (n: number): string => {
|
||||
if (n >= 10) return '#991b1b'
|
||||
if (n >= 9) return '#b91c1c'
|
||||
if (n >= 8) return '#dc2626'
|
||||
if (n >= 7) return '#ef4444'
|
||||
if (n >= 6) return '#f97316'
|
||||
if (n >= 5) return '#fb923c'
|
||||
if (n >= 4) return '#facc15'
|
||||
if (n >= 3) return '#a3e635'
|
||||
if (n >= 2) return '#22c55e'
|
||||
return '#4ade80'
|
||||
}
|
||||
if (n >= 10) return '#991b1b';
|
||||
if (n >= 9) return '#b91c1c';
|
||||
if (n >= 8) return '#dc2626';
|
||||
if (n >= 7) return '#ef4444';
|
||||
if (n >= 6) return '#f97316';
|
||||
if (n >= 5) return '#fb923c';
|
||||
if (n >= 4) return '#facc15';
|
||||
if (n >= 3) return '#a3e635';
|
||||
if (n >= 2) return '#22c55e';
|
||||
return '#4ade80';
|
||||
};
|
||||
|
||||
export const sensColor: Record<string, string> = { '최상': 'var(--red)', '상': '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'
|
||||
export const sensColor: Record<string, string> = {
|
||||
최상: 'var(--red)',
|
||||
상: '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][] = [
|
||||
// 서부 (대정읍~한경면)
|
||||
[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.2470, 126.3500], [33.2460, 126.3700], [33.2450, 126.4000], [33.2440, 126.4300],
|
||||
[33.2430, 126.4600], [33.2420, 126.4900], [33.2410, 126.5100], [33.2400, 126.5300],
|
||||
[33.2400, 126.5500], [33.2410, 126.5700], [33.2430, 126.5900], [33.2450, 126.6200],
|
||||
[33.2500, 126.6600], [33.2600, 126.7000], [33.2800, 126.7400], [33.3100, 126.7800],
|
||||
[33.3300, 126.8200], [33.3600, 126.8400], [33.3900, 126.8600], [33.4200, 126.8800],
|
||||
[33.4400, 126.9000], [33.4530, 126.9100], [33.4580, 126.9200], [33.4610, 126.9310],
|
||||
[33.23, 126.23],
|
||||
[33.235, 126.26],
|
||||
[33.24, 126.29],
|
||||
[33.245, 126.32],
|
||||
[33.247, 126.35],
|
||||
[33.246, 126.37],
|
||||
[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.5200, 126.8500], [33.5350, 126.8200], [33.5450, 126.7900],
|
||||
[33.47, 126.92],
|
||||
[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.5400, 126.6800], [33.5350, 126.6600], [33.5300, 126.6400], [33.5250, 126.6200],
|
||||
[33.5200, 126.6000], [33.5200, 126.5800], [33.5200, 126.5600], [33.5180, 126.5400],
|
||||
[33.5160, 126.5200], [33.5140, 126.5000], [33.5120, 126.4800], [33.5100, 126.4600],
|
||||
[33.5050, 126.4400], [33.5000, 126.4200], [33.4950, 126.4000], [33.4850, 126.3800],
|
||||
[33.4700, 126.3500], [33.4550, 126.3300], [33.4400, 126.3100], [33.4200, 126.2900],
|
||||
[33.4000, 126.2700], [33.3800, 126.2500], [33.3600, 126.2350], [33.3400, 126.2200],
|
||||
[33.3200, 126.2050], [33.3100, 126.1900], [33.3000, 126.1750], [33.2930, 126.1620],
|
||||
]
|
||||
|
||||
function seededRandom(seed: number) {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
const generateSegments = (): ScatSegment[] => {
|
||||
const segs: ScatSegment[] = []
|
||||
let idx = 0
|
||||
scatAreas.forEach(a => {
|
||||
const ac = areaCoords[a.code]
|
||||
for (let i = 0; i < a.cnt; i++) {
|
||||
const seed = idx * 137 + 42
|
||||
const village = a.villages[Math.floor(seededRandom(seed) * a.villages.length)]
|
||||
const substrate = scatSubstrates[Math.floor(seededRandom(seed + 1) * scatSubstrates.length)]
|
||||
const { esi: esiStr, n: esiNum } = substrateESI[substrate]
|
||||
const lengthM = Math.floor(seededRandom(seed + 3) * 900) + 100
|
||||
// 지역 좌표 범위 내 분포
|
||||
const progress = a.cnt > 1 ? i / (a.cnt - 1) : 0.5
|
||||
const lat = ac.latC + (progress - 0.5) * ac.latR * 2 + (seededRandom(seed + 6) - 0.5) * 0.003
|
||||
const lng = ac.lngC + (progress - 0.5) * ac.lngR * 2 + (seededRandom(seed + 7) - 0.5) * 0.003
|
||||
segs.push({
|
||||
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: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
||||
},
|
||||
]
|
||||
[33.55, 126.76],
|
||||
[33.55, 126.73],
|
||||
[33.545, 126.7],
|
||||
[33.54, 126.68],
|
||||
[33.535, 126.66],
|
||||
[33.53, 126.64],
|
||||
[33.525, 126.62],
|
||||
[33.52, 126.6],
|
||||
[33.52, 126.58],
|
||||
[33.52, 126.56],
|
||||
[33.518, 126.54],
|
||||
[33.516, 126.52],
|
||||
[33.514, 126.5],
|
||||
[33.512, 126.48],
|
||||
[33.51, 126.46],
|
||||
[33.505, 126.44],
|
||||
[33.5, 126.42],
|
||||
[33.495, 126.4],
|
||||
[33.485, 126.38],
|
||||
[33.47, 126.35],
|
||||
[33.455, 126.33],
|
||||
[33.44, 126.31],
|
||||
[33.42, 126.29],
|
||||
[33.4, 126.27],
|
||||
[33.38, 126.25],
|
||||
[33.36, 126.235],
|
||||
[33.34, 126.22],
|
||||
[33.32, 126.205],
|
||||
[33.31, 126.19],
|
||||
[33.3, 126.175],
|
||||
[33.293, 126.162],
|
||||
];
|
||||
|
||||
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