feat(scat): Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축
- 백엔드: 관할서 목록 API, zone 필터링 쿼리 추가 - 프론트: ScatLeftPanel 관할서 드롭다운, ScatMap/ScatPopup 개선 - 기상탭: WeatherRightPanel 리팩토링 - prediction/scat: PDF 파싱 → 지오코딩 → ESI 매핑 파이프라인 - vite.config: proxy 설정 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
595fac5adb
커밋
d9fb4506bc
@ -1,15 +1,29 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import { listZones, listSections, getSection } from './scatService.js';
|
import { listJurisdictions, listZones, listSections, getSection } from './scatService.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GET /api/scat/jurisdictions — 관할서 목록
|
||||||
|
// ============================================================
|
||||||
|
router.get('/jurisdictions', requireAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const jurisdictions = await listJurisdictions();
|
||||||
|
res.json(jurisdictions);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[scat] 관할서 목록 조회 오류:', err);
|
||||||
|
res.status(500).json({ error: '관할서 목록 조회 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// GET /api/scat/zones — 조사구역 목록
|
// GET /api/scat/zones — 조사구역 목록
|
||||||
// ============================================================
|
// ============================================================
|
||||||
router.get('/zones', requireAuth, async (_req, res) => {
|
router.get('/zones', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const zones = await listZones();
|
const { jurisdiction } = req.query as { jurisdiction?: string };
|
||||||
|
const zones = await listZones({ jurisdiction });
|
||||||
res.json(zones);
|
res.json(zones);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[scat] 조사구역 목록 조회 오류:', err);
|
console.error('[scat] 조사구역 목록 조회 오류:', err);
|
||||||
|
|||||||
@ -60,22 +60,49 @@ interface SectionDetail {
|
|||||||
notes: string[];
|
notes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 관할서 목록 조회
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function listJurisdictions(): Promise<string[]> {
|
||||||
|
const sql = `
|
||||||
|
SELECT DISTINCT JRSD_NM
|
||||||
|
FROM wing.CST_SRVY_ZONE
|
||||||
|
WHERE USE_YN = 'Y' AND JRSD_NM IS NOT NULL
|
||||||
|
ORDER BY JRSD_NM
|
||||||
|
`;
|
||||||
|
const { rows } = await wingPool.query(sql);
|
||||||
|
return rows.map((r: Record<string, unknown>) => r.jrsd_nm as string);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 조사구역 목록 조회
|
// 조사구역 목록 조회
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export async function listZones(): Promise<ZoneItem[]> {
|
export async function listZones(filters?: {
|
||||||
|
jurisdiction?: string;
|
||||||
|
}): Promise<ZoneItem[]> {
|
||||||
|
const conditions: string[] = ["USE_YN = 'Y'"];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (filters?.jurisdiction) {
|
||||||
|
conditions.push(`JRSD_NM ILIKE '%' || $${idx++} || '%'`);
|
||||||
|
params.push(filters.jurisdiction);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = 'WHERE ' + conditions.join(' AND ');
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT CST_SRVY_ZONE_SN, ZONE_CD, ZONE_NM, JRSD_NM,
|
SELECT CST_SRVY_ZONE_SN, ZONE_CD, ZONE_NM, JRSD_NM,
|
||||||
SECT_CNT, LAT_CENTER, LNG_CENTER, LAT_RANGE, LNG_RANGE
|
SECT_CNT, LAT_CENTER, LNG_CENTER, LAT_RANGE, LNG_RANGE
|
||||||
FROM wing.CST_SRVY_ZONE
|
FROM wing.CST_SRVY_ZONE
|
||||||
WHERE USE_YN = 'Y'
|
${where}
|
||||||
ORDER BY CST_SRVY_ZONE_SN
|
ORDER BY CST_SRVY_ZONE_SN
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const { rows } = await wingPool.query(sql);
|
const { rows } = await wingPool.query(sql, params);
|
||||||
|
|
||||||
// pg QueryResult rows — NUMERIC은 string 반환, 타입 단언 불가피
|
|
||||||
return rows.map((r: Record<string, unknown>) => ({
|
return rows.map((r: Record<string, unknown>) => ({
|
||||||
cstSrvyZoneSn: r.cst_srvy_zone_sn as number,
|
cstSrvyZoneSn: r.cst_srvy_zone_sn as number,
|
||||||
zoneCd: r.zone_cd as string,
|
zoneCd: r.zone_cd as string,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import type { ScatSegment, ScatDetail } from './scatTypes';
|
import type { ScatSegment, ScatDetail } from './scatTypes';
|
||||||
import { fetchSections, fetchSectionDetail, fetchZones } from '../services/scatApi';
|
import { fetchSections, fetchSectionDetail, fetchZones, fetchJurisdictions } from '../services/scatApi';
|
||||||
import type { ApiZoneItem } from '../services/scatApi';
|
import type { ApiZoneItem } from '../services/scatApi';
|
||||||
import ScatLeftPanel from './ScatLeftPanel';
|
import ScatLeftPanel from './ScatLeftPanel';
|
||||||
import ScatMap from './ScatMap';
|
import ScatMap from './ScatMap';
|
||||||
@ -13,11 +13,12 @@ import ScatRightPanel from './ScatRightPanel';
|
|||||||
export function PreScatView() {
|
export function PreScatView() {
|
||||||
const [segments, setSegments] = useState<ScatSegment[]>([]);
|
const [segments, setSegments] = useState<ScatSegment[]>([]);
|
||||||
const [zones, setZones] = useState<ApiZoneItem[]>([]);
|
const [zones, setZones] = useState<ApiZoneItem[]>([]);
|
||||||
|
const [jurisdictions, setJurisdictions] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
|
const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
|
||||||
const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)');
|
const [jurisdictionFilter, setJurisdictionFilter] = useState('');
|
||||||
const [areaFilter, setAreaFilter] = useState('전체');
|
const [areaFilter, setAreaFilter] = useState('');
|
||||||
const [phaseFilter, setPhaseFilter] = useState('Pre-SCAT (사전조사)');
|
const [phaseFilter, setPhaseFilter] = useState('Pre-SCAT (사전조사)');
|
||||||
const [statusFilter, setStatusFilter] = useState('전체');
|
const [statusFilter, setStatusFilter] = useState('전체');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@ -26,17 +27,25 @@ export function PreScatView() {
|
|||||||
const [panelLoading, setPanelLoading] = useState(false);
|
const [panelLoading, setPanelLoading] = useState(false);
|
||||||
const [timelineIdx, setTimelineIdx] = useState(6);
|
const [timelineIdx, setTimelineIdx] = useState(6);
|
||||||
|
|
||||||
// API에서 구역 및 구간 데이터 로딩
|
// 초기 관할서 목록 로딩
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
async function loadInit() {
|
||||||
async function loadData() {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [zonesData, sectionsData] = await Promise.all([fetchZones(), fetchSections()]);
|
const jrsdList = await fetchJurisdictions();
|
||||||
|
if (cancelled) return;
|
||||||
|
setJurisdictions(jrsdList);
|
||||||
|
// 기본 관할해경: '제주' 또는 첫 항목
|
||||||
|
const defaultJrsd = jrsdList.includes('제주') ? '제주' : jrsdList[0] || '';
|
||||||
|
const [zonesData, sectionsData] = await Promise.all([
|
||||||
|
fetchZones(defaultJrsd),
|
||||||
|
fetchSections({ jurisdiction: defaultJrsd }),
|
||||||
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setZones(zonesData);
|
setZones(zonesData);
|
||||||
setSegments(sectionsData);
|
setSegments(sectionsData);
|
||||||
|
setJurisdictionFilter(defaultJrsd);
|
||||||
if (sectionsData.length > 0) {
|
if (sectionsData.length > 0) {
|
||||||
setSelectedSeg(sectionsData[0]);
|
setSelectedSeg(sectionsData[0]);
|
||||||
}
|
}
|
||||||
@ -47,13 +56,43 @@ export function PreScatView() {
|
|||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadInit();
|
||||||
loadData();
|
return () => { cancelled = true; };
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 관할서 필터 변경 시 zones + sections 재로딩
|
||||||
|
useEffect(() => {
|
||||||
|
// 초기 로딩 시에는 스킵 (위의 useEffect에서 처리)
|
||||||
|
if (jurisdictions.length === 0 || !jurisdictionFilter) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [zonesData, sectionsData] = await Promise.all([
|
||||||
|
fetchZones(jurisdictionFilter),
|
||||||
|
fetchSections({ jurisdiction: jurisdictionFilter }),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setZones(zonesData);
|
||||||
|
setSegments(sectionsData);
|
||||||
|
setAreaFilter('');
|
||||||
|
if (sectionsData.length > 0) {
|
||||||
|
setSelectedSeg(sectionsData[0]);
|
||||||
|
} else {
|
||||||
|
setSelectedSeg(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SCAT] 데이터 재로딩 오류:', err);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reload();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [jurisdictionFilter]);
|
||||||
|
|
||||||
// 선택 구간 변경 시 우측 패널 상세 로딩
|
// 선택 구간 변경 시 우측 패널 상세 로딩
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSeg) {
|
if (!selectedSeg) {
|
||||||
@ -69,13 +108,6 @@ export function PreScatView() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [selectedSeg]);
|
}, [selectedSeg]);
|
||||||
|
|
||||||
// 관할 기반 세그먼트 필터링
|
|
||||||
const filteredSegments = segments.filter((s) => {
|
|
||||||
if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포';
|
|
||||||
if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주';
|
|
||||||
return true; // 전체
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleOpenPopup = useCallback(async (sn: number) => {
|
const handleOpenPopup = useCallback(async (sn: number) => {
|
||||||
try {
|
try {
|
||||||
const detail = await fetchSectionDetail(sn);
|
const detail = await fetchSectionDetail(sn);
|
||||||
@ -92,18 +124,17 @@ export function PreScatView() {
|
|||||||
const handleTimelineSeek = useCallback(
|
const handleTimelineSeek = useCallback(
|
||||||
(idx: number) => {
|
(idx: number) => {
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
// advance signal from play
|
|
||||||
setTimelineIdx((prev) => {
|
setTimelineIdx((prev) => {
|
||||||
const next = (prev + 1) % Math.min(filteredSegments.length, 12);
|
const next = (prev + 1) % Math.min(segments.length, 12);
|
||||||
if (filteredSegments[next]) setSelectedSeg(filteredSegments[next]);
|
if (segments[next]) setSelectedSeg(segments[next]);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setTimelineIdx(idx);
|
setTimelineIdx(idx);
|
||||||
if (filteredSegments[idx]) setSelectedSeg(filteredSegments[idx]);
|
if (segments[idx]) setSelectedSeg(segments[idx]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filteredSegments],
|
[segments],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -131,8 +162,9 @@ export function PreScatView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full h-full bg-bg-0 overflow-hidden">
|
<div className="flex w-full h-full bg-bg-0 overflow-hidden">
|
||||||
<ScatLeftPanel
|
<ScatLeftPanel
|
||||||
segments={filteredSegments}
|
segments={segments}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
|
jurisdictions={jurisdictions}
|
||||||
selectedSeg={selectedSeg}
|
selectedSeg={selectedSeg}
|
||||||
onSelectSeg={setSelectedSeg}
|
onSelectSeg={setSelectedSeg}
|
||||||
onOpenPopup={handleOpenPopup}
|
onOpenPopup={handleOpenPopup}
|
||||||
@ -150,13 +182,15 @@ export function PreScatView() {
|
|||||||
|
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<ScatMap
|
<ScatMap
|
||||||
segments={filteredSegments}
|
segments={segments}
|
||||||
|
zones={zones}
|
||||||
selectedSeg={selectedSeg}
|
selectedSeg={selectedSeg}
|
||||||
|
jurisdictionFilter={jurisdictionFilter}
|
||||||
onSelectSeg={setSelectedSeg}
|
onSelectSeg={setSelectedSeg}
|
||||||
onOpenPopup={handleOpenPopup}
|
onOpenPopup={handleOpenPopup}
|
||||||
/>
|
/>
|
||||||
<ScatTimeline
|
<ScatTimeline
|
||||||
segments={filteredSegments}
|
segments={segments}
|
||||||
currentIdx={timelineIdx}
|
currentIdx={timelineIdx}
|
||||||
onSeek={handleTimelineSeek}
|
onSeek={handleTimelineSeek}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
|
|||||||
interface ScatLeftPanelProps {
|
interface ScatLeftPanelProps {
|
||||||
segments: ScatSegment[];
|
segments: ScatSegment[];
|
||||||
zones: ApiZoneItem[];
|
zones: ApiZoneItem[];
|
||||||
|
jurisdictions: string[];
|
||||||
selectedSeg: ScatSegment;
|
selectedSeg: ScatSegment;
|
||||||
onSelectSeg: (s: ScatSegment) => void;
|
onSelectSeg: (s: ScatSegment) => void;
|
||||||
onOpenPopup: (sn: number) => void;
|
onOpenPopup: (sn: number) => void;
|
||||||
@ -23,6 +24,7 @@ interface ScatLeftPanelProps {
|
|||||||
function ScatLeftPanel({
|
function ScatLeftPanel({
|
||||||
segments,
|
segments,
|
||||||
zones,
|
zones,
|
||||||
|
jurisdictions,
|
||||||
selectedSeg,
|
selectedSeg,
|
||||||
onSelectSeg,
|
onSelectSeg,
|
||||||
onOpenPopup,
|
onOpenPopup,
|
||||||
@ -38,13 +40,7 @@ function ScatLeftPanel({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
}: ScatLeftPanelProps) {
|
}: ScatLeftPanelProps) {
|
||||||
const filtered = segments.filter((s) => {
|
const filtered = segments.filter((s) => {
|
||||||
if (
|
if (areaFilter && !s.area.includes(areaFilter)) return false;
|
||||||
areaFilter !== '전체' &&
|
|
||||||
!s.area.includes(
|
|
||||||
areaFilter.replace('서귀포시 ', '').replace('제주시 ', '').replace(' 해안', ''),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
if (statusFilter !== '전체' && s.status !== statusFilter) return false;
|
if (statusFilter !== '전체' && s.status !== statusFilter) return false;
|
||||||
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false;
|
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false;
|
||||||
return true;
|
return true;
|
||||||
@ -68,9 +64,9 @@ function ScatLeftPanel({
|
|||||||
onChange={(e) => onJurisdictionChange(e.target.value)}
|
onChange={(e) => onJurisdictionChange(e.target.value)}
|
||||||
className="prd-i w-full"
|
className="prd-i w-full"
|
||||||
>
|
>
|
||||||
<option>전체 (제주도)</option>
|
{jurisdictions.map((j) => (
|
||||||
<option>서귀포해양경비안전서</option>
|
<option key={j} value={j}>{j}</option>
|
||||||
<option>제주해양경비안전서</option>
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -83,10 +79,10 @@ function ScatLeftPanel({
|
|||||||
onChange={(e) => onAreaChange(e.target.value)}
|
onChange={(e) => onAreaChange(e.target.value)}
|
||||||
className="prd-i w-full"
|
className="prd-i w-full"
|
||||||
>
|
>
|
||||||
<option>전체</option>
|
<option value="">전체</option>
|
||||||
{zones.map((z) => (
|
{zones.map((z) => (
|
||||||
<option key={z.zoneCd}>
|
<option key={z.zoneCd} value={z.zoneNm}>
|
||||||
{z.jrsdNm === '서귀포' ? '서귀포시' : '제주시'} {z.zoneNm} 해안
|
{z.zoneNm}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
|
|||||||
import type { StyleSpecification } from 'maplibre-gl'
|
import type { StyleSpecification } from 'maplibre-gl'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import type { ScatSegment } from './scatTypes'
|
import type { ScatSegment } from './scatTypes'
|
||||||
|
import type { ApiZoneItem } from '../services/scatApi'
|
||||||
import { esiColor, jejuCoastCoords } from './scatConstants'
|
import { esiColor, jejuCoastCoords } from './scatConstants'
|
||||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
import { hexToRgba } from '@common/components/map/mapUtils'
|
||||||
|
|
||||||
@ -27,7 +28,9 @@ const BASE_STYLE: StyleSpecification = {
|
|||||||
|
|
||||||
interface ScatMapProps {
|
interface ScatMapProps {
|
||||||
segments: ScatSegment[]
|
segments: ScatSegment[]
|
||||||
|
zones: ApiZoneItem[]
|
||||||
selectedSeg: ScatSegment
|
selectedSeg: ScatSegment
|
||||||
|
jurisdictionFilter: string
|
||||||
onSelectSeg: (s: ScatSegment) => void
|
onSelectSeg: (s: ScatSegment) => void
|
||||||
onOpenPopup: (idx: number) => void
|
onOpenPopup: (idx: number) => void
|
||||||
}
|
}
|
||||||
@ -41,10 +44,12 @@ function DeckGLOverlay({ layers }: { layers: any[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
|
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
|
||||||
function FlyToController({ selectedSeg }: { selectedSeg: ScatSegment }) {
|
function FlyToController({ selectedSeg, zones }: { selectedSeg: ScatSegment; zones: ApiZoneItem[] }) {
|
||||||
const { current: map } = useMap()
|
const { current: map } = useMap()
|
||||||
const prevIdRef = useRef<number | undefined>(undefined)
|
const prevIdRef = useRef<number | undefined>(undefined)
|
||||||
|
const prevZonesLenRef = useRef<number>(0)
|
||||||
|
|
||||||
|
// 선택 구간 변경 시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return
|
if (!map) return
|
||||||
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
|
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
|
||||||
@ -53,6 +58,18 @@ function FlyToController({ selectedSeg }: { selectedSeg: ScatSegment }) {
|
|||||||
prevIdRef.current = selectedSeg.id
|
prevIdRef.current = selectedSeg.id
|
||||||
}, [map, selectedSeg])
|
}, [map, selectedSeg])
|
||||||
|
|
||||||
|
// 관할해경(zones) 변경 시 지도 중심 이동
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || zones.length === 0) return
|
||||||
|
if (prevZonesLenRef.current === zones.length) return
|
||||||
|
prevZonesLenRef.current = zones.length
|
||||||
|
const validZones = zones.filter(z => z.latCenter && z.lngCenter)
|
||||||
|
if (validZones.length === 0) return
|
||||||
|
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length
|
||||||
|
const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 0) / validZones.length
|
||||||
|
map.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 })
|
||||||
|
}, [map, zones])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +112,7 @@ interface TooltipState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── ScatMap ─────────────────────────────────────────────
|
// ── ScatMap ─────────────────────────────────────────────
|
||||||
function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapProps) {
|
function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg, onOpenPopup }: ScatMapProps) {
|
||||||
const [zoom, setZoom] = useState(10)
|
const [zoom, setZoom] = useState(10)
|
||||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
||||||
|
|
||||||
@ -236,14 +253,21 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
|
|||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ longitude: 126.55, latitude: 33.38, zoom: 10 }}
|
initialViewState={(() => {
|
||||||
|
if (zones.length > 0) {
|
||||||
|
const avgLng = zones.reduce((a, z) => a + z.lngCenter, 0) / zones.length;
|
||||||
|
const avgLat = zones.reduce((a, z) => a + z.latCenter, 0) / zones.length;
|
||||||
|
return { longitude: avgLng, latitude: avgLat, zoom: 10 };
|
||||||
|
}
|
||||||
|
return { longitude: 126.55, latitude: 33.38, zoom: 10 };
|
||||||
|
})()}
|
||||||
mapStyle={BASE_STYLE}
|
mapStyle={BASE_STYLE}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
onZoom={e => setZoom(e.viewState.zoom)}
|
onZoom={e => setZoom(e.viewState.zoom)}
|
||||||
>
|
>
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
<FlyToController selectedSeg={selectedSeg} />
|
<FlyToController selectedSeg={selectedSeg} zones={zones} />
|
||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
{/* 호버 툴팁 */}
|
{/* 호버 툴팁 */}
|
||||||
@ -284,7 +308,7 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
|
|||||||
Pre-SCAT 사전조사
|
Pre-SCAT 사전조사
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-full text-[11px] text-text-2 font-korean">
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-full text-[11px] text-text-2 font-korean">
|
||||||
제주도 — 해양경비안전서 관할 해안 · {segments.length}개 구간
|
{jurisdictionFilter || '전체'} 관할 해안 · {segments.length}개 구간
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -40,6 +40,7 @@ function PopupMap({
|
|||||||
code,
|
code,
|
||||||
name,
|
name,
|
||||||
esi,
|
esi,
|
||||||
|
onMapLoad,
|
||||||
}: {
|
}: {
|
||||||
lat: number
|
lat: number
|
||||||
lng: number
|
lng: number
|
||||||
@ -47,6 +48,7 @@ function PopupMap({
|
|||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
esi: string
|
esi: string
|
||||||
|
onMapLoad?: () => void
|
||||||
}) {
|
}) {
|
||||||
// 해안 구간 라인 (시뮬레이션) — [lng, lat] 순서
|
// 해안 구간 라인 (시뮬레이션) — [lng, lat] 순서
|
||||||
const segLine: [number, number][] = [
|
const segLine: [number, number][] = [
|
||||||
@ -121,8 +123,9 @@ function PopupMap({
|
|||||||
key={`${lat}-${lng}`}
|
key={`${lat}-${lng}`}
|
||||||
initialViewState={{ longitude: lng, latitude: lat, zoom: 15 }}
|
initialViewState={{ longitude: lng, latitude: lat, zoom: 15 }}
|
||||||
mapStyle={BASE_STYLE}
|
mapStyle={BASE_STYLE}
|
||||||
className="w-full h-full"
|
style={{ width: '100%', height: '100%' }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
|
onLoad={onMapLoad}
|
||||||
>
|
>
|
||||||
<DeckGLOverlay layers={deckLayers} />
|
<DeckGLOverlay layers={deckLayers} />
|
||||||
</Map>
|
</Map>
|
||||||
@ -159,8 +162,14 @@ interface ScatPopupProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||||
|
console.log(segCode,'코드')
|
||||||
|
|
||||||
const [popTab, setPopTab] = useState(0)
|
const [popTab, setPopTab] = useState(0)
|
||||||
|
const [imgLoaded, setImgLoaded] = useState(false)
|
||||||
|
const [imgError, setImgError] = useState(false)
|
||||||
|
const [mapLoaded, setMapLoaded] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@ -236,21 +245,26 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
<div className="flex-1 overflow-y-auto border-r border-border p-5 px-6 scrollbar-thin">
|
<div className="flex-1 overflow-y-auto border-r border-border p-5 px-6 scrollbar-thin">
|
||||||
{/* 해안 조사 사진 */}
|
{/* 해안 조사 사진 */}
|
||||||
<div className="w-full bg-bg-0 border border-border rounded-md mb-4 relative overflow-hidden">
|
<div className="w-full bg-bg-0 border border-border rounded-md mb-4 relative overflow-hidden">
|
||||||
|
{/* Skeleton */}
|
||||||
|
{!imgLoaded && !imgError && (
|
||||||
|
<div className="w-full aspect-video bg-bg-3 animate-pulse flex items-center justify-center">
|
||||||
|
<span className="text-text-3 text-xs font-korean">사진 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<img
|
<img
|
||||||
src={`/scat-photos/${segCode}-1.png`}
|
// src={`/scat/img/${segCode}-1.png`}
|
||||||
|
src={`/scat/img/test.png`}
|
||||||
alt={`${segCode} 해안 조사 사진`}
|
alt={`${segCode} 해안 조사 사진`}
|
||||||
className="w-full h-auto object-contain"
|
className={`w-full h-auto object-contain ${imgLoaded ? '' : 'hidden'}`}
|
||||||
onError={e => {
|
onLoad={() => setImgLoaded(true)}
|
||||||
const target = e.currentTarget
|
onError={() => setImgError(true)}
|
||||||
target.style.display = 'none'
|
|
||||||
const fallback = target.nextElementSibling as HTMLElement
|
|
||||||
if (fallback) fallback.style.display = 'flex'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="w-full aspect-video flex-col items-center justify-center text-text-3 text-xs font-korean hidden">
|
{imgError && (
|
||||||
<span className="text-[40px]">📷</span>
|
<div className="w-full aspect-video flex flex-col items-center justify-center text-text-3 text-xs font-korean">
|
||||||
<span>사진 없음</span>
|
{/* <span className="text-[40px]">📷</span> */}
|
||||||
</div>
|
<span>사진 없음</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="absolute top-2 left-2 px-2 py-0.5 bg-[rgba(10,14,26,0.8)] border border-[rgba(255,255,255,0.1)] rounded text-[10px] font-bold text-white font-mono backdrop-blur-sm">
|
<div className="absolute top-2 left-2 px-2 py-0.5 bg-[rgba(10,14,26,0.8)] border border-[rgba(255,255,255,0.1)] rounded text-[10px] font-bold text-white font-mono backdrop-blur-sm">
|
||||||
{segCode}
|
{segCode}
|
||||||
</div>
|
</div>
|
||||||
@ -359,6 +373,11 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
<div className="flex-1 overflow-y-auto p-5 px-6 scrollbar-thin">
|
<div className="flex-1 overflow-y-auto p-5 px-6 scrollbar-thin">
|
||||||
{/* MapLibre 미니맵 */}
|
{/* MapLibre 미니맵 */}
|
||||||
<div className="w-full aspect-[4/3] bg-bg-0 border border-border rounded-md mb-4 overflow-hidden relative">
|
<div className="w-full aspect-[4/3] bg-bg-0 border border-border rounded-md mb-4 overflow-hidden relative">
|
||||||
|
{!mapLoaded && (
|
||||||
|
<div className="absolute inset-0 bg-bg-3 animate-pulse flex items-center justify-center z-10">
|
||||||
|
<span className="text-text-3 text-xs font-korean">지도 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<PopupMap
|
<PopupMap
|
||||||
lat={data.lat}
|
lat={data.lat}
|
||||||
lng={data.lng}
|
lng={data.lng}
|
||||||
@ -366,6 +385,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
|||||||
esiCol={data.esiColor}
|
esiCol={data.esiColor}
|
||||||
code={data.code}
|
code={data.code}
|
||||||
name={data.name}
|
name={data.name}
|
||||||
|
onMapLoad={() => setMapLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
/* 해안 조사 구역 */
|
||||||
export interface ScatSegment {
|
export interface ScatSegment {
|
||||||
id: number
|
id: number
|
||||||
code: string
|
code: string
|
||||||
|
|||||||
@ -102,8 +102,14 @@ function toScatDetail(item: ApiSectionDetail): ScatDetail {
|
|||||||
// API 호출 함수
|
// API 호출 함수
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export async function fetchZones(): Promise<ApiZoneItem[]> {
|
export async function fetchJurisdictions(): Promise<string[]> {
|
||||||
const { data } = await api.get<ApiZoneItem[]>('/scat/zones');
|
const { data } = await api.get<string[]>('/scat/jurisdictions');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchZones(jurisdiction?: string): Promise<ApiZoneItem[]> {
|
||||||
|
const params = jurisdiction ? `?jurisdiction=${encodeURIComponent(jurisdiction)}` : '';
|
||||||
|
const { data } = await api.get<ApiZoneItem[]>(`/scat/zones${params}`);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,38 +1,101 @@
|
|||||||
interface WeatherData {
|
interface WeatherData {
|
||||||
stationName: string
|
stationName: string;
|
||||||
location: { lat: number; lon: number }
|
location: { lat: number; lon: number };
|
||||||
currentTime: string
|
currentTime: string;
|
||||||
wind: {
|
wind: {
|
||||||
speed: number
|
speed: number;
|
||||||
direction: number
|
direction: number;
|
||||||
speed_1k: number
|
directionLabel: string;
|
||||||
speed_3k: number
|
speed_1k: number;
|
||||||
}
|
speed_3k: number;
|
||||||
|
};
|
||||||
wave: {
|
wave: {
|
||||||
height: number
|
height: number;
|
||||||
period: number
|
maxHeight: number;
|
||||||
}
|
period: number;
|
||||||
|
direction: string;
|
||||||
|
};
|
||||||
temperature: {
|
temperature: {
|
||||||
current: number
|
current: number;
|
||||||
feelsLike: number
|
feelsLike: number;
|
||||||
}
|
};
|
||||||
pressure: number
|
pressure: number;
|
||||||
visibility: number
|
visibility: number;
|
||||||
forecast: WeatherForecast[]
|
salinity: number;
|
||||||
|
astronomy?: {
|
||||||
|
sunrise: string;
|
||||||
|
sunset: string;
|
||||||
|
moonrise: string;
|
||||||
|
moonset: string;
|
||||||
|
moonPhase: string;
|
||||||
|
tidalRange: number;
|
||||||
|
};
|
||||||
|
alert?: string;
|
||||||
|
forecast: WeatherForecast[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeatherForecast {
|
interface WeatherForecast {
|
||||||
time: string
|
time: string;
|
||||||
hour: string
|
hour: string;
|
||||||
icon: string
|
icon: string;
|
||||||
temperature: number
|
temperature: number;
|
||||||
windSpeed: number
|
windSpeed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeatherRightPanelProps {
|
interface WeatherRightPanelProps {
|
||||||
weatherData: WeatherData | null
|
weatherData: WeatherData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Local Helpers (not exported) ─────────────────────────── */
|
||||||
|
|
||||||
|
function WindCompass({ degrees }: { degrees: number }) {
|
||||||
|
// center=28, radius=22
|
||||||
|
return (
|
||||||
|
<svg width="56" height="56" viewBox="0 0 56 56" className="shrink-0">
|
||||||
|
{/* arcs connecting N→E→S→W→N */}
|
||||||
|
<path d="M 28,6 A 22,22 0 0,1 50,28" fill="none" stroke="#1e2a42" strokeWidth="1" />
|
||||||
|
<path d="M 50,28 A 22,22 0 0,1 28,50" fill="none" stroke="#1e2a42" strokeWidth="1" />
|
||||||
|
<path d="M 28,50 A 22,22 0 0,1 6,28" fill="none" stroke="#1e2a42" strokeWidth="1" />
|
||||||
|
<path d="M 6,28 A 22,22 0 0,1 28,6" fill="none" stroke="#1e2a42" strokeWidth="1" />
|
||||||
|
{/* cardinal labels — same color as 풍향/기압 text (#edf0f7 = text-1) */}
|
||||||
|
<text x="28" y="10" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">N</text>
|
||||||
|
<text x="28" y="53" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">S</text>
|
||||||
|
<text x="50" y="31" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">E</text>
|
||||||
|
<text x="6" y="31" textAnchor="middle" fontSize="8" fill="#edf0f7" fontWeight="600">W</text>
|
||||||
|
{/* clock-hand needle */}
|
||||||
|
<g style={{ transform: `rotate(${degrees}deg)`, transformOrigin: '28px 28px' }}>
|
||||||
|
<line x1="28" y1="28" x2="28" y2="10" stroke="#eab308" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<circle cx="28" cy="10" r="2" fill="#eab308" />
|
||||||
|
</g>
|
||||||
|
{/* center dot */}
|
||||||
|
<circle cx="28" cy="28" r="2" fill="#8690a6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ value, max, gradient, label }: { value: number; max: number; gradient: string; label: string }) {
|
||||||
|
const pct = Math.min(100, (value / max) * 100);
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 rounded-full bg-bg-2 overflow-hidden">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: gradient }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-text-3 shrink-0">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ value, label, valueClass = 'text-primary-cyan' }: { value: string; label: string; valueClass?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-0.5">
|
||||||
|
<span className={`text-sm font-bold font-mono ${valueClass}`}>{value}</span>
|
||||||
|
<span className="text-[10px] text-text-3">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Component ───────────────────────────────────────── */
|
||||||
|
|
||||||
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||||
if (!weatherData) {
|
if (!weatherData) {
|
||||||
return (
|
return (
|
||||||
@ -41,233 +104,174 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
|||||||
<p className="text-text-3 text-sm">지도에서 해양 지점을 클릭하세요</p>
|
<p className="text-text-3 text-sm">지도에서 해양 지점을 클릭하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sunriseTime = '07:12'
|
const {
|
||||||
const sunsetTime = '17:58'
|
wind, wave, temperature, pressure, visibility,
|
||||||
const moonrise = '19:35'
|
salinity, astronomy, alert, forecast,
|
||||||
const moonset = '01:50'
|
} = weatherData;
|
||||||
const moonPhase = '상현달 14일'
|
|
||||||
const moonVisibility = '6.7 m'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[380px] shrink-0">
|
<div className="flex flex-col bg-bg-1 border-l border-border overflow-hidden w-[380px] shrink-0">
|
||||||
{/* Header */}
|
{/* ── Header ─────────────────────────────────────────── */}
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-primary-cyan text-sm">📍 {weatherData.stationName}</span>
|
<span className="text-primary-cyan text-sm font-semibold">📍 {weatherData.stationName}</span>
|
||||||
<span className="px-2 py-0.5 text-xs rounded bg-primary-cyan/20 text-primary-cyan">
|
<span className="px-2 py-0.5 text-[10px] rounded bg-primary-cyan/20 text-primary-cyan font-semibold">
|
||||||
기상예보관
|
기상예보관
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-3">
|
<p className="text-[11px] text-text-3 font-mono">
|
||||||
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E · 현지시각{' '}
|
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E · {weatherData.currentTime}
|
||||||
{weatherData.currentTime}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* ── Scrollable Content ─────────────────────────────── */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{/* Wind Speed */}
|
{/* ── Summary Cards ──────────────────────────────── */}
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<span className="text-text-3 text-xs">🌬️ 바람 현황</span>
|
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center">
|
||||||
</div>
|
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none">
|
||||||
<div className="flex items-baseline gap-1">
|
{wind.speed.toFixed(1)}
|
||||||
<span className="text-4xl font-bold text-text-1">{Number(weatherData.wind.speed).toFixed(1)}</span>
|
|
||||||
<span className="text-sm text-text-3 ml-1">m/s</span>
|
|
||||||
<span className="text-xs text-text-3 ml-2">NW 315°</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 mt-3 text-xs">
|
|
||||||
<div>
|
|
||||||
<span className="text-text-3">순간최고</span>
|
|
||||||
<span className="text-text-1 font-semibold ml-2">
|
|
||||||
1k:
|
|
||||||
<span className="text-primary-cyan">{Number(weatherData.wind.speed_1k).toFixed(1)} m/s</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[12px] text-text-3 mt-1">풍속 (m/s)</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center">
|
||||||
<span className="text-text-3">평균</span>
|
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none">
|
||||||
<span className="text-text-1 font-semibold ml-2">
|
{wave.height.toFixed(1)}
|
||||||
3k:
|
|
||||||
<span className="text-status-yellow">{Number(weatherData.wind.speed_3k).toFixed(1)} m/s</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[12px] text-text-3 mt-1">파고 (m)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="bg-bg-2 border border-border rounded-lg p-3 flex flex-col items-center">
|
||||||
<div className="mt-3 text-xs text-text-3">
|
<span className="text-[22px] font-bold text-primary-cyan font-mono leading-none">
|
||||||
<span>기압</span>
|
{temperature.current.toFixed(1)}
|
||||||
<span className="text-text-1 font-semibold ml-2">
|
</span>
|
||||||
{weatherData.pressure} hPa (Fresh)
|
<span className="text-[12px] text-text-3 mt-1">수온 (°C)</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-text-3">
|
|
||||||
<span>가시거리</span>
|
|
||||||
<span className="text-text-1 font-semibold ml-2">{weatherData.visibility} km</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wave Height */}
|
|
||||||
<div className="px-6 py-4 border-b border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<span className="text-text-3 text-xs">🌊 파도</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span className="text-4xl font-bold text-text-1">{Number(weatherData.wave.height).toFixed(1)}</span>
|
|
||||||
<span className="text-sm text-text-3 ml-1">m</span>
|
|
||||||
<span className="text-xs text-primary-cyan ml-2">주기:{weatherData.wave.period}초</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs">
|
|
||||||
<div>
|
|
||||||
<div className="text-text-3">치유파고</div>
|
|
||||||
<div className="text-text-1 font-semibold">{Number(weatherData.wave.height).toFixed(1)} m</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-text-3">최고파고</div>
|
|
||||||
<div className="text-text-1 font-semibold">
|
|
||||||
{(weatherData.wave.height * 1.6).toFixed(1)} m
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-text-3">파향</div>
|
|
||||||
<div className="text-text-1 font-semibold">NW 128°</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-text-3">주기(초)</div>
|
|
||||||
<div className="text-text-1 font-semibold">
|
|
||||||
4 (Moderate)
|
|
||||||
<span className="text-text-3 ml-1"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Temperature */}
|
{/* ── 바람 현황 ──────────────────────────────────── */}
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="text-text-3 mb-3">🏳️ 바람 현황</div>
|
||||||
<span className="text-text-3 text-xs">🌡️ 수온 • 공기</span>
|
<div className="flex gap-4 items-start">
|
||||||
</div>
|
<WindCompass degrees={wind.direction} />
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex-1 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]">
|
||||||
<span className="text-4xl font-bold text-text-1">{Number(weatherData.temperature.current).toFixed(1)}</span>
|
<span className="flex justify-between"><span className="text-text-3">풍향</span><span className="text-text-1 font-semibold font-mono">{wind.directionLabel} {wind.direction}°</span></span>
|
||||||
<span className="text-sm text-text-3">°C</span>
|
<span className="flex justify-between"><span className="text-text-3">기압</span><span className="text-text-1 font-semibold font-mono">{pressure} hPa</span></span>
|
||||||
<span className="text-xs text-text-3 ml-2">체감온도</span>
|
<span className="flex justify-between"><span className="text-text-3">1k 최고</span><span className="text-text-1 font-semibold font-mono">{wind.speed_1k.toFixed(1)}</span></span>
|
||||||
</div>
|
<span className="flex justify-between"><span className="text-text-3">3k 평균</span><span className="text-text-1 font-semibold font-mono">{wind.speed_3k.toFixed(1)}</span></span>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs">
|
<span className="flex justify-between"><span className="text-text-3">가시거리</span><span className="text-text-1 font-semibold font-mono">{visibility} km</span></span>
|
||||||
<div>
|
|
||||||
<div className="text-text-3">기온</div>
|
|
||||||
<div className="text-text-1 font-semibold">2.1 °C</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-text-3">습도</div>
|
|
||||||
<div className="text-text-1 font-semibold">31.2 PSU</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
value={wind.speed}
|
||||||
|
max={20}
|
||||||
|
gradient="linear-gradient(to right, #f97316, #eab308)"
|
||||||
|
label={`${wind.speed.toFixed(1)}/20`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 파도 ───────────────────────────────────────── */}
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<div className="text-[11px] text-text-3 mb-3">🌊 파도</div>
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
<StatCard value={`${wave.height.toFixed(1)}m`} label="유의파고" />
|
||||||
|
<StatCard value={`${wave.maxHeight.toFixed(1)}m`} label="최고파고" />
|
||||||
|
<StatCard value={`${wave.period}s`} label="주기" />
|
||||||
|
<StatCard value={wave.direction} label="파향" />
|
||||||
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
value={wave.height}
|
||||||
|
max={5}
|
||||||
|
gradient="linear-gradient(to right, #f97316, #6b7280)"
|
||||||
|
label={`${wave.height.toFixed(1)}/5m`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 수온 · 공기 ────────────────────────────────── */}
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<div className="text-[11px] text-text-3 mb-3">💧 수온 · 공기</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
<StatCard value={`${temperature.current.toFixed(1)}°`} label="수온" />
|
||||||
|
<StatCard value={`${temperature.feelsLike.toFixed(1)}°`} label="기온" />
|
||||||
|
<StatCard value={`${salinity.toFixed(1)}`} label="염분(PSU)" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hourly Forecast */}
|
{/* ── 시간별 예보 ────────────────────────────────── */}
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="text-[11px] text-text-3 mb-3">🕐 시간별 예보</div>
|
||||||
<span className="text-text-3 text-xs">⏰ 시간별 예보</span>
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
</div>
|
{forecast.map((fc, i) => (
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
{weatherData.forecast.map((forecast, index) => (
|
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={i}
|
||||||
className="flex flex-col items-center p-2 bg-bg-2 border border-border rounded"
|
className="bg-bg-2 border border-border rounded-md p-2 flex flex-col items-center gap-0.5"
|
||||||
>
|
>
|
||||||
<span className="text-xs text-text-3 mb-1">{forecast.hour}</span>
|
<span className="text-[10px] text-text-3">{fc.hour}</span>
|
||||||
<span className="text-2xl mb-1">{forecast.icon}</span>
|
<span className="text-lg">{fc.icon}</span>
|
||||||
<span className="text-sm font-semibold text-text-1 mb-1">
|
<span className="text-sm font-bold text-primary-cyan">{fc.temperature}°</span>
|
||||||
{forecast.temperature}°
|
<span className="text-[10px] text-text-3">{fc.windSpeed}</span>
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-xs text-text-3">🌬️</span>
|
|
||||||
<span className="text-xs text-text-2">{forecast.windSpeed}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sun and Moon */}
|
{/* ── 천문 · 조석 ────────────────────────────────── */}
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-5 py-3 border-b border-border">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="text-[11px] text-text-3 mb-3">☀️ 천문 · 조석</div>
|
||||||
<span className="text-text-3 text-xs">☀️ 천문 • 조석</span>
|
{astronomy && (
|
||||||
</div>
|
<>
|
||||||
<div className="space-y-3">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-base">🌅</span>
|
||||||
<span className="text-xl">🌅</span>
|
<span className="text-[9px] text-text-3">일출</span>
|
||||||
<div className="text-xs">
|
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.sunrise}</span>
|
||||||
<div className="text-text-3">일출</div>
|
</div>
|
||||||
<div className="text-text-1 font-semibold">
|
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
|
||||||
{sunriseTime}
|
<span className="text-base">🌇</span>
|
||||||
<span className="text-text-3 ml-1">(8.6m)</span>
|
<span className="text-[9px] text-text-3">일몰</span>
|
||||||
</div>
|
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.sunset}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-base">🌙</span>
|
||||||
|
<span className="text-[9px] text-text-3">월출</span>
|
||||||
|
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.moonrise}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-bg-2 border border-border rounded-md p-2.5 flex flex-col items-center gap-1">
|
||||||
|
<span className="text-base">🌜</span>
|
||||||
|
<span className="text-[9px] text-text-3">월몰</span>
|
||||||
|
<span className="text-xs font-bold text-text-1 font-mono">{astronomy.moonset}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between mt-2 text-[11px] bg-bg-2 border border-border rounded-md p-2.5">
|
||||||
<span className="text-xl">🌄</span>
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-xs">
|
<span>🌓</span>
|
||||||
<div className="text-text-3">일몰</div>
|
<span className="text-text-3">{astronomy.moonPhase}</span>
|
||||||
<div className="text-text-1 font-semibold">
|
</div>
|
||||||
{sunsetTime}
|
<div className="text-text-3">
|
||||||
<span className="text-text-3 ml-1"></span>
|
조차 <span className="ml-2 text-text-1 font-semibold font-mono">{astronomy.tidalRange}m</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">🌙</span>
|
|
||||||
<div className="text-xs">
|
|
||||||
<div className="text-text-3">월출</div>
|
|
||||||
<div className="text-text-1 font-semibold">
|
|
||||||
{moonrise}
|
|
||||||
<span className="text-text-3 ml-1">(8.8m)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">🌜</span>
|
|
||||||
<div className="text-xs">
|
|
||||||
<div className="text-text-3">월몰</div>
|
|
||||||
<div className="text-text-1 font-semibold">
|
|
||||||
{moonset}
|
|
||||||
<span className="text-text-3 ml-1">(1.8m)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xl">🌓</span>
|
|
||||||
<div className="text-xs">
|
|
||||||
<div className="text-text-3">달</div>
|
|
||||||
<div className="text-text-1 font-semibold">
|
|
||||||
{moonPhase}
|
|
||||||
<span className="text-text-3 ml-2">조차</span>
|
|
||||||
<span className="text-text-1 ml-1">{moonVisibility}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alert */}
|
{/* ── 날씨 특보 ──────────────────────────────────── */}
|
||||||
<div className="px-6 py-4">
|
{alert && (
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="px-5 py-3">
|
||||||
<span className="text-text-3 text-xs">🚨 날씨 특보</span>
|
<div className="text-[11px] text-text-3 mb-3">🚨 날씨 특보</div>
|
||||||
</div>
|
<div className="p-3 bg-status-red/10 border border-status-red/30 rounded-md">
|
||||||
<div className="p-3 bg-status-red/10 border border-status-red/30 rounded">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-start gap-2">
|
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-status-red text-white">주의</span>
|
||||||
<span className="text-status-red text-xs">주의</span>
|
<span className="text-text-1 text-xs">{alert}</span>
|
||||||
<span className="text-text-1 text-xs">풍랑주의보 예상 08:00~</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,14 @@ interface WeatherStation {
|
|||||||
}
|
}
|
||||||
pressure: number
|
pressure: number
|
||||||
visibility: number
|
visibility: number
|
||||||
|
salinity?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARDINAL_LABELS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] as const
|
||||||
|
|
||||||
|
function degreesToCardinal(deg: number): string {
|
||||||
|
const idx = Math.round(((deg % 360) + 360) % 360 / 22.5) % 16
|
||||||
|
return CARDINAL_LABELS[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeatherForecast {
|
interface WeatherForecast {
|
||||||
@ -297,11 +305,28 @@ export function WeatherView() {
|
|||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
}),
|
}),
|
||||||
wind: selectedStation.wind,
|
wind: {
|
||||||
wave: selectedStation.wave,
|
...selectedStation.wind,
|
||||||
|
directionLabel: degreesToCardinal(selectedStation.wind.direction),
|
||||||
|
},
|
||||||
|
wave: {
|
||||||
|
...selectedStation.wave,
|
||||||
|
maxHeight: Number((selectedStation.wave.height * 1.6).toFixed(1)),
|
||||||
|
direction: degreesToCardinal(selectedStation.wind.direction + 45),
|
||||||
|
},
|
||||||
temperature: selectedStation.temperature,
|
temperature: selectedStation.temperature,
|
||||||
pressure: selectedStation.pressure,
|
pressure: selectedStation.pressure,
|
||||||
visibility: selectedStation.visibility,
|
visibility: selectedStation.visibility,
|
||||||
|
salinity: selectedStation.salinity ?? 31.2,
|
||||||
|
astronomy: {
|
||||||
|
sunrise: '07:12',
|
||||||
|
sunset: '17:58',
|
||||||
|
moonrise: '19:35',
|
||||||
|
moonset: '01:50',
|
||||||
|
moonPhase: '상현달 14일',
|
||||||
|
tidalRange: 6.7,
|
||||||
|
},
|
||||||
|
alert: '풍랑주의보 예상 08:00~',
|
||||||
forecast: generateForecast(timeOffset),
|
forecast: generateForecast(timeOffset),
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
|
|||||||
@ -17,6 +17,21 @@ export default defineConfig({
|
|||||||
target: 'https://www.khoa.go.kr',
|
target: 'https://www.khoa.go.kr',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/scat/img': {
|
||||||
|
target: process.env.VITE_SERVER_URL || 'http://211.208.115.83',
|
||||||
|
changeOrigin: true,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq, req) => {
|
||||||
|
console.log(`[scat-img] ${req.url} → ${proxyReq.protocol}//${proxyReq.host}${proxyReq.path}`);
|
||||||
|
});
|
||||||
|
proxy.on('proxyRes', (proxyRes, req) => {
|
||||||
|
console.log(`[scat-img] ${req.url} ← ${proxyRes.statusCode}`);
|
||||||
|
});
|
||||||
|
proxy.on('error', (err, req) => {
|
||||||
|
console.error(`[scat-img] ${req.url} ERROR:`, err.message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
29
prediction/scat/config.py
Normal file
29
prediction/scat/config.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""SCAT PDF 파싱 설정."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
# 이미지 저장 경로
|
||||||
|
IMAGE_OUTPUT_DIR = Path(os.getenv('SCAT_IMAGE_DIR', str(BASE_DIR / 'scat_images')))
|
||||||
|
|
||||||
|
# 파싱 결과 저장 경로
|
||||||
|
OUTPUT_DIR = Path(os.getenv('SCAT_OUTPUT_DIR', str(BASE_DIR / 'output')))
|
||||||
|
|
||||||
|
# DB 설정 (wingDb.ts 기본값과 동일, 추후 사용)
|
||||||
|
DB_HOST = os.getenv('DB_HOST', 'localhost')
|
||||||
|
DB_PORT = int(os.getenv('DB_PORT', '5432'))
|
||||||
|
DB_NAME = os.getenv('DB_NAME', 'wing')
|
||||||
|
DB_USER = os.getenv('DB_USER', 'postgres')
|
||||||
|
DB_PASSWORD = os.getenv('DB_PASSWORD', 'dano2030')
|
||||||
|
DB_SCHEMA = 'wing'
|
||||||
|
|
||||||
|
# 해안 사진 저장 경로 (frontend public)
|
||||||
|
SCAT_PHOTOS_DIR = Path(os.getenv(
|
||||||
|
'SCAT_PHOTOS_DIR',
|
||||||
|
str(BASE_DIR.parent.parent / 'frontend' / 'public' / 'scat-photos'),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Kakao Local API (Geocoding)
|
||||||
|
KAKAO_REST_KEY = os.getenv('KAKAO_REST_KEY', '5e5dc9a10eb86b88d5106e508ec4d236')
|
||||||
180
prediction/scat/db.py
Normal file
180
prediction/scat/db.py
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
"""PostgreSQL 연결 및 SCAT 데이터 upsert."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2 import pool
|
||||||
|
|
||||||
|
import config
|
||||||
|
from models import CoastalSection
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 커넥션 풀 (싱글턴)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_pool: pool.ThreadedConnectionPool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pool() -> pool.ThreadedConnectionPool:
|
||||||
|
global _pool
|
||||||
|
if _pool is None:
|
||||||
|
_pool = pool.ThreadedConnectionPool(
|
||||||
|
minconn=1,
|
||||||
|
maxconn=5,
|
||||||
|
host=config.DB_HOST,
|
||||||
|
port=config.DB_PORT,
|
||||||
|
dbname=config.DB_NAME,
|
||||||
|
user=config.DB_USER,
|
||||||
|
password=config.DB_PASSWORD,
|
||||||
|
options=f'-c search_path={config.DB_SCHEMA},public',
|
||||||
|
)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
return get_pool().getconn()
|
||||||
|
|
||||||
|
|
||||||
|
def put_conn(conn):
|
||||||
|
get_pool().putconn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Zone 관리
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def ensure_zone(zone_cd: str, zone_nm: str, jrsd_nm: str) -> int:
|
||||||
|
"""구역이 없으면 생성, 있으면 SN 반환."""
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
'SELECT cst_srvy_zone_sn FROM cst_srvy_zone WHERE zone_cd = %s',
|
||||||
|
(zone_cd,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'''INSERT INTO cst_srvy_zone (zone_cd, zone_nm, jrsd_nm, sect_cnt)
|
||||||
|
VALUES (%s, %s, %s, 0)
|
||||||
|
RETURNING cst_srvy_zone_sn''',
|
||||||
|
(zone_cd, zone_nm, jrsd_nm),
|
||||||
|
)
|
||||||
|
sn = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
return sn
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def update_zone_sect_count(zone_sn: int):
|
||||||
|
"""구역의 구간 수를 갱신."""
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
'''UPDATE cst_srvy_zone
|
||||||
|
SET sect_cnt = (SELECT count(*) FROM cst_sect WHERE cst_srvy_zone_sn = %s)
|
||||||
|
WHERE cst_srvy_zone_sn = %s''',
|
||||||
|
(zone_sn, zone_sn),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def update_zone_center(zone_sn: int):
|
||||||
|
"""zone의 sections 좌표 평균으로 LAT_CENTER/LNG_CENTER 갱신."""
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
'''UPDATE cst_srvy_zone SET
|
||||||
|
lat_center = sub.avg_lat,
|
||||||
|
lng_center = sub.avg_lng
|
||||||
|
FROM (
|
||||||
|
SELECT AVG(lat) as avg_lat, AVG(lng) as avg_lng
|
||||||
|
FROM cst_sect
|
||||||
|
WHERE cst_srvy_zone_sn = %s AND lat IS NOT NULL
|
||||||
|
) sub
|
||||||
|
WHERE cst_srvy_zone_sn = %s''',
|
||||||
|
(zone_sn, zone_sn),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Section upsert
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def upsert_section(zone_sn: int, section: CoastalSection) -> int:
|
||||||
|
"""구간 INSERT 또는 UPDATE (SECT_CD 기준 ON CONFLICT)."""
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
sensitive = json.dumps(
|
||||||
|
[item.model_dump() for item in section.sensitive_info],
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
cleanup = json.dumps(section.cleanup_methods, ensure_ascii=False)
|
||||||
|
end_crit = json.dumps(section.end_criteria, ensure_ascii=False)
|
||||||
|
notes = json.dumps(section.notes, ensure_ascii=False)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
'''INSERT INTO cst_sect (
|
||||||
|
cst_srvy_zone_sn, sect_cd, sect_nm,
|
||||||
|
cst_tp_cd, esi_cd, esi_num, shore_tp, len_m,
|
||||||
|
lat, lng,
|
||||||
|
access_dc, access_pt,
|
||||||
|
sensitive_info, cleanup_methods, end_criteria, notes,
|
||||||
|
srvy_stts_cd
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s,
|
||||||
|
%s, %s,
|
||||||
|
%s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb,
|
||||||
|
'미조사'
|
||||||
|
)
|
||||||
|
ON CONFLICT (sect_cd) DO UPDATE SET
|
||||||
|
sect_nm = EXCLUDED.sect_nm,
|
||||||
|
cst_tp_cd = EXCLUDED.cst_tp_cd,
|
||||||
|
esi_cd = EXCLUDED.esi_cd,
|
||||||
|
esi_num = EXCLUDED.esi_num,
|
||||||
|
shore_tp = EXCLUDED.shore_tp,
|
||||||
|
len_m = EXCLUDED.len_m,
|
||||||
|
lat = EXCLUDED.lat,
|
||||||
|
lng = EXCLUDED.lng,
|
||||||
|
access_dc = EXCLUDED.access_dc,
|
||||||
|
access_pt = EXCLUDED.access_pt,
|
||||||
|
sensitive_info = EXCLUDED.sensitive_info,
|
||||||
|
cleanup_methods = EXCLUDED.cleanup_methods,
|
||||||
|
end_criteria = EXCLUDED.end_criteria,
|
||||||
|
notes = EXCLUDED.notes
|
||||||
|
RETURNING cst_sect_sn''',
|
||||||
|
(
|
||||||
|
zone_sn, section.sect_cd, section.sect_nm,
|
||||||
|
section.cst_tp_cd, section.esi_cd, section.esi_num,
|
||||||
|
section.shore_tp, section.len_m,
|
||||||
|
section.lat, section.lng,
|
||||||
|
section.access_dc, section.access_pt,
|
||||||
|
sensitive, cleanup, end_crit, notes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sn = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
return sn
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def close_pool():
|
||||||
|
global _pool
|
||||||
|
if _pool:
|
||||||
|
_pool.closeall()
|
||||||
|
_pool = None
|
||||||
65
prediction/scat/esi_mapper.py
Normal file
65
prediction/scat/esi_mapper.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"""cst_tp_cd(해안 구성) → ESI 등급 매핑.
|
||||||
|
|
||||||
|
ESI(Environmental Sensitivity Index) 등급은 해안의 물리적 특성에 따라 분류된다.
|
||||||
|
매핑 기준: NOAA(2010) + 성 등(2003) ESI 등급 표.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
# 키워드 → (esi_cd, esi_num) 매핑 (구체적 키워드 우선)
|
||||||
|
_ESI_RULES: list[tuple[list[str], str, int]] = [
|
||||||
|
# 8B: 습지
|
||||||
|
(['염습지', '습지'], '8B', 8),
|
||||||
|
# 8A: 갯벌/점토질
|
||||||
|
(['갯벌', '점토질', '점토'], '8A', 8),
|
||||||
|
# 7: 반폐쇄형
|
||||||
|
(['반폐쇄', '반 폐쇄'], '7', 7),
|
||||||
|
# 6B: 투과성 인공호안/사석
|
||||||
|
(['투과성 인공호안', '투과성 사석', '투과성 인공해안', '투과성 경사식'], '6B', 6),
|
||||||
|
# 6A: 자갈/바위
|
||||||
|
(['자갈', '왕자갈', '바위', '갈('], '6A', 6),
|
||||||
|
# 5: 모래+자갈 혼합
|
||||||
|
(['모래자갈', '모래+자갈', '혼합'], '5', 5),
|
||||||
|
# 4: 굵은 모래
|
||||||
|
(['굵은 모래', '조립질 모래'], '4', 4),
|
||||||
|
# 3: 세립질 모래/모래(기본)
|
||||||
|
(['세립질 모래', '세립질', '모래'], '3', 3),
|
||||||
|
# 2: 수평암반/비투과성
|
||||||
|
(['수평암반', '수평호안', '기반암', '비투과성 기질', '비투과성 인공호안',
|
||||||
|
'비투과성 인공해안', '노출기반암', '수암반', '콘크리트'], '2', 2),
|
||||||
|
# 1: 수직암반/인공구조물/계류안벽
|
||||||
|
(['수직암반', '인공구조물', '직립호안', '절벽', '수직호안', '수진호안',
|
||||||
|
'수직 계류', '인공호안', '계류 안벽', '안벽'], '1', 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ESI 코드 → 숫자 변환
|
||||||
|
_ESI_NUM_RE = re.compile(r'^(\d+)')
|
||||||
|
|
||||||
|
|
||||||
|
def map_esi(cst_tp_cd: Optional[str]) -> Tuple[Optional[str], Optional[int]]:
|
||||||
|
"""cst_tp_cd(해안 구성 키워드)에서 ESI 등급을 매핑한다.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(esi_cd, esi_num) 튜플. 매핑 실패 시 (None, None).
|
||||||
|
"""
|
||||||
|
if not cst_tp_cd:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
text = cst_tp_cd.strip()
|
||||||
|
for keywords, esi_cd, esi_num in _ESI_RULES:
|
||||||
|
for kw in keywords:
|
||||||
|
if kw in text:
|
||||||
|
return esi_cd, esi_num
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_esi_cd(esi_str: str) -> Tuple[str, int]:
|
||||||
|
"""ESI 등급 문자열(e.g. '8A', '6B', '2')에서 (esi_cd, esi_num) 추출."""
|
||||||
|
esi_cd = esi_str.strip()
|
||||||
|
m = _ESI_NUM_RE.match(esi_cd)
|
||||||
|
esi_num = int(m.group(1)) if m else 0
|
||||||
|
return esi_cd, esi_num
|
||||||
266
prediction/scat/geocoder.py
Normal file
266
prediction/scat/geocoder.py
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
"""Kakao Local API 기반 Geocoding + 오프셋 분산.
|
||||||
|
|
||||||
|
환경변수 KAKAO_REST_KEY 필요.
|
||||||
|
- access_pt 있는 구간: 주소/키워드 검색으로 정확한 좌표
|
||||||
|
- access_pt 없는 구간: sect_nm 기반 대표 좌표 + 나선형 오프셋
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
_HEADERS = {
|
||||||
|
'Authorization': f'KakaoAK {config.KAKAO_REST_KEY}',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kakao API endpoints
|
||||||
|
_ADDRESS_URL = 'https://dapi.kakao.com/v2/local/search/address.json'
|
||||||
|
_KEYWORD_URL = 'https://dapi.kakao.com/v2/local/search/keyword.json'
|
||||||
|
|
||||||
|
# 캐시: query → (lat, lng) or None
|
||||||
|
_cache: dict[str, Optional[Tuple[float, float]]] = {}
|
||||||
|
|
||||||
|
# API 호출 간격 (초)
|
||||||
|
_RATE_LIMIT = 0.05
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Kakao API 호출
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _search_address(query: str) -> Optional[Tuple[float, float]]:
|
||||||
|
"""Kakao 주소 검색 API."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
_ADDRESS_URL,
|
||||||
|
params={'query': query},
|
||||||
|
headers=_HEADERS,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
docs = data.get('documents', [])
|
||||||
|
if docs:
|
||||||
|
return float(docs[0]['y']), float(docs[0]['x'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _search_keyword(query: str) -> Optional[Tuple[float, float]]:
|
||||||
|
"""Kakao 키워드 검색 API."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
_KEYWORD_URL,
|
||||||
|
params={'query': query},
|
||||||
|
headers=_HEADERS,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
docs = data.get('documents', [])
|
||||||
|
if docs:
|
||||||
|
return float(docs[0]['y']), float(docs[0]['x'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 메인 Geocoding 함수
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def geocode(query: str) -> Optional[Tuple[float, float]]:
|
||||||
|
"""주소/키워드 → (lat, lng). 캐시 적용. 실패 시 None."""
|
||||||
|
if not config.KAKAO_REST_KEY:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if query in _cache:
|
||||||
|
return _cache[query]
|
||||||
|
|
||||||
|
time.sleep(_RATE_LIMIT)
|
||||||
|
|
||||||
|
# 1차: 주소 검색 (지번/도로명)
|
||||||
|
result = _search_address(query)
|
||||||
|
|
||||||
|
# 2차: 키워드 검색 (장소명, 방조제, 해수욕장 등)
|
||||||
|
if result is None:
|
||||||
|
time.sleep(_RATE_LIMIT)
|
||||||
|
result = _search_keyword(query)
|
||||||
|
|
||||||
|
_cache[query] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def geocode_section(
|
||||||
|
access_pt: Optional[str],
|
||||||
|
sect_nm: str,
|
||||||
|
zone_name: str,
|
||||||
|
) -> Optional[Tuple[float, float]]:
|
||||||
|
"""구간에 대한 최적 좌표 검색.
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
1. access_pt (주소/지명)
|
||||||
|
2. sect_nm에서 '인근 해안' 제거한 지역명
|
||||||
|
3. zone_name (PDF 구역명)
|
||||||
|
"""
|
||||||
|
# 1. access_pt로 시도
|
||||||
|
if access_pt:
|
||||||
|
result = geocode(access_pt)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
# access_pt + zone_name 조합 시도
|
||||||
|
combined = f'{zone_name} {access_pt}' if zone_name else access_pt
|
||||||
|
result = geocode(combined)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 2. sect_nm에서 지역명 추출
|
||||||
|
area = _extract_area(sect_nm)
|
||||||
|
if area:
|
||||||
|
result = geocode(area)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 3. zone_name fallback
|
||||||
|
if zone_name:
|
||||||
|
result = geocode(zone_name)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_area(sect_nm: str) -> str:
|
||||||
|
"""sect_nm에서 geocoding용 지역명 추출.
|
||||||
|
|
||||||
|
예: '하동군 금남면 노량리 인근 해안' → '하동군 금남면 노량리'
|
||||||
|
"""
|
||||||
|
if not sect_nm:
|
||||||
|
return ''
|
||||||
|
# '인근 해안' 등 제거
|
||||||
|
area = re.sub(r'\s*인근\s*해안\s*$', '', sect_nm).strip()
|
||||||
|
# '해안' 단독 제거
|
||||||
|
area = re.sub(r'\s*해안\s*$', '', area).strip()
|
||||||
|
return area
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 오프셋 분산 (같은 좌표에 겹치는 구간들 분산)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def apply_spiral_offset(
|
||||||
|
base_lat: float,
|
||||||
|
base_lng: float,
|
||||||
|
index: int,
|
||||||
|
spacing: float = 0.0005,
|
||||||
|
) -> Tuple[float, float]:
|
||||||
|
"""나선형 오프셋 적용. index=0이면 원점, 1부터 나선.
|
||||||
|
|
||||||
|
spacing ~= 50m (위도 기준)
|
||||||
|
"""
|
||||||
|
if index == 0:
|
||||||
|
return base_lat, base_lng
|
||||||
|
|
||||||
|
# 나선형: 각도와 반경 증가
|
||||||
|
angle = index * 137.508 * math.pi / 180 # 황금각
|
||||||
|
radius = spacing * math.sqrt(index)
|
||||||
|
|
||||||
|
lat = base_lat + radius * math.cos(angle)
|
||||||
|
lng = base_lng + radius * math.sin(angle)
|
||||||
|
|
||||||
|
return round(lat, 6), round(lng, 6)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 배치 Geocoding
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def geocode_sections(
|
||||||
|
sections: list[dict],
|
||||||
|
zone_name: str = '',
|
||||||
|
) -> Tuple[int, int]:
|
||||||
|
"""섹션 리스트에 lat/lng를 채운다.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(성공 수, 실패 수)
|
||||||
|
"""
|
||||||
|
# sect_nm 그룹별로 처리 (오프셋 적용)
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
groups: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
for s in sections:
|
||||||
|
groups[s.get('sect_nm', '')].append(s)
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
fail = 0
|
||||||
|
|
||||||
|
for sect_nm, group in groups.items():
|
||||||
|
# 그룹 대표 좌표 구하기 (첫 번째 access_pt 있는 구간 또는 sect_nm)
|
||||||
|
base_coord = None
|
||||||
|
|
||||||
|
# access_pt가 있는 구간에서 먼저 시도
|
||||||
|
for s in group:
|
||||||
|
if s.get('access_pt'):
|
||||||
|
coord = geocode_section(s['access_pt'], sect_nm, zone_name)
|
||||||
|
if coord:
|
||||||
|
base_coord = coord
|
||||||
|
break
|
||||||
|
|
||||||
|
# access_pt로 못 찾으면 sect_nm으로
|
||||||
|
if base_coord is None:
|
||||||
|
base_coord = geocode_section(None, sect_nm, zone_name)
|
||||||
|
|
||||||
|
if base_coord is None:
|
||||||
|
fail += len(group)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 그룹 내 구간별 좌표 할당
|
||||||
|
# access_pt가 있는 구간은 개별 geocoding, 없으면 대표+오프셋
|
||||||
|
offset_idx = 0
|
||||||
|
for s in sorted(group, key=lambda x: x.get('section_number', 0)):
|
||||||
|
if s.get('access_pt'):
|
||||||
|
coord = geocode_section(s['access_pt'], sect_nm, zone_name)
|
||||||
|
if coord:
|
||||||
|
s['lat'], s['lng'] = coord
|
||||||
|
success += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 오프셋 적용
|
||||||
|
lat, lng = apply_spiral_offset(base_coord[0], base_coord[1], offset_idx)
|
||||||
|
s['lat'] = lat
|
||||||
|
s['lng'] = lng
|
||||||
|
offset_idx += 1
|
||||||
|
success += 1
|
||||||
|
|
||||||
|
return success, fail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 캐시 저장/로드
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_CACHE_FILE = Path(__file__).parent / 'output' / '.geocode_cache.json'
|
||||||
|
|
||||||
|
|
||||||
|
def save_cache():
|
||||||
|
"""캐시를 파일로 저장."""
|
||||||
|
_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
serializable = {k: list(v) if v else None for k, v in _cache.items()}
|
||||||
|
with open(_CACHE_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(serializable, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def load_cache():
|
||||||
|
"""캐시 파일 로드."""
|
||||||
|
global _cache
|
||||||
|
if _CACHE_FILE.exists():
|
||||||
|
with open(_CACHE_FILE, encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
_cache = {k: tuple(v) if v else None for k, v in data.items()}
|
||||||
124
prediction/scat/image_extractor.py
Normal file
124
prediction/scat/image_extractor.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""PDF에서 해안조사 사진을 추출하여 scat-photos 폴더에 저장.
|
||||||
|
|
||||||
|
Type A (해안사전평가정보집): 1페이지 = 1구간 → 가장 큰 RGB 사진
|
||||||
|
Type B (방제정보집): 1페이지 = 2구간 → RGB 사진 2장, 순서대로 매칭
|
||||||
|
|
||||||
|
저장 네이밍: {sect_cd}-1.png (ScatPopup에서 참조하는 형식)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
from pdf_parser import is_data_page, _CODE_RE
|
||||||
|
from pdf_parser_b import is_data_page_b
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
def _get_page_photos(
|
||||||
|
doc: fitz.Document, page: fitz.Page,
|
||||||
|
) -> List[fitz.Pixmap]:
|
||||||
|
"""페이지에서 RGB 사진만 추출 (배경 제외). 크기 내림차순."""
|
||||||
|
photos: List[Tuple[fitz.Pixmap, int]] = []
|
||||||
|
for img_info in page.get_images(full=True):
|
||||||
|
xref = img_info[0]
|
||||||
|
pix = fitz.Pixmap(doc, xref)
|
||||||
|
# CMYK → RGB
|
||||||
|
if pix.n > 4:
|
||||||
|
pix = fitz.Pixmap(fitz.csRGB, pix)
|
||||||
|
elif pix.n == 4:
|
||||||
|
pix = fitz.Pixmap(fitz.csRGB, pix)
|
||||||
|
# 흑백(n=1) 또는 배경(2000px 이상) 스킵
|
||||||
|
if pix.n < 3 or pix.width >= 2000:
|
||||||
|
continue
|
||||||
|
photos.append((pix, pix.width * pix.height))
|
||||||
|
photos.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
return photos
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_codes_type_a(page: fitz.Page) -> List[str]:
|
||||||
|
"""Type A 페이지에서 sect_cd 추출."""
|
||||||
|
text = page.get_text('text')
|
||||||
|
return _CODE_RE.findall(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_codes_type_b(page: fitz.Page) -> List[str]:
|
||||||
|
"""Type B 페이지에서 sect_cd 추출 (순서 유지)."""
|
||||||
|
text = page.get_text('text')
|
||||||
|
codes = re.findall(r'([A-Z]{4,}-\d+(?:-[A-Z]){3,})', text)
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for c in codes:
|
||||||
|
if c not in seen:
|
||||||
|
seen.add(c)
|
||||||
|
unique.append(c)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _save_pixmap(pix: fitz.Pixmap, output_path: Path):
|
||||||
|
"""Pixmap을 PNG로 저장."""
|
||||||
|
if pix.alpha:
|
||||||
|
pix = fitz.Pixmap(fitz.csRGB, pix)
|
||||||
|
pix.save(str(output_path))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_images_from_pdf(
|
||||||
|
pdf_path: str | Path,
|
||||||
|
output_dir: str | Path | None = None,
|
||||||
|
pdf_type: str = 'A',
|
||||||
|
) -> int:
|
||||||
|
"""PDF에서 데이터 페이지별 대표 사진을 추출.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_path: PDF 파일 경로
|
||||||
|
output_dir: 이미지 저장 경로 (기본: config.SCAT_PHOTOS_DIR)
|
||||||
|
pdf_type: 'A' 또는 'B'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
추출된 이미지 수
|
||||||
|
"""
|
||||||
|
pdf_path = Path(pdf_path)
|
||||||
|
out_dir = Path(output_dir) if output_dir else config.SCAT_PHOTOS_DIR
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
saved = 0
|
||||||
|
|
||||||
|
for i in range(doc.page_count):
|
||||||
|
page = doc[i]
|
||||||
|
|
||||||
|
if pdf_type == 'A':
|
||||||
|
if not is_data_page(page):
|
||||||
|
continue
|
||||||
|
codes = _extract_codes_type_a(page)
|
||||||
|
photos = _get_page_photos(doc, page)
|
||||||
|
if not codes or not photos:
|
||||||
|
continue
|
||||||
|
# 가장 작은 RGB 이미지 = 실제 현장 사진
|
||||||
|
pix = photos[-1][0]
|
||||||
|
out_path = out_dir / f'{codes[0]}-1.png'
|
||||||
|
_save_pixmap(pix, out_path)
|
||||||
|
saved += 1
|
||||||
|
|
||||||
|
elif pdf_type == 'B':
|
||||||
|
if not is_data_page_b(page):
|
||||||
|
continue
|
||||||
|
codes = _extract_codes_type_b(page)
|
||||||
|
photos = _get_page_photos(doc, page)
|
||||||
|
if not codes or not photos:
|
||||||
|
continue
|
||||||
|
# Type B: 사진 크기 동일, 순서대로 매칭
|
||||||
|
for idx, code in enumerate(codes):
|
||||||
|
if idx < len(photos):
|
||||||
|
pix = photos[idx][0]
|
||||||
|
out_path = out_dir / f'{code}-1.png'
|
||||||
|
_save_pixmap(pix, out_path)
|
||||||
|
saved += 1
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
return saved
|
||||||
52
prediction/scat/models.py
Normal file
52
prediction/scat/models.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""SCAT PDF 파싱 데이터 모델."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SensitiveItem(BaseModel):
|
||||||
|
"""민감자원 항목."""
|
||||||
|
t: str # 유형 (사회경제적 / 생물자원)
|
||||||
|
v: str # 내용
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoInfo(BaseModel):
|
||||||
|
"""추출된 사진 정보."""
|
||||||
|
filename: str
|
||||||
|
page: int
|
||||||
|
index: int
|
||||||
|
|
||||||
|
|
||||||
|
class CoastalSection(BaseModel):
|
||||||
|
"""해안 구간 1건 (PDF 1페이지)."""
|
||||||
|
section_number: int = 0
|
||||||
|
sect_nm: str = '' # 지역명
|
||||||
|
sect_cd: str = '' # 코드명 (SSDD-1)
|
||||||
|
esi_cd: Optional[str] = None # ESI 등급 (1, 2, 3, 6A, 6B, 8A, 8B 등)
|
||||||
|
esi_num: Optional[int] = None # ESI 숫자 (1~8)
|
||||||
|
shore_tp: Optional[str] = None # 해안 형태 (폐쇄형/개방형)
|
||||||
|
cst_tp_cd: Optional[str] = None # 해안 구성 (투과성 인공호안, 모래 등)
|
||||||
|
len_m: Optional[float] = None # 해안길이 (m)
|
||||||
|
width_m: Optional[float] = None # 해안 폭 (m, 선택)
|
||||||
|
lat: Optional[float] = None # 위도
|
||||||
|
lng: Optional[float] = None # 경도
|
||||||
|
access_dc: Optional[str] = None # 접근방법 설명
|
||||||
|
access_pt: Optional[str] = None # 주요접근지점
|
||||||
|
sensitive_info: List[SensitiveItem] = [] # 민감자원
|
||||||
|
cleanup_methods: List[str] = [] # 권장 방제 방법
|
||||||
|
end_criteria: List[str] = [] # 권장 방제 중지 기준
|
||||||
|
notes: List[str] = [] # 해안 방제시 고려사항
|
||||||
|
photos: List[PhotoInfo] = [] # 추출된 사진
|
||||||
|
|
||||||
|
|
||||||
|
class ParseResult(BaseModel):
|
||||||
|
"""PDF 파싱 전체 결과."""
|
||||||
|
pdf_filename: str
|
||||||
|
zone_name: str = '' # PDF 헤더에서 추출한 구역명
|
||||||
|
jurisdiction: str = '' # 관할 (보령 해양경비안전서 등)
|
||||||
|
total_sections: int = 0
|
||||||
|
sections: List[CoastalSection] = []
|
||||||
|
skipped_pages: int = 0
|
||||||
8758
prediction/scat/output/.geocode_cache.json
Normal file
8758
prediction/scat/output/.geocode_cache.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2936
prediction/scat/output/01_(완료)여수해경서_방제정보집_(하동군-광양시).json
Normal file
2936
prediction/scat/output/01_(완료)여수해경서_방제정보집_(하동군-광양시).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
9300
prediction/scat/output/02_여수해경서_방제정보집_(여수시 율촌산업단지-화양면 안포리).json
Normal file
9300
prediction/scat/output/02_여수해경서_방제정보집_(여수시 율촌산업단지-화양면 안포리).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
8932
prediction/scat/output/03_여수해경서_방제정보집_(여수시 돌산읍).json
Normal file
8932
prediction/scat/output/03_여수해경서_방제정보집_(여수시 돌산읍).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
5561
prediction/scat/output/04_여수해경서_방제정보집_(여수시 백야도- 율촌면 상봉리).json
Normal file
5561
prediction/scat/output/04_여수해경서_방제정보집_(여수시 백야도- 율촌면 상봉리).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
3245
prediction/scat/output/05_여수해경서_방제정보집_(순천시-보성군).json
Normal file
3245
prediction/scat/output/05_여수해경서_방제정보집_(순천시-보성군).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
6665
prediction/scat/output/06_여수해경서_방제정보집_(고흥군 동강면 죽암리- 포두면 남성리).json
Normal file
6665
prediction/scat/output/06_여수해경서_방제정보집_(고흥군 동강면 죽암리- 포두면 남성리).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
5031
prediction/scat/output/07_여수해경서_방제정보집_(고흥군 나로도).json
Normal file
5031
prediction/scat/output/07_여수해경서_방제정보집_(고흥군 나로도).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
7818
prediction/scat/output/07_충남 서천군해안사전평가정보집.json
Normal file
7818
prediction/scat/output/07_충남 서천군해안사전평가정보집.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
12685
prediction/scat/output/08_여수해경서_방제정보집_(고흥군 도화면 덕중리-대서면 안남리)(1).json
Normal file
12685
prediction/scat/output/08_여수해경서_방제정보집_(고흥군 도화면 덕중리-대서면 안남리)(1).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
6965
prediction/scat/output/08_전북 군산시해안사전평가정보집.json
Normal file
6965
prediction/scat/output/08_전북 군산시해안사전평가정보집.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
5626
prediction/scat/output/09_여수해경서 방제정보집_(남해군).json
Normal file
5626
prediction/scat/output/09_여수해경서 방제정보집_(남해군).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
6947
prediction/scat/output/09_전북 부안군해안사전평가정보집.json
Normal file
6947
prediction/scat/output/09_전북 부안군해안사전평가정보집.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
4466
prediction/scat/output/10_전북 고창군해안사전평가정보집.json
Normal file
4466
prediction/scat/output/10_전북 고창군해안사전평가정보집.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
15942
prediction/scat/output/F03_목포해경(신안군 흑산).json
Normal file
15942
prediction/scat/output/F03_목포해경(신안군 흑산).json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
25563
prediction/scat/output/F03_무안군_01.json
Normal file
25563
prediction/scat/output/F03_무안군_01.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
17854
prediction/scat/output/F04_신안군_01.json
Normal file
17854
prediction/scat/output/F04_신안군_01.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
6434
prediction/scat/output/F05_목포시영암군_01.json
Normal file
6434
prediction/scat/output/F05_목포시영암군_01.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
17894
prediction/scat/output/F07_해남군_01.json
Normal file
17894
prediction/scat/output/F07_해남군_01.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
15360
prediction/scat/output/F07_해남군_02.json
Normal file
15360
prediction/scat/output/F07_해남군_02.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
26057
prediction/scat/output/F08_완도군_01.json
Normal file
26057
prediction/scat/output/F08_완도군_01.json
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
390
prediction/scat/pdf_parser.py
Normal file
390
prediction/scat/pdf_parser.py
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
"""PDF 텍스트 파싱 — 상태머신 방식으로 해안사전평가 정보를 추출한다."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from enum import Enum, auto
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
from models import CoastalSection, SensitiveItem, ParseResult
|
||||||
|
from esi_mapper import map_esi
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 상태머신 상태
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class State(Enum):
|
||||||
|
HEADER = auto()
|
||||||
|
GENERAL = auto() # 해안 일반특성
|
||||||
|
ACCESS = auto() # 접근방법
|
||||||
|
SENSITIVE = auto() # 민감자원 정보
|
||||||
|
CLEANUP = auto() # 권장 방제 방법
|
||||||
|
END_CRITERIA = auto() # 권장 방제 중지 기준
|
||||||
|
CONSIDER = auto() # 해안 방제시 고려사항
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 섹션 시작 키워드 → 상태 매핑
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_SECTION_KEYWORDS: list[tuple[str, State]] = [
|
||||||
|
('해안 일반특성', State.GENERAL),
|
||||||
|
('해안 일반 특성', State.GENERAL),
|
||||||
|
('접근방법', State.ACCESS),
|
||||||
|
('접근 방법', State.ACCESS),
|
||||||
|
('민감자원 정보', State.SENSITIVE),
|
||||||
|
('민감자원정보', State.SENSITIVE),
|
||||||
|
('권장 방제 방법', State.CLEANUP),
|
||||||
|
('권장방제방법', State.CLEANUP),
|
||||||
|
('권 장 방 제 방 법', State.CLEANUP),
|
||||||
|
('권장 방제 중지 기준', State.END_CRITERIA),
|
||||||
|
('권장방제중지기준', State.END_CRITERIA),
|
||||||
|
('권 장 방 제 중 지 기 준', State.END_CRITERIA),
|
||||||
|
('해안 방제시 고려사항', State.CONSIDER),
|
||||||
|
('해안 방제 시 고려사항', State.CONSIDER),
|
||||||
|
('해안방제시 고려사항', State.CONSIDER),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 코드 패턴: SSDD-1, BRSM-12, DDIS-1 등
|
||||||
|
_CODE_RE = re.compile(r'\(([A-Z]{2,}-\d+)\)')
|
||||||
|
_NAME_CODE_RE = re.compile(r'(.+?)\s*\(([A-Z]{2,}-\d+)\)')
|
||||||
|
_LENGTH_RE = re.compile(r'약\s*([\d,]+\.?\d*)\s*m\s*임?')
|
||||||
|
_WIDTH_RE = re.compile(r'폭[은는]?\s*약\s*([\d,]+\.?\d*)\s*m')
|
||||||
|
_NUMBER_RE = re.compile(r'^\d+$')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 유틸 함수
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _clean_bullet(line: str) -> str:
|
||||||
|
"""불릿 접두사(Ÿ, ·, •, -) 제거 후 strip."""
|
||||||
|
return line.lstrip('Ÿ \t·•- ').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_bullet(line: str) -> bool:
|
||||||
|
"""불릿으로 시작하는 줄인지 확인."""
|
||||||
|
stripped = line.strip()
|
||||||
|
return stripped.startswith('Ÿ') or stripped.startswith('·') or stripped.startswith('•')
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sub_bullet(line: str) -> bool:
|
||||||
|
"""서브 불릿(- 접두사)으로 시작하는 줄인지 확인."""
|
||||||
|
stripped = line.strip()
|
||||||
|
return stripped.startswith('-') and len(stripped) > 1
|
||||||
|
|
||||||
|
|
||||||
|
def _is_end_criteria_item(text: str) -> bool:
|
||||||
|
"""방제 중지 기준 항목인지 판별 (조건문 패턴)."""
|
||||||
|
criteria_patterns = [
|
||||||
|
'없어야', '않아야', '미만', '이하', '이상',
|
||||||
|
'분포해야', '발생하지',
|
||||||
|
]
|
||||||
|
return any(p in text for p in criteria_patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_measurement(text: str, pattern: re.Pattern) -> float | None:
|
||||||
|
"""정규식으로 수치 추출."""
|
||||||
|
m = pattern.search(text)
|
||||||
|
if m:
|
||||||
|
return float(m.group(1).replace(',', ''))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_section_keyword(line: str) -> State | None:
|
||||||
|
"""줄이 섹션 시작 키워드를 포함하는지 확인."""
|
||||||
|
normalized = line.replace(' ', '')
|
||||||
|
for keyword, state in _SECTION_KEYWORDS:
|
||||||
|
if keyword.replace(' ', '') in normalized:
|
||||||
|
return state
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 데이터 페이지 판별
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def is_data_page(page: fitz.Page) -> bool:
|
||||||
|
"""데이터 페이지인지 판별 — 코드 패턴 + 키워드 존재 여부."""
|
||||||
|
text = page.get_text('text')
|
||||||
|
has_code = bool(_CODE_RE.search(text))
|
||||||
|
has_keyword = '일반특성' in text or '접근방법' in text or '방제 방법' in text
|
||||||
|
return has_code and has_keyword
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 단일 페이지 파싱
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _merge_bullet_lines(raw_lines: list) -> list:
|
||||||
|
"""F-series 형식: 'Ÿ' 단독 줄 + 다음 줄 텍스트를 병합."""
|
||||||
|
merged = []
|
||||||
|
i = 0
|
||||||
|
while i < len(raw_lines):
|
||||||
|
line = raw_lines[i].strip()
|
||||||
|
if line == 'Ÿ' and i + 1 < len(raw_lines):
|
||||||
|
# 다음 줄과 병합
|
||||||
|
merged.append('Ÿ ' + raw_lines[i + 1].strip())
|
||||||
|
i += 2
|
||||||
|
elif line:
|
||||||
|
merged.append(line)
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def parse_page(page: fitz.Page) -> CoastalSection | None:
|
||||||
|
"""데이터 페이지에서 CoastalSection 추출."""
|
||||||
|
text = page.get_text('text')
|
||||||
|
raw_lines = text.split('\n')
|
||||||
|
lines = _merge_bullet_lines(raw_lines)
|
||||||
|
|
||||||
|
section = CoastalSection()
|
||||||
|
state = State.HEADER
|
||||||
|
|
||||||
|
# 현재 섹션에 수집 중인 불릿 항목들
|
||||||
|
current_bullets: list[str] = []
|
||||||
|
# 민감자원 서브 섹션 추적
|
||||||
|
sensitive_sub: str = ''
|
||||||
|
sensitive_items: list[SensitiveItem] = []
|
||||||
|
# 방제방법+중지기준 두 컬럼 병합 모드
|
||||||
|
cleanup_merged = False
|
||||||
|
|
||||||
|
def _flush_bullets():
|
||||||
|
"""현재 상태의 불릿을 section에 반영."""
|
||||||
|
nonlocal current_bullets, sensitive_sub
|
||||||
|
if state == State.GENERAL:
|
||||||
|
_parse_general(section, current_bullets)
|
||||||
|
elif state == State.ACCESS:
|
||||||
|
_parse_access(section, current_bullets)
|
||||||
|
elif state == State.SENSITIVE:
|
||||||
|
if sensitive_sub and current_bullets:
|
||||||
|
sensitive_items.append(SensitiveItem(
|
||||||
|
t=sensitive_sub,
|
||||||
|
v='\n'.join(current_bullets),
|
||||||
|
))
|
||||||
|
elif state == State.CLEANUP:
|
||||||
|
section.cleanup_methods = current_bullets[:]
|
||||||
|
elif state == State.END_CRITERIA:
|
||||||
|
# 병합 모드: 불릿을 방제방법/중지기준으로 분류
|
||||||
|
if cleanup_merged:
|
||||||
|
_split_cleanup_and_criteria(section, current_bullets)
|
||||||
|
else:
|
||||||
|
section.end_criteria = current_bullets[:]
|
||||||
|
elif state == State.CONSIDER:
|
||||||
|
section.notes = current_bullets[:]
|
||||||
|
current_bullets = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# 페이지 헤더/푸터 스킵
|
||||||
|
if '해양경비안전서' in line and ('관할' in line or '정보집' in line):
|
||||||
|
continue
|
||||||
|
if '해안사전평가 정보' in line and '∙' in line:
|
||||||
|
continue
|
||||||
|
if '해양경찰서' in line and ('관할' in line or '정보집' in line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 섹션 전환 감지
|
||||||
|
new_state = _detect_section_keyword(line)
|
||||||
|
if new_state and new_state != state:
|
||||||
|
# 방제방법→중지기준 헤더가 연속 (두 컬럼 레이아웃)
|
||||||
|
if state == State.CLEANUP and new_state == State.END_CRITERIA and not current_bullets:
|
||||||
|
cleanup_merged = True
|
||||||
|
state = State.END_CRITERIA
|
||||||
|
continue
|
||||||
|
_flush_bullets()
|
||||||
|
state = new_state
|
||||||
|
sensitive_sub = ''
|
||||||
|
continue
|
||||||
|
|
||||||
|
# HEADER 상태: 번호 + 지역명/코드명 추출
|
||||||
|
if state == State.HEADER:
|
||||||
|
if _NUMBER_RE.match(line):
|
||||||
|
section.section_number = int(line)
|
||||||
|
continue
|
||||||
|
m = _NAME_CODE_RE.search(line)
|
||||||
|
if m:
|
||||||
|
section.sect_nm = m.group(1).strip()
|
||||||
|
section.sect_cd = m.group(2).strip()
|
||||||
|
continue
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 민감자원: 서브 섹션 감지 (Ÿ 불릿 또는 일반 텍스트)
|
||||||
|
if state == State.SENSITIVE:
|
||||||
|
cleaned_for_check = _clean_bullet(line) if _is_bullet(line) else line
|
||||||
|
if '경제적' in cleaned_for_check and '자원' in cleaned_for_check:
|
||||||
|
if sensitive_sub and current_bullets:
|
||||||
|
sensitive_items.append(SensitiveItem(
|
||||||
|
t=sensitive_sub, v='\n'.join(current_bullets),
|
||||||
|
))
|
||||||
|
sensitive_sub = '사회경제적'
|
||||||
|
current_bullets = []
|
||||||
|
continue
|
||||||
|
if '생물자원' in cleaned_for_check:
|
||||||
|
if sensitive_sub and current_bullets:
|
||||||
|
sensitive_items.append(SensitiveItem(
|
||||||
|
t=sensitive_sub, v='\n'.join(current_bullets),
|
||||||
|
))
|
||||||
|
sensitive_sub = '생물자원'
|
||||||
|
current_bullets = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 불릿 항목 수집
|
||||||
|
if _is_bullet(line):
|
||||||
|
current_bullets.append(_clean_bullet(line))
|
||||||
|
elif _is_sub_bullet(line):
|
||||||
|
# "-" 접두사 서브 항목 (민감자원 상세 등)
|
||||||
|
cleaned = line.strip().lstrip('-').strip()
|
||||||
|
if cleaned:
|
||||||
|
current_bullets.append(cleaned)
|
||||||
|
elif current_bullets and line and not _detect_section_keyword(line):
|
||||||
|
# 연속행 (불릿 없이 이어지는 텍스트)
|
||||||
|
cleaned = line.strip()
|
||||||
|
if cleaned:
|
||||||
|
current_bullets[-1] += ' ' + cleaned
|
||||||
|
|
||||||
|
# 마지막 섹션 flush
|
||||||
|
_flush_bullets()
|
||||||
|
section.sensitive_info = sensitive_items
|
||||||
|
|
||||||
|
if not section.sect_cd:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ESI 등급 매핑 (cst_tp_cd 기반)
|
||||||
|
if section.cst_tp_cd:
|
||||||
|
section.esi_cd, section.esi_num = map_esi(section.cst_tp_cd)
|
||||||
|
|
||||||
|
return section
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 섹션별 파싱 헬퍼
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _parse_general(section: CoastalSection, bullets: list[str]):
|
||||||
|
"""해안 일반특성 불릿에서 shore_tp, cst_tp_cd, len_m, width_m 추출."""
|
||||||
|
for b in bullets:
|
||||||
|
if '형태' in b:
|
||||||
|
if '폐쇄' in b:
|
||||||
|
section.shore_tp = '폐쇄형'
|
||||||
|
elif '개방' in b:
|
||||||
|
section.shore_tp = '개방형'
|
||||||
|
elif '반폐쇄' in b or '반 폐쇄' in b:
|
||||||
|
section.shore_tp = '반폐쇄형'
|
||||||
|
elif '이루어져' in b or '조성' in b or '으로 이루어' in b:
|
||||||
|
# 해안 구성 추출
|
||||||
|
section.cst_tp_cd = _extract_coastal_type(b)
|
||||||
|
length = _parse_measurement(b, _LENGTH_RE)
|
||||||
|
if length and not section.len_m:
|
||||||
|
section.len_m = length
|
||||||
|
width = _parse_measurement(b, _WIDTH_RE)
|
||||||
|
if width:
|
||||||
|
section.width_m = width
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_coastal_type(text: str) -> str:
|
||||||
|
"""해안 구성 유형 추출."""
|
||||||
|
types = [
|
||||||
|
'투과성 인공호안', '비투과성 인공호안', '인공호안',
|
||||||
|
'모래', '세립질 모래', '굵은 모래',
|
||||||
|
'자갈', '수직암반', '수평암반',
|
||||||
|
'갯벌', '습지', '사석',
|
||||||
|
'콘크리트', '테트라포드',
|
||||||
|
]
|
||||||
|
for t in types:
|
||||||
|
if t in text:
|
||||||
|
return t
|
||||||
|
# fallback: "해안은 XXX으로 이루어져" 패턴
|
||||||
|
m = re.search(r'해안은\s+(.+?)(?:으로|로)\s*이루어져', text)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _split_cleanup_and_criteria(section: CoastalSection, bullets: list[str]):
|
||||||
|
"""두 컬럼이 병합된 불릿을 방제방법/중지기준으로 분류."""
|
||||||
|
cleanup = []
|
||||||
|
criteria = []
|
||||||
|
for b in bullets:
|
||||||
|
if _is_end_criteria_item(b):
|
||||||
|
criteria.append(b)
|
||||||
|
else:
|
||||||
|
cleanup.append(b)
|
||||||
|
section.cleanup_methods = cleanup
|
||||||
|
section.end_criteria = criteria
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_access(section: CoastalSection, bullets: list[str]):
|
||||||
|
"""접근방법 불릿에서 access_dc, access_pt 추출."""
|
||||||
|
access_parts = []
|
||||||
|
for b in bullets:
|
||||||
|
if '주요접근지점' in b or '주요 접근지점' in b or '주요접근 지점' in b:
|
||||||
|
# "주요접근지점 : 부사방조제" 패턴
|
||||||
|
parts = re.split(r'[::]', b, maxsplit=1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
section.access_pt = parts[1].strip()
|
||||||
|
else:
|
||||||
|
section.access_pt = b.replace('주요접근지점', '').strip()
|
||||||
|
else:
|
||||||
|
access_parts.append(b)
|
||||||
|
if access_parts:
|
||||||
|
section.access_dc = ' / '.join(access_parts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 전체 PDF 파싱
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def parse_pdf(pdf_path: str | Path) -> ParseResult:
|
||||||
|
"""PDF 전체를 파싱하여 ParseResult 반환."""
|
||||||
|
pdf_path = Path(pdf_path)
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
|
||||||
|
result = ParseResult(
|
||||||
|
pdf_filename=pdf_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 관할/구역명 추출 시도 (첫 30페이지 탐색)
|
||||||
|
for i in range(min(35, doc.page_count)):
|
||||||
|
text = doc[i].get_text('text')
|
||||||
|
if '관할' in text and '해안' in text:
|
||||||
|
# "보령 해양경비안전서 관할" 패턴
|
||||||
|
m = re.search(r'(\S+\s*해양경[비찰]\S*)\s*관할', text)
|
||||||
|
if m and not result.jurisdiction:
|
||||||
|
result.jurisdiction = m.group(1).strip()
|
||||||
|
# 구역명: "X. 충남 서천군 해안 사전평가 정보" 패턴
|
||||||
|
m = re.search(r'\d+\.\s*(.+?)\s*해안\s*사전평가', text)
|
||||||
|
if m and not result.zone_name:
|
||||||
|
result.zone_name = m.group(1).strip()
|
||||||
|
if result.jurisdiction and result.zone_name:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 데이터 페이지 파싱
|
||||||
|
skipped = 0
|
||||||
|
for i in range(doc.page_count):
|
||||||
|
page = doc[i]
|
||||||
|
if not is_data_page(page):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
section = parse_page(page)
|
||||||
|
if section:
|
||||||
|
result.sections.append(section)
|
||||||
|
|
||||||
|
result.total_sections = len(result.sections)
|
||||||
|
result.skipped_pages = skipped
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI 실행
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print('Usage: python parser.py <pdf_path>')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
r = parse_pdf(sys.argv[1])
|
||||||
|
print(json.dumps(r.model_dump(), ensure_ascii=False, indent=2))
|
||||||
341
prediction/scat/pdf_parser_b.py
Normal file
341
prediction/scat/pdf_parser_b.py
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
"""PDF 텍스트 파싱 — Type B (방제정보집) 형식.
|
||||||
|
|
||||||
|
여수해경서 등의 '방제정보집' PDF에서 해안 구간 정보를 추출한다.
|
||||||
|
Type A(해안사전평가정보집)와 다른 레이아웃:
|
||||||
|
- 코드: HDPJ-1-M-E-R-P-L (괄호 없음)
|
||||||
|
- 페이지당 2개 구간
|
||||||
|
- '식별자 코드명' 라벨로 구간 시작
|
||||||
|
- '민감자원' 섹션 없음
|
||||||
|
- '초기 방제 및 고려사항' 섹션
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
from models import CoastalSection, ParseResult
|
||||||
|
from esi_mapper import parse_esi_cd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 정규식 패턴
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HDPJ-1-M-E-R-P-L, HDDH-10-MI-E-R-P-L 등
|
||||||
|
_CODE_RE_B = re.compile(r'([A-Z]{2,}-\d+(?:-[A-Z]{1,2}){0,5}(?:-[A-Z])*)')
|
||||||
|
_ESI_RE = re.compile(r'ESI\s*등급\s*[::]\s*(\d+[A-Z]?)')
|
||||||
|
_LENGTH_RE = re.compile(r'([\d,.]+)\s*/\s*(-|[\d,.]+(?:\.\d+)?)')
|
||||||
|
_NUMBER_RE = re.compile(r'^(\d{1,3})$')
|
||||||
|
_SECTION_START_RE = re.compile(r'식별자\s+코드명')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 유틸
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _clean_bullet(line: str) -> str:
|
||||||
|
return line.lstrip('Ÿ \t·•- ').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_bullet(line: str) -> bool:
|
||||||
|
s = line.strip()
|
||||||
|
return s.startswith('Ÿ') or s.startswith('·') or s.startswith('•')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 데이터 페이지 판별
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def is_data_page_b(page: fitz.Page) -> bool:
|
||||||
|
text = page.get_text('text')
|
||||||
|
has_identifier = bool(_SECTION_START_RE.search(text))
|
||||||
|
has_code = bool(_CODE_RE_B.search(text))
|
||||||
|
return has_identifier and has_code
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 단일 구간 블록 파싱
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _parse_section_block(lines: list[str], area_name: str) -> CoastalSection | None:
|
||||||
|
"""식별자 코드명 ~ 다음 식별자 코드명 사이의 텍스트 블록을 파싱."""
|
||||||
|
section = CoastalSection()
|
||||||
|
section.sect_nm = area_name
|
||||||
|
|
||||||
|
# Phase: code → general → cleanup → end_criteria → consider
|
||||||
|
phase = 'code'
|
||||||
|
cleanup_items: list[str] = []
|
||||||
|
end_criteria_items: list[str] = []
|
||||||
|
consider_items: list[str] = []
|
||||||
|
current_target: list[str] | None = None
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i].strip()
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 코드 추출
|
||||||
|
if phase == 'code':
|
||||||
|
m = _CODE_RE_B.search(line)
|
||||||
|
if m:
|
||||||
|
section.sect_cd = m.group(1)
|
||||||
|
phase = 'general'
|
||||||
|
else:
|
||||||
|
# 구간 번호
|
||||||
|
nm = _NUMBER_RE.match(line)
|
||||||
|
if nm:
|
||||||
|
section.section_number = int(nm.group(1))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 해안 형태/저질 특성 + ESI + 길이
|
||||||
|
if phase == 'general':
|
||||||
|
# 형태
|
||||||
|
if '형태' in line and ':' in line:
|
||||||
|
val = line.split(':', 1)[1].strip().split(':')[-1].strip()
|
||||||
|
if val:
|
||||||
|
section.shore_tp = val
|
||||||
|
continue
|
||||||
|
# 퇴적물
|
||||||
|
if '퇴적물' in line and ':' in line:
|
||||||
|
val = line.split(':', 1)[1].strip().split(':')[-1].strip()
|
||||||
|
if val:
|
||||||
|
section.cst_tp_cd = val
|
||||||
|
continue
|
||||||
|
# ESI
|
||||||
|
m = _ESI_RE.search(line)
|
||||||
|
if m:
|
||||||
|
section.esi_cd, section.esi_num = parse_esi_cd(m.group(1))
|
||||||
|
continue
|
||||||
|
# 길이/폭
|
||||||
|
m = _LENGTH_RE.search(line)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
section.len_m = float(m.group(1).replace(',', ''))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
width_str = m.group(2)
|
||||||
|
if width_str != '-':
|
||||||
|
# '2차선 도로' 등 비숫자 후속 방지
|
||||||
|
end_pos = m.end(2)
|
||||||
|
after = line[end_pos:end_pos + 1] if end_pos < len(line) else ''
|
||||||
|
if not after or after in (' ', '\t', '\n', ')', ''):
|
||||||
|
try:
|
||||||
|
section.width_m = float(width_str.replace(',', ''))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
# 접근성 — þ 마커들은 접근성 열에 해당
|
||||||
|
if 'þ' in line:
|
||||||
|
continue
|
||||||
|
# 섹션 전환 감지
|
||||||
|
normalized = line.replace(' ', '')
|
||||||
|
if '권장방제방법' in normalized:
|
||||||
|
phase = 'cleanup'
|
||||||
|
current_target = cleanup_items
|
||||||
|
continue
|
||||||
|
if '접근성' in normalized or '차량' in normalized or '도로' in normalized or '도보' in normalized or '선박' in normalized:
|
||||||
|
continue
|
||||||
|
if '해안길이' in normalized or '대표' in normalized or '사진' in normalized:
|
||||||
|
continue
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 방제 방법 / 종료 기준 (두 컬럼이 같은 줄에 섞여 나옴)
|
||||||
|
if phase == 'cleanup':
|
||||||
|
normalized = line.replace(' ', '')
|
||||||
|
if '방제종료기준' in normalized:
|
||||||
|
# 헤더만 있는 줄 — 이후 불릿은 종료기준
|
||||||
|
current_target = end_criteria_items
|
||||||
|
continue
|
||||||
|
if '초기방제' in normalized and '고려사항' in normalized:
|
||||||
|
phase = 'consider'
|
||||||
|
current_target = consider_items
|
||||||
|
continue
|
||||||
|
if _is_bullet(line):
|
||||||
|
text = _clean_bullet(line)
|
||||||
|
if text:
|
||||||
|
# 두 컬럼이 혼합된 경우 heuristic 분류
|
||||||
|
if _is_criteria(text):
|
||||||
|
end_criteria_items.append(text)
|
||||||
|
else:
|
||||||
|
cleanup_items.append(text)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 고려사항
|
||||||
|
if phase == 'consider':
|
||||||
|
if _is_bullet(line):
|
||||||
|
text = _clean_bullet(line)
|
||||||
|
if text:
|
||||||
|
consider_items.append(text)
|
||||||
|
elif consider_items and line and not _SECTION_START_RE.search(line):
|
||||||
|
# 연속행
|
||||||
|
consider_items[-1] += ' ' + line
|
||||||
|
continue
|
||||||
|
|
||||||
|
section.cleanup_methods = cleanup_items
|
||||||
|
section.end_criteria = end_criteria_items
|
||||||
|
section.notes = consider_items
|
||||||
|
|
||||||
|
if not section.sect_cd:
|
||||||
|
return None
|
||||||
|
return section
|
||||||
|
|
||||||
|
|
||||||
|
def _is_criteria(text: str) -> bool:
|
||||||
|
patterns = ['없어야', '않아야', '미만', '이하', '이상', '분포해야',
|
||||||
|
'분포하면', '발생하지', '묻어나지', '유출되지', '관찰되는']
|
||||||
|
return any(p in text for p in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 접근성 추출
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _extract_accessibility(text_block: str) -> str | None:
|
||||||
|
"""블록 텍스트에서 접근성(차량/도보/선박) 추출."""
|
||||||
|
lines = text_block.split('\n')
|
||||||
|
access_types = []
|
||||||
|
# 접근성 헤더 찾기
|
||||||
|
header_idx = -1
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
s = line.replace(' ', '')
|
||||||
|
if '접근성' in s:
|
||||||
|
header_idx = idx
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_idx < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 헤더 이후에서 차량/도로/도보/선박 라벨과 þ 위치 매칭
|
||||||
|
labels = []
|
||||||
|
for idx in range(header_idx, min(header_idx + 5, len(lines))):
|
||||||
|
line = lines[idx]
|
||||||
|
for label in ['차량', '도로', '도보', '선박']:
|
||||||
|
if label in line.replace(' ', ''):
|
||||||
|
labels.append('도로' if label == '도로' else label)
|
||||||
|
|
||||||
|
# þ 마커 수 세기
|
||||||
|
check_count = 0
|
||||||
|
for idx in range(header_idx, min(header_idx + 8, len(lines))):
|
||||||
|
check_count += lines[idx].count('þ')
|
||||||
|
|
||||||
|
if labels and check_count > 0:
|
||||||
|
# þ 개수만큼 앞에서부터 접근 가능
|
||||||
|
accessed = labels[:check_count] if check_count <= len(labels) else labels
|
||||||
|
return ', '.join(accessed) + ' 접근 가능'
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 페이지 파싱 (여러 구간)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def parse_page_b(page: fitz.Page) -> list[CoastalSection]:
|
||||||
|
"""데이터 페이지에서 CoastalSection 목록 추출 (보통 2개)."""
|
||||||
|
text = page.get_text('text')
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
# 지역명 추출 (첫 줄 또는 헤더)
|
||||||
|
area_name = ''
|
||||||
|
for line in lines[:3]:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped and '정보집' not in stripped and '∙' not in stripped:
|
||||||
|
area_name = stripped
|
||||||
|
break
|
||||||
|
|
||||||
|
# 접근성 추출 (페이지 전체에서)
|
||||||
|
accessibility = _extract_accessibility(text)
|
||||||
|
|
||||||
|
# 구간 블록 분리: "식별자 코드명" 기준
|
||||||
|
block_starts: list[int] = []
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
if _SECTION_START_RE.search(line):
|
||||||
|
# 구간 번호는 이전 줄에 있을 수 있음
|
||||||
|
start = idx
|
||||||
|
if idx > 0 and _NUMBER_RE.match(lines[idx - 1].strip()):
|
||||||
|
start = idx - 1
|
||||||
|
block_starts.append(start)
|
||||||
|
|
||||||
|
if not block_starts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sections: list[CoastalSection] = []
|
||||||
|
for i, start in enumerate(block_starts):
|
||||||
|
end = block_starts[i + 1] if i + 1 < len(block_starts) else len(lines)
|
||||||
|
block = lines[start:end]
|
||||||
|
section = _parse_section_block(block, area_name)
|
||||||
|
if section:
|
||||||
|
if accessibility and not section.access_dc:
|
||||||
|
section.access_dc = accessibility
|
||||||
|
sections.append(section)
|
||||||
|
|
||||||
|
# 접근성은 구간마다 다를 수 있음 — 각 블록에서 개별 추출
|
||||||
|
for i, start in enumerate(block_starts):
|
||||||
|
end = block_starts[i + 1] if i + 1 < len(block_starts) else len(lines)
|
||||||
|
block_text = '\n'.join(lines[start:end])
|
||||||
|
acc = _extract_accessibility(block_text)
|
||||||
|
if acc and i < len(sections):
|
||||||
|
sections[i].access_dc = acc
|
||||||
|
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 전체 PDF 파싱
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def parse_pdf_b(pdf_path: str | Path) -> ParseResult:
|
||||||
|
"""Type B PDF 전체를 파싱하여 ParseResult 반환."""
|
||||||
|
pdf_path = Path(pdf_path)
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
|
||||||
|
result = ParseResult(pdf_filename=pdf_path.name)
|
||||||
|
|
||||||
|
# 관할/구역명 추출 (페이지 헤더/푸터에서)
|
||||||
|
for i in range(min(40, doc.page_count)):
|
||||||
|
text = doc[i].get_text('text')
|
||||||
|
# "여수해경서 관할 해안 사전 평가 정보집" 패턴
|
||||||
|
m = re.search(r'(\S+해경서)\s*관할', text)
|
||||||
|
if m and not result.jurisdiction:
|
||||||
|
result.jurisdiction = m.group(1).strip()
|
||||||
|
# "2. 하동군 해안 사전 평가 정보" 패턴
|
||||||
|
m = re.search(r'\d+\.\s*(.+?)\s*해안\s*사전\s*평가', text)
|
||||||
|
if m and not result.zone_name:
|
||||||
|
result.zone_name = m.group(1).strip()
|
||||||
|
if result.jurisdiction and result.zone_name:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 데이터 페이지 파싱
|
||||||
|
skipped = 0
|
||||||
|
for i in range(doc.page_count):
|
||||||
|
page = doc[i]
|
||||||
|
if not is_data_page_b(page):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
sections = parse_page_b(page)
|
||||||
|
result.sections.extend(sections)
|
||||||
|
|
||||||
|
result.total_sections = len(result.sections)
|
||||||
|
result.skipped_pages = skipped
|
||||||
|
doc.close()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import io
|
||||||
|
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print('Usage: python pdf_parser_b.py <pdf_path>')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
r = parse_pdf_b(sys.argv[1])
|
||||||
|
print(json.dumps(r.model_dump(), ensure_ascii=False, indent=2))
|
||||||
14
prediction/scat/requirements.txt
Normal file
14
prediction/scat/requirements.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# PDF 파싱
|
||||||
|
PyMuPDF==1.26.5
|
||||||
|
|
||||||
|
# 이미지 처리
|
||||||
|
Pillow==10.3.0
|
||||||
|
|
||||||
|
# DB (추후 사용)
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
|
||||||
|
# 데이터 모델
|
||||||
|
pydantic==2.7.0
|
||||||
|
|
||||||
|
# HTTP (Geocoding)
|
||||||
|
requests>=2.31.0
|
||||||
378
prediction/scat/run.py
Normal file
378
prediction/scat/run.py
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
"""SCAT PDF 파싱 CLI 도구.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python run.py <pdf_path> # 단일 PDF 파싱
|
||||||
|
python run.py <directory_path> # 배치 파싱
|
||||||
|
python run.py --load-json output/ --geocode # JSON에 좌표 추가
|
||||||
|
python run.py --load-json output/ --save # JSON → DB 저장
|
||||||
|
python run.py --load-json output/ --save --dry-run # DB 저장 미리보기
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Windows cp949 대응
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
from pdf_parser import parse_pdf
|
||||||
|
from pdf_parser_b import parse_pdf_b
|
||||||
|
from models import CoastalSection, SensitiveItem
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path(__file__).parent / 'output'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PDF 형식 감지
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def detect_pdf_type(pdf_path: Path) -> str:
|
||||||
|
"""PDF 형식 감지. 'A'(해안사전평가정보집) 또는 'B'(방제정보집) 반환."""
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
for i in range(min(30, doc.page_count)):
|
||||||
|
text = doc[i].get_text('text')
|
||||||
|
if '식별자' in text and '코드명' in text:
|
||||||
|
doc.close()
|
||||||
|
return 'B'
|
||||||
|
doc.close()
|
||||||
|
return 'A'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PDF 파싱
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def process_pdf(pdf_path: Path) -> dict:
|
||||||
|
"""단일 PDF를 파싱하고 JSON 파일로 저장한다."""
|
||||||
|
pdf_type = detect_pdf_type(pdf_path)
|
||||||
|
if pdf_type == 'B':
|
||||||
|
result = parse_pdf_b(str(pdf_path))
|
||||||
|
else:
|
||||||
|
result = parse_pdf(str(pdf_path))
|
||||||
|
data = result.model_dump()
|
||||||
|
|
||||||
|
for s in data['sections']:
|
||||||
|
s.pop('photos', None)
|
||||||
|
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path = OUTPUT_DIR / f'{pdf_path.stem}.json'
|
||||||
|
with open(out_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'file': pdf_path.name,
|
||||||
|
'output': str(out_path),
|
||||||
|
'zone_name': result.zone_name,
|
||||||
|
'jurisdiction': result.jurisdiction,
|
||||||
|
'total_sections': result.total_sections,
|
||||||
|
'skipped_pages': result.skipped_pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_parse(target: Path):
|
||||||
|
"""PDF 파싱 실행."""
|
||||||
|
if target.is_file() and target.suffix.lower() == '.pdf':
|
||||||
|
pdf_files = [target]
|
||||||
|
elif target.is_dir():
|
||||||
|
pdf_files = sorted(target.glob('*.pdf'))
|
||||||
|
if not pdf_files:
|
||||||
|
print(f'PDF 파일을 찾을 수 없습니다: {target}')
|
||||||
|
sys.exit(1)
|
||||||
|
print(f'{len(pdf_files)}개 PDF 발견\n')
|
||||||
|
else:
|
||||||
|
print(f'유효하지 않은 경로: {target}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, pdf in enumerate(pdf_files, 1):
|
||||||
|
pdf_type = detect_pdf_type(pdf)
|
||||||
|
print(f'[{i}/{len(pdf_files)}] {pdf.name} (Type {pdf_type}) 파싱 중...')
|
||||||
|
try:
|
||||||
|
info = process_pdf(pdf)
|
||||||
|
results.append(info)
|
||||||
|
print(f' -> {info["total_sections"]}개 구간 | {info["zone_name"]} | {info["jurisdiction"]}')
|
||||||
|
print(f' -> 저장: {info["output"]}')
|
||||||
|
except Exception as e:
|
||||||
|
print(f' -> 오류: {e}')
|
||||||
|
results.append({'file': pdf.name, 'error': str(e)})
|
||||||
|
|
||||||
|
if len(results) > 1:
|
||||||
|
print(f'\n=== 요약 ===')
|
||||||
|
success = [r for r in results if 'error' not in r]
|
||||||
|
failed = [r for r in results if 'error' in r]
|
||||||
|
total_sections = sum(r['total_sections'] for r in success)
|
||||||
|
print(f'성공: {len(success)}개 / 실패: {len(failed)}개 / 총 구간: {total_sections}개')
|
||||||
|
if failed:
|
||||||
|
print(f'실패 파일: {", ".join(r["file"] for r in failed)}')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON → DB 저장
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _extract_zone_cd(sect_cd: str) -> str:
|
||||||
|
"""sect_cd에서 zone_cd 추출 (영문 접두사).
|
||||||
|
|
||||||
|
Type A: SSDD-1 → SSDD (하이픈 앞 영문)
|
||||||
|
Type B: BSBB-1-M-E-S-N → BSBB (첫 하이픈 앞 영문)
|
||||||
|
"""
|
||||||
|
m = re.match(r'^([A-Z]{2,})', sect_cd)
|
||||||
|
return m.group(1) if m else sect_cd
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_jrsd_short(jurisdiction: str) -> str:
|
||||||
|
"""관할 기관명에서 짧은 이름 추출. 예: '보령 해양경비안전서' → '보령'"""
|
||||||
|
if not jurisdiction:
|
||||||
|
return ''
|
||||||
|
return jurisdiction.split()[0] if ' ' in jurisdiction else jurisdiction
|
||||||
|
|
||||||
|
|
||||||
|
def _dict_to_section(d: dict) -> CoastalSection:
|
||||||
|
"""JSON dict → CoastalSection 모델 변환."""
|
||||||
|
sensitive = [SensitiveItem(**item) for item in (d.get('sensitive_info') or [])]
|
||||||
|
return CoastalSection(
|
||||||
|
section_number=d.get('section_number', 0),
|
||||||
|
sect_nm=d.get('sect_nm', ''),
|
||||||
|
sect_cd=d.get('sect_cd', ''),
|
||||||
|
esi_cd=d.get('esi_cd'),
|
||||||
|
esi_num=d.get('esi_num'),
|
||||||
|
shore_tp=d.get('shore_tp'),
|
||||||
|
cst_tp_cd=d.get('cst_tp_cd'),
|
||||||
|
len_m=d.get('len_m'),
|
||||||
|
width_m=d.get('width_m'),
|
||||||
|
lat=d.get('lat'),
|
||||||
|
lng=d.get('lng'),
|
||||||
|
access_dc=d.get('access_dc'),
|
||||||
|
access_pt=d.get('access_pt'),
|
||||||
|
sensitive_info=sensitive,
|
||||||
|
cleanup_methods=d.get('cleanup_methods', []),
|
||||||
|
end_criteria=d.get('end_criteria', []),
|
||||||
|
notes=d.get('notes', []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_json_files(json_dir: Path) -> list[dict]:
|
||||||
|
"""JSON 디렉토리에서 모든 파싱 결과를 로드한다."""
|
||||||
|
all_data = []
|
||||||
|
for f in sorted(json_dir.glob('*.json')):
|
||||||
|
with open(f, encoding='utf-8') as fp:
|
||||||
|
data = json.load(fp)
|
||||||
|
if data.get('total_sections', 0) > 0:
|
||||||
|
all_data.append(data)
|
||||||
|
return all_data
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_zone(all_data: list[dict]) -> dict:
|
||||||
|
"""파싱 결과를 zone_cd로 그룹핑한다.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{zone_cd: {
|
||||||
|
'zone_nm': str,
|
||||||
|
'jrsd_nm': str,
|
||||||
|
'sections': [dict, ...]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
zones = defaultdict(lambda: {'zone_nm': '', 'jrsd_nm': '', 'sections': []})
|
||||||
|
|
||||||
|
for data in all_data:
|
||||||
|
zone_name = data.get('zone_name', '')
|
||||||
|
jrsd_nm = _extract_jrsd_short(data.get('jurisdiction', ''))
|
||||||
|
|
||||||
|
for sect in data['sections']:
|
||||||
|
zone_cd = _extract_zone_cd(sect['sect_cd'])
|
||||||
|
zone = zones[zone_cd]
|
||||||
|
if not zone['zone_nm']:
|
||||||
|
zone['zone_nm'] = zone_name
|
||||||
|
if not zone['jrsd_nm']:
|
||||||
|
zone['jrsd_nm'] = jrsd_nm
|
||||||
|
zone['sections'].append(sect)
|
||||||
|
|
||||||
|
return dict(zones)
|
||||||
|
|
||||||
|
|
||||||
|
def run_save(json_dir: Path, dry_run: bool = False):
|
||||||
|
"""JSON 파싱 결과를 DB에 저장한다."""
|
||||||
|
all_data = load_json_files(json_dir)
|
||||||
|
if not all_data:
|
||||||
|
print(f'유효한 JSON 파일을 찾을 수 없습니다: {json_dir}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
zones = group_by_zone(all_data)
|
||||||
|
total_sections = sum(len(z['sections']) for z in zones.values())
|
||||||
|
|
||||||
|
print(f'=== DB 저장 {"미리보기" if dry_run else "시작"} ===')
|
||||||
|
print(f'총 {len(zones)}개 zone, {total_sections}개 구간\n')
|
||||||
|
|
||||||
|
for zone_cd, zone_info in sorted(zones.items()):
|
||||||
|
sect_count = len(zone_info['sections'])
|
||||||
|
print(f' {zone_cd:8s} | {zone_info["zone_nm"]:20s} | {zone_info["jrsd_nm"]:8s} | {sect_count}개 구간')
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f'\n(dry-run 모드 — DB에 저장하지 않음)')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 실제 DB 저장
|
||||||
|
from db import ensure_zone, upsert_section, update_zone_sect_count, update_zone_center, close_pool
|
||||||
|
|
||||||
|
saved_zones = 0
|
||||||
|
saved_sections = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for zone_cd, zone_info in sorted(zones.items()):
|
||||||
|
zone_sn = ensure_zone(zone_cd, zone_info['zone_nm'], zone_info['jrsd_nm'])
|
||||||
|
saved_zones += 1
|
||||||
|
|
||||||
|
for sect_dict in zone_info['sections']:
|
||||||
|
section = _dict_to_section(sect_dict)
|
||||||
|
upsert_section(zone_sn, section)
|
||||||
|
saved_sections += 1
|
||||||
|
|
||||||
|
update_zone_sect_count(zone_sn)
|
||||||
|
update_zone_center(zone_sn)
|
||||||
|
|
||||||
|
print(f'\n=== 완료 ===')
|
||||||
|
print(f'{saved_zones}개 zone, {saved_sections}개 구간 저장 완료')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'\n오류 발생: {e}')
|
||||||
|
print(f'저장 진행: {saved_zones}개 zone, {saved_sections}개 구간까지 완료')
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
close_pool()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Geocoding
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def run_geocode(json_dir: Path):
|
||||||
|
"""JSON 파싱 결과에 좌표를 추가한다."""
|
||||||
|
from geocoder import geocode_sections, load_cache, save_cache
|
||||||
|
|
||||||
|
load_cache()
|
||||||
|
|
||||||
|
json_files = sorted(json_dir.glob('*.json'))
|
||||||
|
json_files = [f for f in json_files if not f.name.startswith('.')]
|
||||||
|
if not json_files:
|
||||||
|
print(f'JSON 파일을 찾을 수 없습니다: {json_dir}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'=== Geocoding 시작 ({len(json_files)}개 JSON) ===\n')
|
||||||
|
|
||||||
|
total_success = 0
|
||||||
|
total_fail = 0
|
||||||
|
|
||||||
|
for i, f in enumerate(json_files, 1):
|
||||||
|
with open(f, encoding='utf-8') as fp:
|
||||||
|
data = json.load(fp)
|
||||||
|
|
||||||
|
sections = data.get('sections', [])
|
||||||
|
if not sections:
|
||||||
|
continue
|
||||||
|
|
||||||
|
zone_name = data.get('zone_name', '')
|
||||||
|
print(f'[{i}/{len(json_files)}] {f.name} ({len(sections)}개 구간)...')
|
||||||
|
|
||||||
|
success, fail = geocode_sections(sections, zone_name)
|
||||||
|
total_success += success
|
||||||
|
total_fail += fail
|
||||||
|
|
||||||
|
# 좌표가 있는 구간 수
|
||||||
|
with_coords = sum(1 for s in sections if s.get('lat'))
|
||||||
|
print(f' -> 좌표: {with_coords}/{len(sections)}')
|
||||||
|
|
||||||
|
# JSON 업데이트 저장
|
||||||
|
with open(f, 'w', encoding='utf-8') as fp:
|
||||||
|
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
save_cache()
|
||||||
|
|
||||||
|
print(f'\n=== Geocoding 완료 ===')
|
||||||
|
print(f'성공: {total_success} / 실패: {total_fail}')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 이미지 추출
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def run_extract_images(target: Path):
|
||||||
|
"""PDF에서 해안사진을 추출하여 scat-photos/에 저장."""
|
||||||
|
from image_extractor import extract_images_from_pdf
|
||||||
|
|
||||||
|
if target.is_file() and target.suffix.lower() == '.pdf':
|
||||||
|
pdf_files = [target]
|
||||||
|
elif target.is_dir():
|
||||||
|
pdf_files = sorted(target.glob('*.pdf'))
|
||||||
|
if not pdf_files:
|
||||||
|
print(f'PDF 파일을 찾을 수 없습니다: {target}')
|
||||||
|
sys.exit(1)
|
||||||
|
print(f'{len(pdf_files)}개 PDF 발견\n')
|
||||||
|
else:
|
||||||
|
print(f'유효하지 않은 경로: {target}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for i, pdf in enumerate(pdf_files, 1):
|
||||||
|
pdf_type = detect_pdf_type(pdf)
|
||||||
|
print(f'[{i}/{len(pdf_files)}] {pdf.name} (Type {pdf_type}) 이미지 추출 중...')
|
||||||
|
try:
|
||||||
|
count = extract_images_from_pdf(pdf, pdf_type=pdf_type)
|
||||||
|
total += count
|
||||||
|
print(f' -> {count}개 이미지 저장')
|
||||||
|
except Exception as e:
|
||||||
|
print(f' -> 오류: {e}')
|
||||||
|
|
||||||
|
print(f'\n=== 이미지 추출 완료: 총 {total}개 ===')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='SCAT PDF 파싱 CLI 도구')
|
||||||
|
parser.add_argument('target', nargs='?', help='PDF 파일 또는 디렉토리 경로')
|
||||||
|
parser.add_argument('--save', action='store_true', help='파싱 결과를 DB에 저장')
|
||||||
|
parser.add_argument('--load-json', type=Path, help='이미 파싱된 JSON 디렉토리에서 로드')
|
||||||
|
parser.add_argument('--geocode', action='store_true', help='JSON에 Kakao Geocoding으로 좌표 추가')
|
||||||
|
parser.add_argument('--extract-images', action='store_true', help='PDF에서 해안사진 추출 → scat-photos/')
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help='DB 저장 미리보기 (실제 저장 안 함)')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# JSON 로드 모드
|
||||||
|
if args.load_json:
|
||||||
|
if args.geocode:
|
||||||
|
run_geocode(args.load_json)
|
||||||
|
if args.save:
|
||||||
|
run_save(args.load_json, dry_run=args.dry_run)
|
||||||
|
if not args.geocode and not args.save:
|
||||||
|
print('--load-json은 --geocode 또는 --save와 함께 사용해야 합니다.')
|
||||||
|
sys.exit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
# PDF 파싱 모드
|
||||||
|
if not args.target:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target = Path(args.target)
|
||||||
|
|
||||||
|
# 이미지 추출 모드
|
||||||
|
if args.extract_images:
|
||||||
|
run_extract_images(target)
|
||||||
|
return
|
||||||
|
|
||||||
|
run_parse(target)
|
||||||
|
|
||||||
|
# 파싱 후 바로 DB 저장
|
||||||
|
if args.save:
|
||||||
|
print('\n')
|
||||||
|
run_save(OUTPUT_DIR, dry_run=args.dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
불러오는 중...
Reference in New Issue
Block a user