From a0a7f19e58070406b41312813d0e2fea913d4e0e Mon Sep 17 00:00:00 2001 From: "jeonghyo.K" Date: Tue, 10 Feb 2026 13:15:12 +0900 Subject: [PATCH] =?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, },