From a0a7f19e58070406b41312813d0e2fea913d4e0e Mon Sep 17 00:00:00 2001 From: "jeonghyo.K" Date: Tue, 10 Feb 2026 13:15:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EA=B8=B0=EC=83=81=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=201.=20=EA=B0=81=20=ED=83=AD=EC=9D=98=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EB=B0=9C=202.?= =?UTF-8?q?=20api=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=91=9C=EC=B6=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 20 +- src/api/weatherApi.js | 309 +++++++++++++++++ src/components/layout/Sidebar.jsx | 3 +- src/pages/WeatherPage.jsx | 63 ++++ src/publish/pages/Panel3Component.jsx | 5 +- src/weather/components/AviationWeather.jsx | 267 +++++++++++++++ src/weather/components/TidalInfo.jsx | 349 ++++++++++++++++++++ src/weather/components/TidalObservation.jsx | 289 ++++++++++++++++ src/weather/components/TyphoonInfo.jsx | 190 +++++++++++ src/weather/components/WeatherAlert.jsx | 143 ++++++++ vite.config.js | 2 +- 11 files changed, 1626 insertions(+), 14 deletions(-) create mode 100644 src/api/weatherApi.js create mode 100644 src/pages/WeatherPage.jsx create mode 100644 src/weather/components/AviationWeather.jsx create mode 100644 src/weather/components/TidalInfo.jsx create mode 100644 src/weather/components/TidalObservation.jsx create mode 100644 src/weather/components/TyphoonInfo.jsx create mode 100644 src/weather/components/WeatherAlert.jsx diff --git a/src/App.jsx b/src/App.jsx index eb48247b..4cbb8b79 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -35,16 +35,16 @@ export default function App() { /publish/* 로 접근하여 퍼블리시 결과물 미리보기 프로덕션 빌드 시 이 라우트와 관련 모듈이 제외됨 ===================== */} - {import.meta.env.DEV && PublishRouter && ( - Loading publish...}> - - - } - /> - )} + {/*{import.meta.env.DEV && PublishRouter && (*/} + {/* Loading publish...}>*/} + {/* */} + {/* */} + {/* }*/} + {/* />*/} + {/*)}*/} ); diff --git a/src/api/weatherApi.js b/src/api/weatherApi.js new file mode 100644 index 00000000..66f2b84f --- /dev/null +++ b/src/api/weatherApi.js @@ -0,0 +1,309 @@ +/** + * 기상해양 API + */ + +const SPECIAL_NEWS_ENDPOINT = '/api/gis/weather/special-news/search'; + +/** + * 기상특보 목록 조회 + * + * @param {Object} params + * @param {string} params.startPresentationDate - 조회 시작일 (e.g. '2026-01-01') + * @param {string} params.endPresentationDate - 조회 종료일 (e.g. '2026-01-31') + * @param {number} params.page - 페이지 번호 + * @param {number} params.limit - 페이지당 항목 수 + * @returns {Promise<{ list: Array, totalPage: number }>} + */ +export async function fetchWeatherAlerts({ startPresentationDate, endPresentationDate, page, limit }) { + try { + const response = await fetch(SPECIAL_NEWS_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + startPresentationDate, + endPresentationDate, + page, + limit, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + return { + list: result?.specialNewsDetailList || [], + totalPage: result?.totalPage || 0, + }; + } catch (error) { + console.error('[fetchWeatherAlerts] Error:', error); + throw error; + } +} + +const TYPHOON_LIST_ENDPOINT = '/api/gis/weather/typhoon/list/search'; +const TYPHOON_DETAIL_ENDPOINT = '/api/gis/weather/typhoon/search'; + +/** + * 태풍 목록 조회 + * + * @param {Object} params + * @param {string} params.typhoonBeginningYear - 조회 연도 + * @param {string} params.typhoonBeginningMonth - 조회 월 (빈 문자열이면 전체) + * @param {number} params.page - 페이지 번호 + * @param {number} params.limit - 페이지당 항목 수 + * @returns {Promise<{ list: Array, totalPage: number }>} + */ +export async function fetchTyphoonList({ typhoonBeginningYear, typhoonBeginningMonth, page, limit }) { + try { + const response = await fetch(TYPHOON_LIST_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + typhoonBeginningYear, + typhoonBeginningMonth, + page, + limit, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + const grouped = result?.typhoonList || []; + const list = grouped.flatMap((group) => group.typhoonList || []); + + return { + list, + totalPage: result?.totalPage || 0, + }; + } catch (error) { + console.error('[fetchTyphoonList] Error:', error); + throw error; + } +} + +/** + * 태풍 상세(진행정보) 조회 + * + * @param {Object} params + * @param {string} params.typhoonSequence - 태풍 순번 + * @param {string} params.year - 연도 + * @returns {Promise} + */ +export async function fetchTyphoonDetail({ typhoonSequence, year }) { + try { + const response = await fetch(TYPHOON_DETAIL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + typhoonSequence, + year, + page: 1, + limit: 10000, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + return result?.typhoonSelectDto || []; + } catch (error) { + console.error('[fetchTyphoonDetail] Error:', error); + throw error; + } +} + +const TIDE_INFORMATION_ENDPOINT = '/api/gis/weather/tide-information/search'; +const SUNRISE_SUNSET_DETAIL_ENDPOINT = '/api/gis/weather/tide-information/observatory/detail/search'; + +/** + * 조석정보 통합 조회 (조위관측소 + 일출몰관측지역) + * + * @returns {Promise<{ observatories: Array, sunriseSunsets: Array }>} + */ +export async function fetchTideInformation() { + try { + const response = await fetch(TIDE_INFORMATION_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({}), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + return { + observatories: result?.observatorySearchDto || [], + sunriseSunsets: result?.sunriseSunsetSearchDto || [], + }; + } catch (error) { + console.error('[fetchTideInformation] Error:', error); + throw error; + } +} + +/** + * 일출일몰 상세 조회 + * + * @param {Object} params - SunriseSunsetSearchDto + * @param {string} params.locationName - 지역명 + * @param {string} params.locationType - 지역 유형 + * @param {Object} params.coordinate - 좌표 + * @param {boolean} params.isChecked - 체크 여부 + * @param {Array} params.locationCoordinates - 좌표 배열 + * @returns {Promise} SunriseSunsetSelectDetailDto 또는 null + */ +export async function fetchSunriseSunsetDetail(params) { + try { + const response = await fetch(SUNRISE_SUNSET_DETAIL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + return result?.sunriseSunsetSelectDetailDto?.[0] || null; + } catch (error) { + console.error('[fetchSunriseSunsetDetail] Error:', error); + throw error; + } +} + +const OBSERVATORY_ENDPOINT = '/api/gis/weather/observatory/search'; +const OBSERVATORY_DETAIL_ENDPOINT = '/api/gis/weather/observatory/select/detail/search'; + +/** + * 관측소 목록 조회 + * + * @returns {Promise} ObservatorySearchDto 배열 + */ +export async function fetchObservatoryList() { + try { + const response = await fetch(OBSERVATORY_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({}), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + return result?.dtoList || []; + } catch (error) { + console.error('[fetchObservatoryList] Error:', error); + throw error; + } +} + +/** + * 관측소 상세정보 조회 + * + * @param {Object} params + * @param {string} params.observatoryId - 관측소 ID + * @param {string} params.toDate - 조회 기준일 (e.g. '2026-02-10') + * @returns {Promise} ObservatorySelectDetailDto 또는 null + */ +const AIRPORT_ENDPOINT = '/api/gis/weather/airport/search'; +const AIRPORT_DETAIL_ENDPOINT = '/api/gis/weather/airport/select'; + +/** + * 공항 목록 조회 + * + * @returns {Promise} AirportSearchDto 배열 + */ +export async function fetchAirportList() { + try { + const response = await fetch(AIRPORT_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({}), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return result?.airportSearchDto || []; + } catch (error) { + console.error('[fetchAirportList] Error:', error); + throw error; + } +} + +/** + * 공항 상세정보 조회 + * + * @param {Object} params + * @param {string} params.airportId - 공항 ID + * @returns {Promise} AirportSelectDto 또는 null + */ +export async function fetchAirportDetail({ airportId }) { + try { + const response = await fetch(AIRPORT_DETAIL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ airportId }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return result?.airportSelectDto || null; + } catch (error) { + console.error('[fetchAirportDetail] Error:', error); + throw error; + } +} + +export async function fetchObservatoryDetail({ observatoryId, toDate }) { + try { + const response = await fetch(OBSERVATORY_DETAIL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ observatoryId, toDate }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + return result?.observatorySelectDetail?.[0] || null; + } catch (error) { + console.error('[fetchObservatoryDetail] Error:', error); + throw error; + } +} diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx index 1702c55a..3b4c4414 100644 --- a/src/components/layout/Sidebar.jsx +++ b/src/components/layout/Sidebar.jsx @@ -18,6 +18,7 @@ const Panel8Component = getPanel('Panel8Component'); import DisplayComponent from '../../component/wrap/side/DisplayComponent'; // 구현된 페이지 import ReplayPage from '../../pages/ReplayPage'; +import WeatherPage from '../../pages/WeatherPage'; /** * 사이드바 컴포넌트 @@ -64,7 +65,7 @@ export default function Sidebar() { const panelMap = { gnb1: DisplayComponent ? : null, gnb2: Panel2Component ? : null, - gnb3: Panel3Component ? : null, + gnb3: , gnb4: Panel4Component ? : null, gnb5: Panel5Component ? : null, gnb6: Panel6Component ? : null, diff --git a/src/pages/WeatherPage.jsx b/src/pages/WeatherPage.jsx new file mode 100644 index 00000000..0e98bc16 --- /dev/null +++ b/src/pages/WeatherPage.jsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import WeatherAlert from '@/weather/components/WeatherAlert'; +import TyphoonInfo from '@/weather/components/TyphoonInfo'; +import TidalObservation from '@/weather/components/TidalObservation'; +import TidalInfo from '@/weather/components/TidalInfo'; +import AviationWeather from '@/weather/components/AviationWeather'; + +const tabs = [ + { id: 'weather01', label: '기상특보' }, + { id: 'weather02', label: '태풍정보' }, + { id: 'weather03', label: '조위관측' }, + { id: 'weather04', label: '조석정보' }, + { id: 'weather05', label: '항공기상' }, +]; + +const tabComponents = { + weather01: WeatherAlert, + weather02: TyphoonInfo, + weather03: TidalObservation, + weather04: TidalInfo, + weather05: AviationWeather, +}; + +export default function WeatherPage({ isOpen, onToggle }) { + const [activeTab, setActiveTab] = useState('weather01'); + + const ActiveComponent = tabComponents[activeTab]; + + return ( + + ); +} diff --git a/src/publish/pages/Panel3Component.jsx b/src/publish/pages/Panel3Component.jsx index 55780771..359d8240 100644 --- a/src/publish/pages/Panel3Component.jsx +++ b/src/publish/pages/Panel3Component.jsx @@ -41,9 +41,10 @@ export default function Panel3Component({ isOpen, onToggle }) {
  • diff --git a/src/weather/components/AviationWeather.jsx b/src/weather/components/AviationWeather.jsx new file mode 100644 index 00000000..e1135c45 --- /dev/null +++ b/src/weather/components/AviationWeather.jsx @@ -0,0 +1,267 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { fetchAirportList, fetchAirportDetail } from '@/api/weatherApi'; +import { useMapStore } from '@/stores/mapStore'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import Feature from 'ol/Feature'; +import { Point } from 'ol/geom'; +import { Icon, Style } from 'ol/style'; +import { fromLonLat } from 'ol/proj'; +import Overlay from 'ol/Overlay'; + +export default function AviationWeather() { + const [airports, setAirports] = useState([]); + const [checkedIds, setCheckedIds] = useState(new Set()); + const [isLoading, setIsLoading] = useState(false); + const [popupData, setPopupData] = useState(null); + + const layerRef = useRef(null); + const sourceRef = useRef(new VectorSource()); + const overlayRef = useRef(null); + const popupElRef = useRef(document.createElement('div')); + + const map = useMapStore((state) => state.map); + + const closePopup = useCallback(() => { + overlayRef.current?.setPosition(undefined); + setPopupData(null); + }, []); + + // 공항 데이터 로딩 + useEffect(() => { + let cancelled = false; + + async function load() { + setIsLoading(true); + try { + const data = await fetchAirportList(); + if (!cancelled) { + setAirports(data); + } + } catch { + // 에러는 weatherApi 내부에서 로깅됨 + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + load(); + + return () => { + cancelled = true; + }; + }, []); + + // 레이어 + 오버레이 생성/제거 + useEffect(() => { + if (!map) return; + + const layer = new VectorLayer({ + source: sourceRef.current, + zIndex: 10, + }); + + map.addLayer(layer); + layerRef.current = layer; + + const overlay = new Overlay({ + element: popupElRef.current, + positioning: 'bottom-center', + offset: [0, -10], + stopEvent: true, + }); + + map.addOverlay(overlay); + overlayRef.current = overlay; + + return () => { + closePopup(); + map.removeOverlay(overlay); + overlayRef.current = null; + map.removeLayer(layer); + layerRef.current = null; + popupElRef.current?.remove(); + }; + }, [map, closePopup]); + + // 지도 클릭 이벤트 핸들러 + useEffect(() => { + if (!map || !layerRef.current) return; + + const handleMapClick = (evt) => { + const feature = map.forEachFeatureAtPixel(evt.pixel, (f) => f, { + layerFilter: (l) => l === layerRef.current, + }); + + if (!feature) { + closePopup(); + return; + } + + const airportId = feature.get('airportId'); + const coordinate = feature.getGeometry().getCoordinates(); + + overlayRef.current.setPosition(coordinate); + + fetchAirportDetail({ airportId }) + .then((data) => setPopupData(data)) + .catch(() => setPopupData(null)); + }; + + map.on('singleclick', handleMapClick); + + return () => { + map.un('singleclick', handleMapClick); + }; + }, [map, closePopup]); + + // 마커 동기화 + useEffect(() => { + const source = sourceRef.current; + source.clear(); + + if (checkedIds.size === 0 || airports.length === 0) return; + + airports + .filter((airport) => checkedIds.has(airport.airportId)) + .forEach((airport) => { + const coords = airport.coordinates?.[0]; + if (!coords) return; + + const feature = new Feature({ + geometry: new Point(fromLonLat([coords[0], coords[1]])), + name: airport.airportName, + airportId: airport.airportId, + }); + + feature.setStyle( + new Style({ + image: new Icon({ + src: '/images/ico_legend_aircraft.svg', + scale: 1, + anchor: [0.5, 1], + }), + }), + ); + + source.addFeature(feature); + }); + }, [checkedIds, airports]); + + const isAllChecked = checkedIds.size === airports.length && airports.length > 0; + + const handleToggleAll = useCallback(() => { + setCheckedIds((prev) => { + if (prev.size === airports.length && airports.length > 0) { + return new Set(); + } + return new Set(airports.map((a) => a.airportId)); + }); + }, [airports]); + + const handleToggle = useCallback((airportId) => { + setCheckedIds((prev) => { + const next = new Set(prev); + if (next.has(airportId)) next.delete(airportId); + else next.add(airportId); + return next; + }); + }, []); + + return ( + <> + {createPortal( + popupData ? ( +
    +
    +
    + {popupData.airportName} +
    +
    +
    +
      +
    • + 기온 + {popupData.temperature ?? '-'} °C +
    • +
    • + 기상 + {popupData.presentWeatherTypeCode ?? '-'} +
    • +
    • + 풍향 + {popupData.windDirection ?? '-'} ° +
    • +
    • + 풍속 + {popupData.windSpeed ?? '-'} kt +
    • +
    • + 시정 + {popupData.visibility ?? '-'} +
    • +
    • + 운고 + {popupData.ceiling ?? '-'} +
    • +
    • + 강수량 + {popupData.rainFall ?? '-'} mm +
    • +
    +
    +
    + ) : null, + popupElRef.current, + )} + +
    +
    +
    항공기상
    +
    + 항공기상 범례 +
      +
    • + 공항 + 공항 +
    • +
    +
    +
    + +
    + {isLoading &&
    데이터를 불러오는 중...
    } +
      +
    • + +
    • + {airports.map((airport) => ( +
    • + +
    • + ))} +
    +
    +
    + + ); +} diff --git a/src/weather/components/TidalInfo.jsx b/src/weather/components/TidalInfo.jsx new file mode 100644 index 00000000..c8b7924c --- /dev/null +++ b/src/weather/components/TidalInfo.jsx @@ -0,0 +1,349 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { fetchTideInformation, fetchObservatoryDetail, fetchSunriseSunsetDetail } from '@/api/weatherApi'; +import { useMapStore } from '@/stores/mapStore'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import Feature from 'ol/Feature'; +import { Point } from 'ol/geom'; +import { Icon, Style } from 'ol/style'; +import { fromLonLat } from 'ol/proj'; +import Overlay from 'ol/Overlay'; + +const TIDAL_INFO_TYPES = [ + { type: '조위관측소', icon: '/images/ico_obsTide.svg' }, + { type: '일출몰관측지역', icon: '/images/ico_obsSunrise.svg' }, +]; + +export default function TidalInfo() { + const [data, setData] = useState({ observatories: [], sunriseSunsets: [] }); + const [checkedTypes, setCheckedTypes] = useState(new Set()); + const [isLoading, setIsLoading] = useState(false); + const [popupData, setPopupData] = useState(null); + const [popupType, setPopupType] = useState(null); // 'observatory' | 'sunrise' + + const layerRef = useRef(null); + const sourceRef = useRef(new VectorSource()); + const overlayRef = useRef(null); + const popupElRef = useRef(document.createElement('div')); + + const map = useMapStore((state) => state.map); + + // 팝업 닫기 + const closePopup = useCallback(() => { + overlayRef.current?.setPosition(undefined); + setPopupData(null); + setPopupType(null); + }, []); + + // 데이터 로딩 + useEffect(() => { + let cancelled = false; + + async function load() { + setIsLoading(true); + try { + const result = await fetchTideInformation(); + if (!cancelled) { + setData(result); + } + } catch { + // 에러는 weatherApi 내부에서 로깅됨 + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + load(); + + return () => { + cancelled = true; + }; + }, []); + + // 레이어 + 오버레이 생성/제거 + useEffect(() => { + if (!map) return; + + const layer = new VectorLayer({ + source: sourceRef.current, + zIndex: 10, + }); + + map.addLayer(layer); + layerRef.current = layer; + + const overlay = new Overlay({ + element: popupElRef.current, + positioning: 'bottom-center', + offset: [0, -10], + stopEvent: true, + }); + + map.addOverlay(overlay); + overlayRef.current = overlay; + + return () => { + closePopup(); + map.removeOverlay(overlay); + overlayRef.current = null; + map.removeLayer(layer); + layerRef.current = null; + popupElRef.current?.remove(); + }; + }, [map, closePopup]); + + // 지도 클릭 이벤트 핸들러 + useEffect(() => { + if (!map || !layerRef.current) return; + + const handleMapClick = (evt) => { + const feature = map.forEachFeatureAtPixel(evt.pixel, (f) => f, { + layerFilter: (l) => l === layerRef.current, + }); + + if (!feature) { + closePopup(); + return; + } + + const coordinate = feature.getGeometry().getCoordinates(); + overlayRef.current.setPosition(coordinate); + + const type = feature.get('featureType'); + + if (type === 'observatory') { + const observatoryId = feature.get('observatoryId'); + const today = new Date().toISOString().slice(0, 10); + setPopupType('observatory'); + fetchObservatoryDetail({ observatoryId, toDate: today }) + .then((d) => setPopupData(d)) + .catch(() => setPopupData(null)); + } else if (type === 'sunrise') { + const stationData = feature.get('stationData'); + setPopupType('sunrise'); + fetchSunriseSunsetDetail(stationData) + .then((d) => setPopupData(d)) + .catch(() => setPopupData(null)); + } + }; + + map.on('singleclick', handleMapClick); + + return () => { + map.un('singleclick', handleMapClick); + }; + }, [map, closePopup]); + + // 마커 동기화 + useEffect(() => { + const source = sourceRef.current; + source.clear(); + + if (checkedTypes.size === 0) return; + + // 조위관측소 마커 + if (checkedTypes.has('조위관측소')) { + data.observatories.forEach((station) => { + const coord = station.coordinates?.[0]; + if (!coord) return; + + const feature = new Feature({ + geometry: new Point(fromLonLat([coord.longitude, coord.latitude])), + name: station.observatoryName, + featureType: 'observatory', + stationData: station, + observatoryId: station.observatoryId, + }); + + feature.setStyle( + new Style({ + image: new Icon({ + src: '/images/ico_obsTide.svg', + scale: 1, + anchor: [0.5, 1], + }), + }), + ); + + source.addFeature(feature); + }); + } + + // 일출몰관측지역 마커 + if (checkedTypes.has('일출몰관측지역')) { + data.sunriseSunsets.forEach((region) => { + const coord = region.locationCoordinates?.[0]; + if (!coord) return; + + const feature = new Feature({ + geometry: new Point(fromLonLat([coord[0], coord[1]])), + name: region.locationName, + featureType: 'sunrise', + stationData: region, + }); + + feature.setStyle( + new Style({ + image: new Icon({ + src: '/images/ico_obsSunrise.svg', + scale: 1, + anchor: [0.5, 1], + }), + }), + ); + + source.addFeature(feature); + }); + } + }, [checkedTypes, data]); + + const handleToggle = useCallback((type) => { + setCheckedTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); + }, []); + + return ( + <> + {/* OL Overlay에 연결될 팝업 요소 - createPortal로 React DOM 트리 외부에 렌더링 */} + {createPortal( + <> + {popupData && popupType === 'observatory' && ( +
    +
    +
    + {popupData.observatoryName} +
    +
    +
    +
      +
    • + {popupData.latestTime + ? new Date(popupData.latestTime).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + : '-'} +
    • +
    • + 조위 + {popupData.tidalLevel ?? '-'} cm +
    • +
    • + 수온 + {popupData.seawaterTemperature ?? '-'} °C +
    • +
    • + 기온 + {popupData.temperature ?? '-'} °C +
    • +
    • + 기압 + {popupData.atmosphericPressure ?? '-'} hPa +
    • +
    • + 풍향 + {popupData.windDirection ?? '-'} deg +
    • +
    • + 풍속 + {popupData.windSpeed ?? '-'} m/s +
    • +
    • + 유속방향 + {popupData.tidalDirection ?? '-'} deg +
    • +
    • + 유속 + {popupData.tidalSpeed ?? '-'} m/s +
    • +
    • + 파고 + {popupData.waveHeight ?? '-'} m +
    • +
    +
    +
    + )} + + {popupData && popupType === 'sunrise' && ( +
    +
    +
    + {popupData.locationName} +
    +
    +
    +
      +
    • {popupData.date ?? '-'} ({popupData.day ?? '-'})
    • +
    • + 일출 + {popupData.sunriseTime ?? '-'} +
    • +
    • + 일몰 + {popupData.sunsetTime ?? '-'} +
    • +
    +
    +
    + )} + , + popupElRef.current, + )} + +
    +
    +
    조석정보
    +
    + 조위관측 범례 +
      + {TIDAL_INFO_TYPES.map(({ type, icon }) => ( +
    • + {type} + {type} +
    • + ))} +
    +
    +
    + +
    + {isLoading &&
    데이터를 불러오는 중...
    } +
      + {TIDAL_INFO_TYPES.map(({ type }) => ( +
    • + +
    • + ))} +
    +
    +
    + + ); +} diff --git a/src/weather/components/TidalObservation.jsx b/src/weather/components/TidalObservation.jsx new file mode 100644 index 00000000..52035169 --- /dev/null +++ b/src/weather/components/TidalObservation.jsx @@ -0,0 +1,289 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { fetchObservatoryList, fetchObservatoryDetail } from '@/api/weatherApi'; +import { useMapStore } from '@/stores/mapStore'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import Feature from 'ol/Feature'; +import { Point } from 'ol/geom'; +import { Icon, Style } from 'ol/style'; +import { fromLonLat } from 'ol/proj'; +import Overlay from 'ol/Overlay'; + +const OBSERVATORY_TYPES = [ + { type: '조위관측소', icon: '/images/ico_obsTide.svg' }, + { type: '해양관측소', icon: '/images/ico_obsOcean.svg' }, + { type: '해양관측부이', icon: '/images/ico_obsBuoy.svg' }, + { type: '해수유동관측소', icon: '/images/ico_obsCurrent.svg' }, + { type: '해양과학기지', icon: '/images/ico_obsScience.svg' }, +]; + +const ICON_BY_TYPE = Object.fromEntries( + OBSERVATORY_TYPES.map(({ type, icon }) => [type, icon]), +); + +export default function TidalObservation() { + const [stations, setStations] = useState([]); + const [checkedTypes, setCheckedTypes] = useState(new Set()); + const [isLoading, setIsLoading] = useState(false); + const [popupData, setPopupData] = useState(null); + + const layerRef = useRef(null); + const sourceRef = useRef(new VectorSource()); + const overlayRef = useRef(null); + const popupElRef = useRef(document.createElement('div')); + + const map = useMapStore((state) => state.map); + + // 팝업 닫기 + const closePopup = useCallback(() => { + overlayRef.current?.setPosition(undefined); + setPopupData(null); + }, []); + + // 관측소 데이터 로딩 + useEffect(() => { + let cancelled = false; + + async function load() { + setIsLoading(true); + try { + const data = await fetchObservatoryList(); + if (!cancelled) { + setStations(data); + } + } catch { + // 에러는 weatherApi 내부에서 로깅됨 + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + load(); + + return () => { + cancelled = true; + }; + }, []); + + // 레이어 + 오버레이 생성/제거 + useEffect(() => { + if (!map) return; + + const layer = new VectorLayer({ + source: sourceRef.current, + zIndex: 10, + }); + + map.addLayer(layer); + layerRef.current = layer; + + const overlay = new Overlay({ + element: popupElRef.current, + positioning: 'bottom-center', + offset: [0, -10], + stopEvent: true, + }); + + map.addOverlay(overlay); + overlayRef.current = overlay; + + return () => { + closePopup(); + map.removeOverlay(overlay); + overlayRef.current = null; + map.removeLayer(layer); + layerRef.current = null; + // OL이 이동시킨 요소를 DOM에서 제거 + popupElRef.current?.remove(); + }; + }, [map, closePopup]); + + // 지도 클릭 이벤트 핸들러 + useEffect(() => { + if (!map || !layerRef.current) return; + + const handleMapClick = (evt) => { + const feature = map.forEachFeatureAtPixel(evt.pixel, (f) => f, { + layerFilter: (l) => l === layerRef.current, + }); + + if (!feature) { + closePopup(); + return; + } + + const observatoryId = feature.get('observatoryId'); + const coordinate = feature.getGeometry().getCoordinates(); + + overlayRef.current.setPosition(coordinate); + + const today = new Date().toISOString().slice(0, 10); + fetchObservatoryDetail({ observatoryId, toDate: today }) + .then((data) => setPopupData(data)) + .catch(() => setPopupData(null)); + }; + + map.on('singleclick', handleMapClick); + + return () => { + map.un('singleclick', handleMapClick); + }; + }, [map, closePopup]); + + // 마커 동기화 + useEffect(() => { + const source = sourceRef.current; + source.clear(); + + if (checkedTypes.size === 0 || stations.length === 0) return; + + const filtered = stations.filter((s) => checkedTypes.has(s.observatoryType)); + + filtered.forEach((station) => { + const coord = station.coordinates?.[0]; + if (!coord) return; + + const feature = new Feature({ + geometry: new Point(fromLonLat([coord[0], coord[1]])), + name: station.observatoryName, + observatoryId: station.observatoryId, + observatoryType: station.observatoryType, + }); + + const iconSrc = ICON_BY_TYPE[station.observatoryType]; + if (iconSrc) { + feature.setStyle( + new Style({ + image: new Icon({ + src: iconSrc, + scale: 1, + anchor: [0.5, 1], + }), + }), + ); + } + + source.addFeature(feature); + }); + }, [checkedTypes, stations]); + + const handleToggle = useCallback((type) => { + setCheckedTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); + }, []); + + return ( + <> + {createPortal( + popupData ? ( +
    +
    +
    + {popupData.observatoryName} +
    +
    +
    +
      +
    • + {popupData.latestTime + ? new Date(popupData.latestTime).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + : '-'} +
    • +
    • + 조위 + {popupData.tidalLevel ?? '-'} cm +
    • +
    • + 수온 + {popupData.seawaterTemperature ?? '-'} °C +
    • +
    • + 기온 + {popupData.temperature ?? '-'} °C +
    • +
    • + 기압 + {popupData.atmosphericPressure ?? '-'} hPa +
    • +
    • + 풍향 + {popupData.windDirection ?? '-'} deg +
    • +
    • + 풍속 + {popupData.windSpeed ?? '-'} m/s +
    • +
    • + 유속방향 + {popupData.tidalDirection ?? '-'} deg +
    • +
    • + 유속 + {popupData.tidalSpeed ?? '-'} m/s +
    • +
    • + 파고 + {popupData.waveHeight ?? '-'} m +
    • +
    +
    +
    + ) : null, + popupElRef.current, + )} + +
    +
    +
    조위관측
    +
    + 조위관측 범례 +
      + {OBSERVATORY_TYPES.map(({ type, icon }) => ( +
    • + {type} + {type} +
    • + ))} +
    +
    +
    + +
    + {isLoading &&
    데이터를 불러오는 중...
    } +
      + {OBSERVATORY_TYPES.map(({ type }) => ( +
    • + +
    • + ))} +
    +
    +
    + + ); +} diff --git a/src/weather/components/TyphoonInfo.jsx b/src/weather/components/TyphoonInfo.jsx new file mode 100644 index 00000000..9db3a5e8 --- /dev/null +++ b/src/weather/components/TyphoonInfo.jsx @@ -0,0 +1,190 @@ +import { useState, useCallback } from 'react'; +import { fetchTyphoonList, fetchTyphoonDetail } from '@/api/weatherApi'; + +const LIMIT = 10; +const currentYear = new Date().getFullYear(); + +const yearOptions = Array.from({ length: currentYear - 2000 + 1 }, (_, i) => currentYear - i); +const monthOptions = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0')); + +export default function TyphoonInfo() { + const [year, setYear] = useState(String(currentYear)); + const [month, setMonth] = useState(''); + const [list, setList] = useState([]); + const [page, setPage] = useState(1); + const [totalPage, setTotalPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedIndex, setExpandedIndex] = useState(null); + const [detailData, setDetailData] = useState([]); + const [isDetailLoading, setIsDetailLoading] = useState(false); + + const search = useCallback(async (targetPage) => { + if (!year) return; + + setIsLoading(true); + setError(null); + setExpandedIndex(null); + + try { + const result = await fetchTyphoonList({ + typhoonBeginningYear: year, + typhoonBeginningMonth: month, + page: targetPage, + limit: LIMIT, + }); + setList(result.list); + setTotalPage(result.totalPage); + setPage(targetPage); + } catch (err) { + setError('태풍정보 조회 중 오류가 발생했습니다.'); + setList([]); + setTotalPage(0); + } finally { + setIsLoading(false); + } + }, [year, month]); + + const handleSearch = () => { + search(1); + }; + + const handlePageChange = (newPage) => { + search(newPage); + }; + + const handleToggle = useCallback(async (idx, item) => { + if (expandedIndex === idx) { + setExpandedIndex(null); + return; + } + + setExpandedIndex(idx); + setIsDetailLoading(true); + setDetailData([]); + + try { + const data = await fetchTyphoonDetail({ + typhoonSequence: item.typhoonSequence, + year: item.typhoonBeginningYear || year, + }); + setDetailData(data); + } catch { + setDetailData([]); + } finally { + setIsDetailLoading(false); + } + }, [expandedIndex, year]); + + return ( +
    +
    +
    태풍정보
    +
    +
      +
    • + + +
    • +
    • + +
    • +
    +
    +
    + +
    + {isLoading &&
    조회 중...
    } + + {error &&
    {error}
    } + + {!isLoading && !error && list.length === 0 && ( +
    검색 결과가 없습니다.
    + )} + + {!isLoading && list.length > 0 && ( + <> + + + {totalPage > 1 && ( +
    + + {page > 3 && } + {page > 4 && ...} + {Array.from({ length: 5 }, (_, i) => page - 2 + i) + .filter((p) => p >= 1 && p <= totalPage) + .map((p) => ( + + ))} + {page < totalPage - 3 && ...} + {page < totalPage - 2 && } + +
    + )} + + )} +
    +
    + ); +} diff --git a/src/weather/components/WeatherAlert.jsx b/src/weather/components/WeatherAlert.jsx new file mode 100644 index 00000000..2a216fd5 --- /dev/null +++ b/src/weather/components/WeatherAlert.jsx @@ -0,0 +1,143 @@ +import { useState, useCallback } from 'react'; +import { fetchWeatherAlerts } from '@/api/weatherApi'; + +const LIMIT = 10; + +function formatDate(date) { + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function getDefaultDates() { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - 7); + return { start: formatDate(start), end: formatDate(end) }; +} + +const defaults = getDefaultDates(); + +export default function WeatherAlert() { + const [startDate, setStartDate] = useState(defaults.start); + const [endDate, setEndDate] = useState(defaults.end); + const [list, setList] = useState([]); + const [page, setPage] = useState(1); + const [totalPage, setTotalPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const search = useCallback(async (targetPage) => { + if (!startDate || !endDate) return; + if (startDate > endDate) return; + + setIsLoading(true); + setError(null); + + try { + const result = await fetchWeatherAlerts({ + startPresentationDate: startDate, + endPresentationDate: endDate, + page: targetPage, + limit: LIMIT, + }); + setList(result.list); + setTotalPage(result.totalPage); + setPage(targetPage); + } catch (err) { + setError('기상특보 조회 중 오류가 발생했습니다.'); + setList([]); + setTotalPage(0); + } finally { + setIsLoading(false); + } + }, [startDate, endDate]); + + const handleSearch = () => { + search(1); + }; + + const handlePageChange = (newPage) => { + search(newPage); + }; + + return ( +
    +
    +
    기상특보
    + +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    + +
    + {isLoading &&
    조회 중...
    } + + {error &&
    {error}
    } + + {!isLoading && !error && list.length === 0 && ( +
    검색 결과가 없습니다.
    + )} + + {!isLoading && list.length > 0 && ( + <> + + + {totalPage > 1 && ( +
    + + {page > 3 && } + {page > 4 && ...} + {Array.from({ length: 5 }, (_, i) => page - 2 + i) + .filter((p) => p >= 1 && p <= totalPage) + .map((p) => ( + + ))} + {page < totalPage - 3 && ...} + {page < totalPage - 2 && } + +
    + )} + + )} +
    +
    + ); +} diff --git a/vite.config.js b/vite.config.js index 8ad5e532..3beefda9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -79,7 +79,7 @@ export default ({ mode, command }) => { }, // API 서버 '/api': { - target: env.VITE_API_URL || 'http://localhost:8080', + target: env.VITE_TRACK_API || 'http://localhost:8090', changeOrigin: true, secure: false, }, From e79c50baead73f9340e9dd8cfa6a49cc641cf2d4 Mon Sep 17 00:00:00 2001 From: "jeonghyo.K" Date: Wed, 11 Feb 2026 13:46:36 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EC=9C=84=EC=84=B1=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/commonApi.js | 32 ++ src/api/satelliteApi.js | 481 +++++++++++++++++ src/components/layout/Sidebar.jsx | 15 +- src/map/MapContainer.jsx | 59 ++- src/map/layers/satelliteLayer.js | 131 +++++ src/pages/SatellitePage.jsx | 57 +++ .../components/SatelliteImageManage.jsx | 369 +++++++++++++ src/satellite/components/SatelliteManage.jsx | 204 ++++++++ .../SatelliteManageRegisterPopup.jsx | 231 +++++++++ .../components/SatelliteProviderManage.jsx | 182 +++++++ .../SatelliteProviderRegisterPopup.jsx | 241 +++++++++ .../components/SatelliteRegisterPopup.jsx | 483 ++++++++++++++++++ src/satellite/components/Slider.jsx | 34 ++ src/satellite/hooks/useDraggable.js | 36 ++ src/stores/satelliteStore.js | 13 + src/weather/components/TidalInfo.jsx | 2 +- 16 files changed, 2534 insertions(+), 36 deletions(-) create mode 100644 src/api/commonApi.js create mode 100644 src/api/satelliteApi.js create mode 100644 src/map/layers/satelliteLayer.js create mode 100644 src/pages/SatellitePage.jsx create mode 100644 src/satellite/components/SatelliteImageManage.jsx create mode 100644 src/satellite/components/SatelliteManage.jsx create mode 100644 src/satellite/components/SatelliteManageRegisterPopup.jsx create mode 100644 src/satellite/components/SatelliteProviderManage.jsx create mode 100644 src/satellite/components/SatelliteProviderRegisterPopup.jsx create mode 100644 src/satellite/components/SatelliteRegisterPopup.jsx create mode 100644 src/satellite/components/Slider.jsx create mode 100644 src/satellite/hooks/useDraggable.js create mode 100644 src/stores/satelliteStore.js diff --git a/src/api/commonApi.js b/src/api/commonApi.js new file mode 100644 index 00000000..1bdafaf8 --- /dev/null +++ b/src/api/commonApi.js @@ -0,0 +1,32 @@ +/** + * 공통코드 API + */ + +const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search'; + +/** + * 공통코드 목록 조회 + * + * @param {string} commonCodeTypeNumber - 공통코드 유형 번호 + * @returns {Promise>} + */ +export async function fetchCommonCodeList(commonCodeTypeNumber) { + try { + const response = await fetch(COMMON_CODE_LIST_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ commonCodeTypeNumber }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return result?.codeList || []; + } catch (error) { + console.error('[fetchCommonCodeList] Error:', error); + throw error; + } +} diff --git a/src/api/satelliteApi.js b/src/api/satelliteApi.js new file mode 100644 index 00000000..abe82b4d --- /dev/null +++ b/src/api/satelliteApi.js @@ -0,0 +1,481 @@ +/** + * 위성 API + */ + +const SATELLITE_VIDEO_SEARCH_ENDPOINT = '/api/gis/satlit/search'; +const SATELLITE_CSV_ENDPOINT = '/api/gis/satlit/excelToJson'; +const SATELLITE_DETAIL_ENDPOINT = '/api/gis/satlit/id/search'; +const SATELLITE_UPDATE_ENDPOINT = '/api/gis/satlit/update'; +const SATELLITE_COMPANY_LIST_ENDPOINT = '/api/gis/satlit/sat-bz/all/search'; +const SATELLITE_MANAGE_LIST_ENDPOINT = '/api/gis/satlit/sat-mng/bz/search'; +const SATELLITE_SAVE_ENDPOINT = '/api/gis/satlit/save'; +const SATELLITE_COMPANY_SEARCH_ENDPOINT = '/api/gis/satlit/sat-bz/search'; +const SATELLITE_COMPANY_SAVE_ENDPOINT = '/api/gis/satlit/sat-bz/save'; +const SATELLITE_COMPANY_DETAIL_ENDPOINT = '/api/gis/satlit/sat-bz/id/search'; +const SATELLITE_COMPANY_UPDATE_ENDPOINT = '/api/gis/satlit/sat-bz/update'; +const SATELLITE_MANAGE_SEARCH_ENDPOINT = '/api/gis/satlit/sat-mng/search'; +const SATELLITE_MANAGE_SAVE_ENDPOINT = '/api/gis/satlit/sat-mng/save'; +const SATELLITE_MANAGE_DETAIL_ENDPOINT = '/api/gis/satlit/sat-mng/id/search'; +const SATELLITE_MANAGE_UPDATE_ENDPOINT = '/api/gis/satlit/sat-mng/update'; + +/** + * 위성영상 목록 조회 + * + * @param {Object} params + * @param {number} params.page - 페이지 번호 + * @param {string} [params.startDate] - 촬영 시작일 + * @param {string} [params.endDate] - 촬영 종료일 + * @param {string} [params.satelliteVideoName] - 위성영상명 + * @param {string} [params.satelliteVideoTransmissionCycle] - 전송주기 + * @param {string} [params.satelliteVideoKind] - 영상 종류 + * @param {string} [params.satelliteVideoOrbit] - 위성 궤도 + * @param {string} [params.satelliteVideoOrigin] - 영상 출처 + * @returns {Promise<{ list: Array, totalPage: number }>} + */ +export async function fetchSatelliteVideoList({ + page, + startDate, + endDate, + satelliteVideoName, + satelliteVideoTransmissionCycle, + satelliteVideoKind, + satelliteVideoOrbit, + satelliteVideoOrigin, + }) { + try { + const response = await fetch(SATELLITE_VIDEO_SEARCH_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + page, + startDate, + endDate, + satelliteVideoName, + satelliteVideoTransmissionCycle, + satelliteVideoKind, + satelliteVideoOrbit, + satelliteVideoOrigin, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + return { + list: result?.satelliteVideoInfoList || [], + totalPage: result?.totalPage || 0, + }; + } catch (error) { + console.error('[fetchSatelliteVideoList] Error:', error); + throw error; + } +} + +/** + * 위성영상 CSV → JSON 변환 (선박 좌표 추출) + * + * @param {string} csvFileName - CSV 파일명 + * @returns {Promise>} + */ +export async function fetchSatelliteCsvFeatures(csvFileName) { + try { + const response = await fetch(SATELLITE_CSV_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ csvFileName }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + const data = result?.jsonData; + if (!data) return []; + + const parsed = typeof data === 'string' ? JSON.parse(data) : data; + return parsed.map(({ lon, lat }) => ({ + coordinates: [parseFloat(lon), parseFloat(lat)], + })); + } catch (error) { + console.error('[fetchSatelliteCsvFeatures] Error:', error); + throw error; + } +} + +/** + * 위성영상 상세조회 + * + * @param {number} satelliteId - 위성 ID + * @returns {Promise} SatelliteVideoInfoOneDto + */ +export async function fetchSatelliteVideoDetail(satelliteId) { + try { + const response = await fetch(SATELLITE_DETAIL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ satelliteId }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return result?.satelliteVideoInfoById || null; + } catch (error) { + console.error('[fetchSatelliteVideoDetail] Error:', error); + throw error; + } +} + +/** + * 위성영상 수정 + * + * @param {Object} params + * @param {number} params.satelliteId + * @param {number} params.satelliteManageId + * @param {string} [params.photographDate] + * @param {string} [params.satelliteVideoName] + * @param {string} [params.satelliteVideoTransmissionCycle] + * @param {string} [params.satelliteVideoKind] + * @param {string} [params.satelliteVideoOrbit] + * @param {string} [params.satelliteVideoOrigin] + * @param {string} [params.photographPurpose] + * @param {string} [params.photographMode] + * @param {string} [params.purchaseCode] + * @param {number} [params.purchasePrice] + */ +export async function updateSatelliteVideo(params) { + try { + const response = await fetch(SATELLITE_UPDATE_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error('[updateSatelliteVideo] Error:', error); + throw error; + } +} + +/** + * 사업자 목록 조회 + * + * @returns {Promise>} + */ +export async function fetchSatelliteCompanyList() { + try { + const response = await fetch(SATELLITE_COMPANY_LIST_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({}), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return result?.satelliteCompanyNameList || []; + } catch (error) { + console.error('[fetchSatelliteCompanyList] Error:', error); + throw error; + } +} + +/** + * 사업자별 위성명 목록 조회 + * + * @param {number} companyNo - 사업자 번호 + * @returns {Promise>} + */ +export async function fetchSatelliteManageList(companyNo) { + try { + const response = await fetch(SATELLITE_MANAGE_LIST_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ companyNo }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return result?.satelliteManageInfoList || []; + } catch (error) { + console.error('[fetchSatelliteManageList] Error:', error); + throw error; + } +} + +/** + * 위성영상 등록 (multipart/form-data) + * + * @param {FormData} formData - 파일(tifFile, csvFile, cloudMaskFile) + 폼 필드 + */ +export async function saveSatelliteVideo(formData) { + try { + const response = await fetch(SATELLITE_SAVE_ENDPOINT, { + method: 'POST', + credentials: 'include', + body: formData, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error('[saveSatelliteVideo] Error:', error); + throw error; + } +} + +/** + * 위성 사업자 목록 검색 + * + * @param {Object} params + * @param {string} [params.companyTypeCode] - 사업자 분류 코드 + * @param {string} [params.companyName] - 사업자명 + * @returns {Promise<{ list: Array, totalPage: number }>} + */ +export async function searchSatelliteCompany({ companyTypeCode, companyName, page, limit }) { + try { + const response = await fetch(SATELLITE_COMPANY_SEARCH_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ companyTypeCode, companyName, page, limit }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return { + list: result?.satelliteCompanySearchList || [], + totalPage: result?.totalPage || 0, + }; + } catch (error) { + console.error('[searchSatelliteCompany] Error:', error); + throw error; + } +} + +/** + * 위성 사업자 등록 + * + * @param {Object} params + * @param {string} params.companyTypeCode - 사업자 분류 코드 + * @param {string} params.companyName - 사업자명 + * @param {string} params.nationalCode - 국가코드 + * @param {string} [params.location] - 소재지 + * @param {string} [params.companyDetail] - 상세내역 + */ +export async function saveSatelliteCompany(params) { + try { + const response = await fetch(SATELLITE_COMPANY_SAVE_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error('[saveSatelliteCompany] Error:', error); + throw error; + } +} + +/** + * 위성 사업자 상세조회 + * + * @param {number} companyNo - 사업자 번호 + * @returns {Promise} SatelliteCompanySearchDto + */ +export async function fetchSatelliteCompanyDetail(companyNo) { + try { + const response = await fetch(SATELLITE_COMPANY_DETAIL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ companyNo }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return result?.satelliteCompany || null; + } catch (error) { + console.error('[fetchSatelliteCompanyDetail] Error:', error); + throw error; + } +} + +/** + * 위성 사업자 수정 + * + * @param {Object} params + * @param {number} params.companyNo - 사업자 번호 + * @param {string} params.companyTypeCode - 사업자 분류 코드 + * @param {string} params.companyName - 사업자명 + * @param {string} params.nationalCode - 국가코드 + * @param {string} [params.location] - 소재지 + * @param {string} [params.companyDetail] - 상세내역 + */ +export async function updateSatelliteCompany(params) { + try { + const response = await fetch(SATELLITE_COMPANY_UPDATE_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error('[updateSatelliteCompany] Error:', error); + throw error; + } +} + +/** + * 위성관리 목록 검색 + * + * @param {Object} params + * @param {number} [params.companyNo] - 사업자 번호 + * @param {string} [params.satelliteName] - 위성명 + * @param {string} [params.sensorType] - 센서 타입 + * @returns {Promise<{ list: Array, totalPage: number }>} + */ +export async function searchSatelliteManage({ companyNo, satelliteName, sensorType, page, limit }) { + try { + const response = await fetch(SATELLITE_MANAGE_SEARCH_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ companyNo, satelliteName, sensorType, page, limit }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return { + list: result?.satelliteManageInfoSearchList || [], + totalPage: result?.totalPage || 0, + }; + } catch (error) { + console.error('[searchSatelliteManage] Error:', error); + throw error; + } +} + +/** + * 위성 관리 등록 + * + * @param {Object} params + * @param {number} params.companyNo - 사업자 번호 + * @param {string} params.satelliteName - 위성명 + * @param {string} [params.sensorType] - 센서 타입 + * @param {string} [params.photoResolution] - 촬영 해상도 + * @param {string} [params.frequency] - 주파수 + * @param {string} [params.photoDetail] - 상세내역 + */ +export async function saveSatelliteManage(params) { + try { + const response = await fetch(SATELLITE_MANAGE_SAVE_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error('[saveSatelliteManage] Error:', error); + throw error; + } +} + +/** + * 위성 관리 상세조회 + * + * @param {number} satelliteManageId - 위성 관리 ID + * @returns {Promise} SatelliteManageInfoDto + */ +export async function fetchSatelliteManageDetail(satelliteManageId) { + try { + const response = await fetch(SATELLITE_MANAGE_DETAIL_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ satelliteManageId }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + return result?.satelliteManageInfo || null; + } catch (error) { + console.error('[fetchSatelliteManageDetail] Error:', error); + throw error; + } +} + +/** + * 위성 관리 수정 + * + * @param {Object} params + * @param {number} params.satelliteManageId - 위성 관리 ID + * @param {number} params.companyNo - 사업자 번호 + * @param {string} params.satelliteName - 위성명 + * @param {string} [params.sensorType] - 센서 타입 + * @param {string} [params.photoResolution] - 촬영 해상도 + * @param {string} [params.frequency] - 주파수 + * @param {string} [params.photoDetail] - 상세내역 + */ +export async function updateSatelliteManage(params) { + try { + const response = await fetch(SATELLITE_MANAGE_UPDATE_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error('[updateSatelliteManage] Error:', error); + throw error; + } +} diff --git a/src/components/layout/Sidebar.jsx b/src/components/layout/Sidebar.jsx index 3b4c4414..97e6b157 100644 --- a/src/components/layout/Sidebar.jsx +++ b/src/components/layout/Sidebar.jsx @@ -19,6 +19,7 @@ import DisplayComponent from '../../component/wrap/side/DisplayComponent'; // 구현된 페이지 import ReplayPage from '../../pages/ReplayPage'; import WeatherPage from '../../pages/WeatherPage'; +import SatellitePage from '../../pages/SatellitePage'; /** * 사이드바 컴포넌트 @@ -64,7 +65,7 @@ export default function Sidebar() { const renderPanel = () => { const panelMap = { gnb1: DisplayComponent ? : null, - gnb2: Panel2Component ? : null, + gnb2: , gnb3: , gnb4: Panel4Component ? : null, gnb5: Panel5Component ? : null, @@ -78,11 +79,11 @@ export default function Sidebar() { }; return ( -
    - -
    - {renderPanel()} -
    -
    +
    + +
    + {renderPanel()} +
    +
    ); } diff --git a/src/map/MapContainer.jsx b/src/map/MapContainer.jsx index 5aefd676..044abc95 100644 --- a/src/map/MapContainer.jsx +++ b/src/map/MapContainer.jsx @@ -7,6 +7,7 @@ import { defaults as defaultInteractions, DragBox } from 'ol/interaction'; import { platformModifierKeyOnly } from 'ol/events/condition'; import { createBaseLayers } from './layers/baseLayer'; +import { satelliteLayer, csvDeckLayer } from './layers/satelliteLayer'; import { useMapStore, BASE_MAP_TYPES } from '../stores/mapStore'; import useShipStore from '../stores/shipStore'; import useShipData from '../hooks/useShipData'; @@ -347,6 +348,8 @@ export default function MapContainer() { worldMap, encMap, darkMap, + satelliteLayer, + csvDeckLayer, eastAsiaMap, korMap, ], @@ -376,7 +379,7 @@ export default function MapContainer() { const state = useShipStore.getState(); const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, - nationalVisibility, darkSignalVisible } = state; + nationalVisibility, darkSignalVisible } = state; // 국적 코드 매핑 (shipStore.js와 동일) const mapNational = (code) => { @@ -439,32 +442,32 @@ export default function MapContainer() { }, []); return ( - <> -
    - - {showLegend && (replayCompleted ? : )} - {hoverInfo && ( - - )} - {detailModals.map((modal) => ( - - ))} - - - - {replayCompleted && ( - { - useReplayStore.getState().reset(); - useAnimationStore.getState().reset(); - unregisterReplayLayers(); - showLiveShips(); // 라이브 선박 다시 표시 - shipBatchRenderer.immediateRender(); - }} - /> - )} - + <> +
    + + {showLegend && (replayCompleted ? : )} + {hoverInfo && ( + + )} + {detailModals.map((modal) => ( + + ))} + + + + {replayCompleted && ( + { + useReplayStore.getState().reset(); + useAnimationStore.getState().reset(); + unregisterReplayLayers(); + showLiveShips(); // 라이브 선박 다시 표시 + shipBatchRenderer.immediateRender(); + }} + /> + )} + ); } diff --git a/src/map/layers/satelliteLayer.js b/src/map/layers/satelliteLayer.js new file mode 100644 index 00000000..0b31678f --- /dev/null +++ b/src/map/layers/satelliteLayer.js @@ -0,0 +1,131 @@ +/** + * 위성영상 레이어 + * - TIF 영상: OL TileLayer + GeoServer WMS + * - CSV 선박 점: Deck.gl ScatterplotLayer + 별도 Deck 인스턴스 → OL WebGLTileLayer 래핑 + * + * 참조: mda-react-front/src/common/targetLayer.ts (satelliteLayer, deckSatellite 등) + * 참조: mda-react-front/src/util/satellite.ts (createSatellitePictureLayer, removeSatelliteLayer) + */ +import TileLayer from 'ol/layer/Tile'; +import TileWMS from 'ol/source/TileWMS'; +import WebGLTileLayer from 'ol/layer/WebGLTile'; +import { transformExtent, toLonLat } from 'ol/proj'; +import { Deck } from '@deck.gl/core'; +import { ScatterplotLayer } from '@deck.gl/layers'; + +// ===================== +// TIF 영상 레이어 (GeoServer WMS) +// ===================== +export const satelliteLayer = new TileLayer({ + source: new TileWMS({ + url: '/geo/geoserver/mda/wms', + params: { tiled: true, LAYERS: '' }, + }), + className: 'satellite-map', + zIndex: 10, + visible: false, +}); + +// ===================== +// CSV 선박 점 레이어 (Deck.gl ScatterplotLayer) +// ===================== +export const csvScatterLayer = new ScatterplotLayer({ + id: 'satellite-csv-layer', + data: [], + getPosition: (d) => d.coordinates, + getFillColor: [232, 232, 21], + getRadius: 3, + radiusUnits: 'pixels', + pickable: false, +}); + +export const csvDeck = new Deck({ + initialViewState: { + longitude: 127.1388684, + latitude: 37.4449168, + zoom: 6, + transitionDuration: 0, + }, + controller: false, + layers: [csvScatterLayer], +}); + +export const csvDeckLayer = new WebGLTileLayer({ + source: undefined, + zIndex: 200, + visible: false, + render: (frameState) => { + const { center, zoom } = frameState.viewState; + csvDeck.setProps({ + viewState: { + longitude: toLonLat(center)[0], + latitude: toLonLat(center)[1], + zoom: zoom - 1, + }, + }); + csvDeck.redraw(); + return csvDeck.canvas; + }, +}); + +// ===================== +// 표출/제거 함수 +// ===================== + +/** + * 위성영상 TIF를 지도에 표출 + * @param {import('ol/Map').default} map - OL 맵 인스턴스 + * @param {string} tifGeoName - GeoServer 레이어명 + * @param {[number,number,number,number]} extent - [minX, minY, maxX, maxY] EPSG:4326 + * @param {number} opacity - 0~1 + * @param {number} brightness - 0~200 (%) + */ +export function showSatelliteImage(map, tifGeoName, extent, opacity, brightness) { + const extent3857 = transformExtent(extent, 'EPSG:4326', 'EPSG:3857'); + + const source = new TileWMS({ + url: '/geo/geoserver/mda/wms', + params: { tiled: true, LAYERS: tifGeoName }, + hidpi: false, + transition: 0, + }); + + satelliteLayer.setExtent(extent3857); + satelliteLayer.setSource(source); + satelliteLayer.setOpacity(Number(opacity)); + satelliteLayer.setVisible(true); + + // CSS brightness 적용 + const el = document.querySelector('.satellite-map'); + if (el) { + el.style.filter = `brightness(${brightness}%)`; + } + + // 해당 영상 범위로 지도 이동 + map.getView().fit(extent3857); + + // 타일 로딩 강제 트리거 + source.refresh(); +} + +/** + * CSV 선박 좌표를 Deck.gl ScatterplotLayer로 표시 + * @param {Array<{ coordinates: [number, number] }>} features + */ +export function showCsvFeatures(features) { + const layer = csvScatterLayer.clone({ data: features }); + csvDeck.setProps({ layers: [layer] }); + csvDeckLayer.setVisible(true); +} + +/** + * 위성영상 + CSV 레이어 제거 + */ +export function hideSatelliteImage() { + satelliteLayer.setVisible(false); + satelliteLayer.setSource(null); + + const emptyLayer = csvScatterLayer.clone({ data: [] }); + csvDeck.setProps({ layers: [emptyLayer] }); + csvDeckLayer.setVisible(false); +} diff --git a/src/pages/SatellitePage.jsx b/src/pages/SatellitePage.jsx new file mode 100644 index 00000000..478ae26f --- /dev/null +++ b/src/pages/SatellitePage.jsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import SatelliteImageManage from '@/satellite/components/SatelliteImageManage'; +import SatelliteProviderManage from '@/satellite/components/SatelliteProviderManage'; +import SatelliteManage from '@/satellite/components/SatelliteManage'; + +const tabs = [ + { id: 'satellite01', label: '위성영상 관리' }, + { id: 'satellite02', label: '위성사업자 관리' }, + { id: 'satellite03', label: '위성 관리' }, +]; + +const tabComponents = { + satellite01: SatelliteImageManage, + satellite02: SatelliteProviderManage, + satellite03: SatelliteManage, +}; + +export default function SatellitePage({ isOpen, onToggle }) { + const [activeTab, setActiveTab] = useState('satellite01'); + + const ActiveComponent = tabComponents[activeTab]; + + return ( + + ); +} diff --git a/src/satellite/components/SatelliteImageManage.jsx b/src/satellite/components/SatelliteImageManage.jsx new file mode 100644 index 00000000..d2ce2ffa --- /dev/null +++ b/src/satellite/components/SatelliteImageManage.jsx @@ -0,0 +1,369 @@ +import { useState, useCallback } from 'react'; +import { fetchSatelliteVideoList, fetchSatelliteCsvFeatures } from '@/api/satelliteApi'; +import { useSatelliteStore } from '@/stores/satelliteStore'; +import { useMapStore } from '@/stores/mapStore'; +import { satelliteLayer, showSatelliteImage, showCsvFeatures, hideSatelliteImage } from '@/map/layers/satelliteLayer'; +import Slider from './Slider'; +import SatelliteRegisterPopup from './SatelliteRegisterPopup'; + +const LIMIT = 10; + +export default function SatelliteImageManage() { + const [isAccordionOpen, setIsAccordionOpen] = useState(false); + + // 폼 필터 state + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [satelliteVideoName, setSatelliteVideoName] = useState(''); + const [satelliteVideoKind, setSatelliteVideoKind] = useState(''); + const [satelliteVideoOrigin, setSatelliteVideoOrigin] = useState(''); + const [satelliteVideoOrbit, setSatelliteVideoOrbit] = useState(''); + const [satelliteVideoTransmissionCycle, setSatelliteVideoTransmissionCycle] = useState(''); + + // 결과 state + const [list, setList] = useState([]); + const [page, setPage] = useState(1); + const [totalPage, setTotalPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 지도 표출 state + const [activeImageId, setActiveImageId] = useState(null); + + // 상세 팝업 state + const [detailPopupId, setDetailPopupId] = useState(null); + + // 등록 팝업 state + const [isRegisterOpen, setIsRegisterOpen] = useState(false); + + // 위성 투명도/밝기 스토어 + const opacity = useSatelliteStore((s) => s.opacity); + const brightness = useSatelliteStore((s) => s.brightness); + const setOpacity = useSatelliteStore((s) => s.setOpacity); + const setBrightness = useSatelliteStore((s) => s.setBrightness); + + const toggleAccordion = () => setIsAccordionOpen((prev) => !prev); + + const search = useCallback(async (targetPage) => { + setIsLoading(true); + setError(null); + + try { + const result = await fetchSatelliteVideoList({ + page: targetPage, + startDate, + endDate, + satelliteVideoName, + satelliteVideoTransmissionCycle, + satelliteVideoKind, + satelliteVideoOrbit, + satelliteVideoOrigin, + }); + setList(result.list); + setTotalPage(result.totalPage); + setPage(targetPage); + } catch (err) { + setError('위성영상 조회 중 오류가 발생했습니다.'); + setList([]); + setTotalPage(0); + } finally { + setIsLoading(false); + } + }, [startDate, endDate, satelliteVideoName, satelliteVideoTransmissionCycle, satelliteVideoKind, satelliteVideoOrbit, satelliteVideoOrigin]); + + const handleSearch = () => { + search(1); + }; + + const handlePageChange = (newPage) => { + search(newPage); + }; + + // 투명도 변경 (Slider → 스토어 + 레이어 실시간 반영) + const handleOpacityChange = (v) => { + const val = v / 100; + setOpacity(val); + satelliteLayer.setOpacity(val); + }; + + // 밝기 변경 (Slider → 스토어 + CSS filter 실시간 반영) + const handleBrightnessChange = (v) => { + setBrightness(v); + const el = document.querySelector('.satellite-map'); + if (el) { + el.style.filter = `brightness(${v}%)`; + } + }; + + // btnMap: 위성영상 지도 표출 토글 + const handleShowOnMap = async (item) => { + const map = useMapStore.getState().map; + if (!map) return; + + // 같은 영상이면 제거 (토글) + if (activeImageId === item.satelliteManageId) { + hideSatelliteImage(); + setActiveImageId(null); + return; + } + + const { opacity: curOpacity, brightness: curBrightness } = useSatelliteStore.getState(); + const extent = [item.tifMinX, item.tifMinY, item.tifMaxX, item.tifMaxY]; + showSatelliteImage(map, item.tifGeoName, extent, curOpacity, curBrightness); + + // CSV 선박 점 표시 + if (item.csvFileName) { + try { + const features = await fetchSatelliteCsvFeatures(item.csvFileName); + showCsvFeatures(features); + } catch { + // CSV 없으면 무시 + } + } + + setActiveImageId(item.satelliteManageId); + }; + + return ( +
    +
    +
    위성영상 관리
    + +
    +
      +
    • + +
    • + + {/* 아코디언 — 상세검색 */} +
      +
    • + + +
    • +
    • + + +
    • +
      + +
    • + +
    • +
    • + +
    • +
    • + <> +
      + 투명도 +
      + +
      +
      +
      + 밝기 +
      + +
      +
      + + +
    • +
    +
    +
    + +
    +
    + {/* 스크롤영역 */} +
    + {isLoading &&
    조회 중...
    } + + {error &&
    {error}
    } + + {!isLoading && !error && list.length === 0 && ( +
    검색 결과가 없습니다.
    + )} + + {!isLoading && list.length > 0 && ( +
    + {list.map((item) => ( +
      +
    • +
      + {item.satelliteVideoName} + {item.photographDate} +
      +
    • +
    • +
        +
      • + 위성명 + {item.satelliteName} +
      • +
      • + 위성영상파일 + {item.tifFileName} +
      • +
      • + 영상 종류 + {item.satelliteVideoKind} +
      • +
      • + 영상 출처 + {item.satelliteVideoOrigin} +
      • +
      +
      + + + +
      +
    • +
    + ))} +
    + )} + + {!isLoading && totalPage > 1 && ( +
    + + {page > 3 && } + {page > 4 && ...} + {Array.from({ length: 5 }, (_, i) => page - 2 + i) + .filter((p) => p >= 1 && p <= totalPage) + .map((p) => ( + + ))} + {page < totalPage - 3 && ...} + {page < totalPage - 2 && } + +
    + )} +
    + {/* 하단버튼 영역 */} +
    + {/**/} + +
    +
    +
    + + {detailPopupId && ( + setDetailPopupId(null)} + onSaved={() => search(page)} + /> + )} + + {isRegisterOpen && ( + setIsRegisterOpen(false)} + onSaved={() => search(page)} + /> + )} +
    + ); +} diff --git a/src/satellite/components/SatelliteManage.jsx b/src/satellite/components/SatelliteManage.jsx new file mode 100644 index 00000000..c86b2989 --- /dev/null +++ b/src/satellite/components/SatelliteManage.jsx @@ -0,0 +1,204 @@ +import { useState, useEffect, useCallback } from 'react'; +import { fetchCommonCodeList } from '@/api/commonApi'; +import { fetchSatelliteCompanyList, searchSatelliteManage } from '@/api/satelliteApi'; +import SatelliteManageRegisterPopup from './SatelliteManageRegisterPopup'; + +export default function SatelliteManage() { + // 드롭다운 옵션 + const [companyOptions, setCompanyOptions] = useState([]); + const [sensorTypeOptions, setSensorTypeOptions] = useState([]); + + // 검색 폼 + const [companyNo, setCompanyNo] = useState(''); + const [satelliteName, setSatelliteName] = useState(''); + const [sensorType, setSensorType] = useState(''); + + // 결과 + const [list, setList] = useState([]); + const [page, setPage] = useState(1); + const [totalPage, setTotalPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 등록 팝업 state + const [isRegisterOpen, setIsRegisterOpen] = useState(false); + + // 상세/수정 팝업 state + const [detailManageId, setDetailManageId] = useState(null); + + // 마운트 시 사업자명 목록 + 센서타입 공통코드 병렬 로드 + useEffect(() => { + fetchSatelliteCompanyList() + .then(setCompanyOptions) + .catch(() => setCompanyOptions([])); + + fetchCommonCodeList('000092') + .then(setSensorTypeOptions) + .catch(() => setSensorTypeOptions([])); + }, []); + + const search = useCallback(async (targetPage) => { + setIsLoading(true); + setError(null); + + try { + const limit = 10; + const page = targetPage != null ? targetPage : 1; + const result = await searchSatelliteManage({ + companyNo: companyNo ? Number(companyNo) : undefined, + satelliteName, + sensorType, + page, + limit, + }); + setList(result.list); + setTotalPage(result.totalPage); + setPage(targetPage); + } catch { + setError('위성 관리 조회 중 오류가 발생했습니다.'); + setList([]); + setTotalPage(0); + } finally { + setIsLoading(false); + } + }, [companyNo, satelliteName, sensorType]); + + const handleSearch = () => { + search(1); + }; + + const handlePageChange = (newPage) => { + search(newPage); + }; + + return ( +
    +
    +
    위성 관리
    +
    +
      +
    • + + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +
    + +
    +
    + {/* 스크롤영역 */} +
    + {isLoading &&
    조회 중...
    } + + {error &&
    {error}
    } + + {!isLoading && !error && list.length === 0 && ( +
    검색 결과가 없습니다.
    + )} + + {!isLoading && list.length > 0 && ( +
    + {list.map((item) => ( +
      setDetailManageId(item.satelliteManageId)}> +
    • + 사업자명 + {item.companyName} +
    • +
    • + 위성명 + {item.satelliteName} +
    • +
    • + 센서 타입 + {item.sensorType} +
    • +
    • + 촬영 해상도 + {item.photoResolution} +
    • +
    + ))} +
    + )} + + {!isLoading && totalPage > 1 && ( +
    + + {page > 3 && } + {page > 4 && ...} + {Array.from({ length: 5 }, (_, i) => page - 2 + i) + .filter((p) => p >= 1 && p <= totalPage) + .map((p) => ( + + ))} + {page < totalPage - 3 && ...} + {page < totalPage - 2 && } + +
    + )} +
    + {/* 하단버튼 영역 */} +
    + +
    +
    +
    + + {isRegisterOpen && ( + setIsRegisterOpen(false)} + onSaved={() => search(page)} + /> + )} + + {detailManageId && ( + setDetailManageId(null)} + onSaved={() => search(page)} + /> + )} +
    + ); +} diff --git a/src/satellite/components/SatelliteManageRegisterPopup.jsx b/src/satellite/components/SatelliteManageRegisterPopup.jsx new file mode 100644 index 00000000..23ef156c --- /dev/null +++ b/src/satellite/components/SatelliteManageRegisterPopup.jsx @@ -0,0 +1,231 @@ +import { useState, useEffect } from 'react'; +import { fetchCommonCodeList } from '@/api/commonApi'; +import { + saveSatelliteManage, + fetchSatelliteManageDetail, + updateSatelliteManage, +} from '@/api/satelliteApi'; +import useDraggable from '../hooks/useDraggable'; + +/** + * 위성 관리 등록/수정 팝업 + * @param {{ satelliteManageId?: number, onClose: () => void, onSaved: () => void }} props + * satelliteManageId가 있으면 수정 모드, 없으면 등록 모드 + */ +export default function SatelliteManageRegisterPopup({ satelliteManageId, onClose, onSaved }) { + const isEditMode = !!satelliteManageId; + + const [sensorTypeOptions, setSensorTypeOptions] = useState([]); + + const [companyNo, setCompanyNo] = useState(''); + const [satelliteName, setSatelliteName] = useState(''); + const [sensorType, setSensorType] = useState(''); + const [photoResolution, setPhotoResolution] = useState(''); + const [frequency, setFrequency] = useState(''); + const [photoDetail, setPhotoDetail] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const { position, handleMouseDown } = useDraggable(); + + // 마운트 시 센서타입 공통코드 로드 + 수정 모드면 상세조회 + useEffect(() => { + let cancelled = false; + + async function load() { + setIsLoading(true); + setError(null); + + try { + const codeList = await fetchCommonCodeList('000092'); + if (cancelled) return; + setSensorTypeOptions(codeList); + + if (satelliteManageId) { + const data = await fetchSatelliteManageDetail(satelliteManageId); + if (cancelled || !data) return; + + setCompanyNo(data.companyNo ?? ''); + setSatelliteName(data.satelliteName || ''); + setSensorType(data.sensorType || ''); + setPhotoResolution(data.photoResolution || ''); + setFrequency(data.frequency || ''); + setPhotoDetail(data.photoDetail || ''); + } + } catch { + setError('데이터 조회 중 오류가 발생했습니다.'); + } finally { + if (!cancelled) setIsLoading(false); + } + } + + load(); + return () => { cancelled = true; }; + }, [satelliteManageId]); + + const handleSave = async () => { + setIsSaving(true); + setError(null); + + try { + if (isEditMode) { + await updateSatelliteManage({ + satelliteManageId, + companyNo: Number(companyNo), + satelliteName, + sensorType, + photoResolution, + frequency, + photoDetail, + }); + } else { + await saveSatelliteManage({ + companyNo: Number(companyNo), + satelliteName, + sensorType, + photoResolution, + frequency, + photoDetail, + }); + } + onSaved?.(); + onClose(); + } catch { + setError('저장 중 오류가 발생했습니다.'); + } finally { + setIsSaving(false); + } + }; + + return ( +
    +
    +
    + + {isEditMode ? '위성 관리 상세' : '위성 관리 등록'} + +
    + +
    + {isLoading &&
    조회 중...
    } + {error &&
    {error}
    } + + {!isLoading && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    위성 관리 등록 - 사업자명, 위성명, 센서 타입, 촬영 해상도, 주파수, 상세내역에 대한 내용을 등록하는 표입니다.
    사업자명 * + setCompanyNo(e.target.value)} + /> +
    위성명 * + setSatelliteName(e.target.value)} + /> +
    센서 타입 + +
    촬영 해상도 + setPhotoResolution(e.target.value)} + /> +
    주파수 + setFrequency(e.target.value)} + /> +
    상세내역 +