feat(scat): SCAT Mock → API 전환 + PostGIS GEOMETRY 일괄 적용 #41

병합
htlee feature/scat-api-conversion 에서 develop 로 1 commits 를 머지했습니다 2026-02-28 23:29:13 +09:00
10개의 변경된 파일2314개의 추가작업 그리고 575개의 파일을 삭제
Showing only changes of commit 736c6ae429 - Show all commits

파일 보기

@ -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;

파일 보기

@ -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) => {

파일 보기

@ -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);

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. 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(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'
).addTo(map)
L.control.zoom({ position: 'bottomright' }).addTo(map);
L.control
.attribution({ position: 'bottomleft' })
.addAttribution(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <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],
];

파일 보기

@ -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);
}