기상 메뉴 개발

1. 각 탭의 컴포넌트 개발
2. api 연결 및 화면 표출 추가
This commit is contained in:
jeonghyo.K 2026-02-10 13:15:12 +09:00
부모 e45be93e71
커밋 a0a7f19e58
11개의 변경된 파일1626개의 추가작업 그리고 14개의 파일을 삭제

파일 보기

@ -35,16 +35,16 @@ export default function App() {
/publish/* 접근하여 퍼블리시 결과물 미리보기 /publish/* 접근하여 퍼블리시 결과물 미리보기
프로덕션 빌드 라우트와 관련 모듈이 제외됨 프로덕션 빌드 라우트와 관련 모듈이 제외됨
===================== */} ===================== */}
{import.meta.env.DEV && PublishRouter && ( {/*{import.meta.env.DEV && PublishRouter && (*/}
<Route {/* <Route*/}
path="/publish/*" {/* path="/publish/*"*/}
element={ {/* element={*/}
<Suspense fallback={<div style={{ color: '#fff', padding: '2rem' }}>Loading publish...</div>}> {/* <Suspense fallback={<div style={{ color: '#fff', padding: '2rem' }}>Loading publish...</div>}>*/}
<PublishRouter /> {/* <PublishRouter />*/}
</Suspense> {/* </Suspense>*/}
} {/* }*/}
/> {/* />*/}
)} {/*)}*/}
</Routes> </Routes>
</> </>
); );

309
src/api/weatherApi.js Normal file
파일 보기

@ -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<Array>}
*/
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<Object|null>} 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<Array>} 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<Object|null>} ObservatorySelectDetailDto 또는 null
*/
const AIRPORT_ENDPOINT = '/api/gis/weather/airport/search';
const AIRPORT_DETAIL_ENDPOINT = '/api/gis/weather/airport/select';
/**
* 공항 목록 조회
*
* @returns {Promise<Array>} 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<Object|null>} 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;
}
}

파일 보기

@ -18,6 +18,7 @@ const Panel8Component = getPanel('Panel8Component');
import DisplayComponent from '../../component/wrap/side/DisplayComponent'; import DisplayComponent from '../../component/wrap/side/DisplayComponent';
// //
import ReplayPage from '../../pages/ReplayPage'; import ReplayPage from '../../pages/ReplayPage';
import WeatherPage from '../../pages/WeatherPage';
/** /**
* 사이드바 컴포넌트 * 사이드바 컴포넌트
@ -64,7 +65,7 @@ export default function Sidebar() {
const panelMap = { const panelMap = {
gnb1: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null, gnb1: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null,
gnb2: Panel2Component ? <Panel2Component {...panelProps} /> : null, gnb2: Panel2Component ? <Panel2Component {...panelProps} /> : null,
gnb3: Panel3Component ? <Panel3Component {...panelProps} /> : null, gnb3: <WeatherPage {...panelProps} />,
gnb4: Panel4Component ? <Panel4Component {...panelProps} /> : null, gnb4: Panel4Component ? <Panel4Component {...panelProps} /> : null,
gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null, gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null,
gnb6: Panel6Component ? <Panel6Component {...panelProps} /> : null, gnb6: Panel6Component ? <Panel6Component {...panelProps} /> : null,

63
src/pages/WeatherPage.jsx Normal file
파일 보기

@ -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 (
<aside className={`slidePanel ${!isOpen ? 'is-closed' : ''}`}>
{/* 탭 버튼 */}
<div className="tabBox">
<div className="tabDefault">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
className={activeTab === tab.id ? 'on' : ''}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* 탭 콘텐츠 */}
{ActiveComponent && <ActiveComponent />}
{/* 사이드패널 토글버튼 */}
<button
type="button"
className="toogle"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="blind">
{isOpen ? '패널 접기' : '패널 열기'}
</span>
</button>
</aside>
);
}

파일 보기

@ -41,9 +41,10 @@ export default function Panel3Component({ isOpen, onToggle }) {
<label> <label>
<span>일자</span> <span>일자</span>
<div className='labelRow'> <div className='labelRow'>
<input type="text" className="dateInput" placeholder="연도-월-일" /> <input type="date" className="dateInput" placeholder="연도-월-일" />
<span>-</span> <span>-</span>
<input type="text"className="dateInput" placeholder="연도-월-일" /></div> <input type="date" className="dateInput" placeholder="연도-월-일" />
</div>
</label> </label>
</li> </li>
<li className="fgBtn"> <li className="fgBtn">

파일 보기

@ -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 ? (
<div
className="popupMap osbInfo"
style={{ position: 'relative', top: 'auto', left: 'auto', transform: 'none' }}
>
<div className="pmHeader">
<div className="rowL">
<span className="title">{popupData.airportName}</span>
</div>
<button className="pmClose" onClick={closePopup} />
</div>
<div className="pmBody">
<ul className="osbStatus">
<li>
<span className="label">기온</span>
<span className="value">{popupData.temperature ?? '-'} °C</span>
</li>
<li>
<span className="label">기상</span>
<span className="value">{popupData.presentWeatherTypeCode ?? '-'}</span>
</li>
<li>
<span className="label">풍향</span>
<span className="value">{popupData.windDirection ?? '-'} °</span>
</li>
<li>
<span className="label">풍속</span>
<span className="value">{popupData.windSpeed ?? '-'} kt</span>
</li>
<li>
<span className="label">시정</span>
<span className="value">{popupData.visibility ?? '-'}</span>
</li>
<li>
<span className="label">운고</span>
<span className="value">{popupData.ceiling ?? '-'}</span>
</li>
<li>
<span className="label">강수량</span>
<span className="value">{popupData.rainFall ?? '-'} mm</span>
</li>
</ul>
</div>
</div>
) : null,
popupElRef.current,
)}
<div className="tabWrap is-active">
<div className="tabTop">
<div className="title">항공기상</div>
<div className="legend">
<span className="legendTitle">항공기상 범례</span>
<ul className="legendList">
<li>
<img src="/images/ico_legend_aircraft.svg" alt="공항" />
공항
</li>
</ul>
</div>
</div>
<div className="tabBtm noLine">
{isLoading && <div className="loading">데이터를 불러오는 ...</div>}
<ul className="lineList">
<li>
<label className="checkbox checkL">
<input type="checkbox" checked={isAllChecked} onChange={handleToggleAll} />
<span>전체</span>
</label>
</li>
{airports.map((airport) => (
<li key={airport.airportId}>
<label className="checkbox checkL">
<input
type="checkbox"
checked={checkedIds.has(airport.airportId)}
onChange={() => handleToggle(airport.airportId)}
/>
<span>
{airport.airportName}({airport.airportIcaoCode})
</span>
</label>
</li>
))}
</ul>
</div>
</div>
</>
);
}

파일 보기

@ -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' && (
<div
className="popupMap osbInfo"
style={{ position: 'relative', top: 'auto', left: 'auto', transform: 'none' }}
>
<div className="pmHeader">
<div className="rowL">
<span className="title">{popupData.observatoryName}</span>
</div>
<button className="pmClose" onClick={closePopup} />
</div>
<div className="pmBody">
<ul className="osbStatus">
<li className="date">
{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,
})
: '-'}
</li>
<li>
<span className="label">조위</span>
<span className="value">{popupData.tidalLevel ?? '-'} cm</span>
</li>
<li>
<span className="label">수온</span>
<span className="value">{popupData.seawaterTemperature ?? '-'} °C</span>
</li>
<li>
<span className="label">기온</span>
<span className="value">{popupData.temperature ?? '-'} °C</span>
</li>
<li>
<span className="label">기압</span>
<span className="value">{popupData.atmosphericPressure ?? '-'} hPa</span>
</li>
<li>
<span className="label">풍향</span>
<span className="value">{popupData.windDirection ?? '-'} deg</span>
</li>
<li>
<span className="label">풍속</span>
<span className="value">{popupData.windSpeed ?? '-'} m/s</span>
</li>
<li>
<span className="label">유속방향</span>
<span className="value">{popupData.tidalDirection ?? '-'} deg</span>
</li>
<li>
<span className="label">유속</span>
<span className="value">{popupData.tidalSpeed ?? '-'} m/s</span>
</li>
<li>
<span className="label">파고</span>
<span className="value">{popupData.waveHeight ?? '-'} m</span>
</li>
</ul>
</div>
</div>
)}
{popupData && popupType === 'sunrise' && (
<div
className="popupMap osbInfo"
style={{ position: 'relative', top: 'auto', left: 'auto', transform: 'none' }}
>
<div className="pmHeader">
<div className="rowL">
<span className="title">{popupData.locationName}</span>
</div>
<button className="pmClose" onClick={closePopup} />
</div>
<div className="pmBody">
<ul className="osbStatus">
<li className="date">{popupData.date ?? '-'} ({popupData.day ?? '-'})</li>
<li>
<span className="label">일출</span>
<span className="value">{popupData.sunriseTime ?? '-'}</span>
</li>
<li>
<span className="label">일몰</span>
<span className="value">{popupData.sunsetTime ?? '-'}</span>
</li>
</ul>
</div>
</div>
)}
</>,
popupElRef.current,
)}
<div className="tabWrap is-active">
<div className="tabTop">
<div className="title">조석정보</div>
<div className="legend">
<span className="legendTitle">조위관측 범례</span>
<ul className="legendList">
{TIDAL_INFO_TYPES.map(({ type, icon }) => (
<li key={type}>
<img src={icon} alt={type} />
{type}
</li>
))}
</ul>
</div>
</div>
<div className="tabBtm">
{isLoading && <div className="loading">데이터를 불러오는 ...</div>}
<ul className="lineList">
{TIDAL_INFO_TYPES.map(({ type }) => (
<li key={type}>
<label className="checkbox checkL">
<input
type="checkbox"
checked={checkedTypes.has(type)}
onChange={() => handleToggle(type)}
/>
<span>{type}</span>
</label>
</li>
))}
</ul>
</div>
</div>
</>
);
}

파일 보기

@ -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 ? (
<div
className="popupMap osbInfo"
style={{ position: 'relative', top: 'auto', left: 'auto', transform: 'none' }}
>
<div className="pmHeader">
<div className="rowL">
<span className="title">{popupData.observatoryName}</span>
</div>
<button className="pmClose" onClick={closePopup} />
</div>
<div className="pmBody">
<ul className="osbStatus">
<li className="date">
{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,
})
: '-'}
</li>
<li>
<span className="label">조위</span>
<span className="value">{popupData.tidalLevel ?? '-'} cm</span>
</li>
<li>
<span className="label">수온</span>
<span className="value">{popupData.seawaterTemperature ?? '-'} °C</span>
</li>
<li>
<span className="label">기온</span>
<span className="value">{popupData.temperature ?? '-'} °C</span>
</li>
<li>
<span className="label">기압</span>
<span className="value">{popupData.atmosphericPressure ?? '-'} hPa</span>
</li>
<li>
<span className="label">풍향</span>
<span className="value">{popupData.windDirection ?? '-'} deg</span>
</li>
<li>
<span className="label">풍속</span>
<span className="value">{popupData.windSpeed ?? '-'} m/s</span>
</li>
<li>
<span className="label">유속방향</span>
<span className="value">{popupData.tidalDirection ?? '-'} deg</span>
</li>
<li>
<span className="label">유속</span>
<span className="value">{popupData.tidalSpeed ?? '-'} m/s</span>
</li>
<li>
<span className="label">파고</span>
<span className="value">{popupData.waveHeight ?? '-'} m</span>
</li>
</ul>
</div>
</div>
) : null,
popupElRef.current,
)}
<div className="tabWrap is-active">
<div className="tabTop">
<div className="title">조위관측</div>
<div className="legend">
<span className="legendTitle">조위관측 범례</span>
<ul className="legendList">
{OBSERVATORY_TYPES.map(({ type, icon }) => (
<li key={type}>
<img src={icon} alt={type} />
{type}
</li>
))}
</ul>
</div>
</div>
<div className="tabBtm">
{isLoading && <div className="loading">데이터를 불러오는 ...</div>}
<ul className="lineList">
{OBSERVATORY_TYPES.map(({ type }) => (
<li key={type}>
<label className="checkbox checkL">
<input
type="checkbox"
checked={checkedTypes.has(type)}
onChange={() => handleToggle(type)}
/>
<span>{type}</span>
</label>
</li>
))}
</ul>
</div>
</div>
</>
);
}

파일 보기

@ -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 (
<div className="tabWrap is-active">
<div className="tabTop">
<div className="title">태풍정보</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>연도</span>
<select value={year} onChange={(e) => setYear(e.target.value)}>
{yearOptions.map((y) => (
<option key={y} value={y}>{y}</option>
))}
</select>
</label>
<label>
<span></span>
<select value={month} onChange={(e) => setMonth(e.target.value)}>
<option value="">전체</option>
{monthOptions.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
{isLoading && <div className="loading">조회 ...</div>}
{error && <div className="error">{error}</div>}
{!isLoading && !error && list.length === 0 && (
<div className="empty">검색 결과가 없습니다.</div>
)}
{!isLoading && list.length > 0 && (
<>
<ul className="colList lineSB">
{list.map((item, idx) => {
const isExpanded = expandedIndex === idx;
const status = item.typhoonEndTime ? '종료' : '진행중';
return (
<li key={idx} style={isExpanded ? { flexDirection: 'column', alignItems: 'stretch' } : undefined}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleToggle(idx, item);
}}
>
<span className="title">
{(page - 1) * LIMIT + idx + 1}. {item.typhoonName} ({status})
</span>
<span className="meta">
발생일시 {item.typhoonBeginningTime} / 종료일시 {item.typhoonEndTime || '-'}
</span>
</a>
{isExpanded && (
<div className={`acdListBox${isExpanded ? ' open' : ''}`}>
{isDetailLoading && <div className="loading">상세 조회 ...</div>}
{!isDetailLoading && detailData.length === 0 && (
<div className="empty">진행정보가 없습니다.</div>
)}
{!isDetailLoading && detailData.length > 0 && (
<ul className="acdList">
{detailData.map((d, dIdx) => (
<li key={dIdx}>
<span>발표순서: {d.presentationSequence}</span>
<span>시간간격: {d.timeInterval}</span>
<span>발표시간: {d.presentationTime}</span>
</li>
))}
</ul>
)}
</div>
)}
</li>
);
})}
</ul>
{totalPage > 1 && (
<div className="pagination">
<button type="button" className={page <= 1 ? 'disabled' : ''} disabled={page <= 1} onClick={() => handlePageChange(page - 1)}>&lt;</button>
{page > 3 && <button type="button" onClick={() => handlePageChange(1)}>1</button>}
{page > 4 && <span className="ellipsis">...</span>}
{Array.from({ length: 5 }, (_, i) => page - 2 + i)
.filter((p) => p >= 1 && p <= totalPage)
.map((p) => (
<button key={p} type="button" className={p === page ? 'on' : ''} onClick={() => handlePageChange(p)}>{p}</button>
))}
{page < totalPage - 3 && <span className="ellipsis">...</span>}
{page < totalPage - 2 && <button type="button" onClick={() => handlePageChange(totalPage)}>{totalPage}</button>}
<button type="button" className={page >= totalPage ? 'disabled' : ''} disabled={page >= totalPage} onClick={() => handlePageChange(page + 1)}>&gt;</button>
</div>
)}
</>
)}
</div>
</div>
);
}

파일 보기

@ -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 (
<div className="tabWrap is-active">
<div className="tabTop">
<div className="title">기상특보</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>일자</span>
<div className="labelRow">
<input
type="date"
className="dateInput"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<span>-</span>
<input
type="date"
className="dateInput"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm">
{isLoading && <div className="loading">조회 ...</div>}
{error && <div className="error">{error}</div>}
{!isLoading && !error && list.length === 0 && (
<div className="empty">검색 결과가 없습니다.</div>
)}
{!isLoading && list.length > 0 && (
<>
<ul className="colList lineSB">
{list.map((item, idx) => (
<li key={idx}>
<a href="#" onClick={(e) => e.preventDefault()}>
<span className="title">
{(page - 1) * LIMIT + idx + 1}. {item.specialNewsKind}: {item.specialZoneName}
</span>
<span className="meta">
발표일시 {item.presentationTime} / 발효일시 {item.effectivationTime}
</span>
</a>
</li>
))}
</ul>
{totalPage > 1 && (
<div className="pagination">
<button type="button" className={page <= 1 ? 'disabled' : ''} disabled={page <= 1} onClick={() => handlePageChange(page - 1)}>&lt;</button>
{page > 3 && <button type="button" onClick={() => handlePageChange(1)}>1</button>}
{page > 4 && <span className="ellipsis">...</span>}
{Array.from({ length: 5 }, (_, i) => page - 2 + i)
.filter((p) => p >= 1 && p <= totalPage)
.map((p) => (
<button key={p} type="button" className={p === page ? 'on' : ''} onClick={() => handlePageChange(p)}>{p}</button>
))}
{page < totalPage - 3 && <span className="ellipsis">...</span>}
{page < totalPage - 2 && <button type="button" onClick={() => handlePageChange(totalPage)}>{totalPage}</button>}
<button type="button" className={page >= totalPage ? 'disabled' : ''} disabled={page >= totalPage} onClick={() => handlePageChange(page + 1)}>&gt;</button>
</div>
)}
</>
)}
</div>
</div>
);
}

파일 보기

@ -79,7 +79,7 @@ export default ({ mode, command }) => {
}, },
// API 서버 // API 서버
'/api': { '/api': {
target: env.VITE_API_URL || 'http://localhost:8080', target: env.VITE_TRACK_API || 'http://localhost:8090',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },