위성 메뉴 개발

This commit is contained in:
jeonghyo.K 2026-02-11 13:46:36 +09:00
부모 a0a7f19e58
커밋 e79c50baea
16개의 변경된 파일2534개의 추가작업 그리고 36개의 파일을 삭제

32
src/api/commonApi.js Normal file
파일 보기

@ -0,0 +1,32 @@
/**
* 공통코드 API
*/
const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search';
/**
* 공통코드 목록 조회
*
* @param {string} commonCodeTypeNumber - 공통코드 유형 번호
* @returns {Promise<Array<{ commonCodeTypeName: string, commonCodeTypeNumber: string, commonCodeEtc: string }>>}
*/
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;
}
}

481
src/api/satelliteApi.js Normal file
파일 보기

@ -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<Array<{ coordinates: [number, number] }>>}
*/
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<Object>} 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<Array<{ companyNo: number, companyName: string }>>}
*/
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<Array<{ satelliteManageId: number, satelliteName: string }>>}
*/
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<Object>} 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<Object>} 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;
}
}

파일 보기

@ -19,6 +19,7 @@ import DisplayComponent from '../../component/wrap/side/DisplayComponent';
// //
import ReplayPage from '../../pages/ReplayPage'; import ReplayPage from '../../pages/ReplayPage';
import WeatherPage from '../../pages/WeatherPage'; import WeatherPage from '../../pages/WeatherPage';
import SatellitePage from '../../pages/SatellitePage';
/** /**
* 사이드바 컴포넌트 * 사이드바 컴포넌트
@ -64,7 +65,7 @@ export default function Sidebar() {
const renderPanel = () => { const renderPanel = () => {
const panelMap = { const panelMap = {
gnb1: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null, gnb1: DisplayComponent ? <DisplayComponent {...panelProps} initialTab="filter" /> : null,
gnb2: Panel2Component ? <Panel2Component {...panelProps} /> : null, gnb2: <SatellitePage {...panelProps} />,
gnb3: <WeatherPage {...panelProps} />, gnb3: <WeatherPage {...panelProps} />,
gnb4: Panel4Component ? <Panel4Component {...panelProps} /> : null, gnb4: Panel4Component ? <Panel4Component {...panelProps} /> : null,
gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null, gnb5: Panel5Component ? <Panel5Component {...panelProps} /> : null,
@ -78,11 +79,11 @@ export default function Sidebar() {
}; };
return ( return (
<section id="sidePanel"> <section id="sidePanel">
<SideNav activeKey={activeKey} onChange={handleMenuChange} /> <SideNav activeKey={activeKey} onChange={handleMenuChange} />
<div className="sidePanelContent"> <div className="sidePanelContent">
{renderPanel()} {renderPanel()}
</div> </div>
</section> </section>
); );
} }

파일 보기

@ -7,6 +7,7 @@ import { defaults as defaultInteractions, DragBox } from 'ol/interaction';
import { platformModifierKeyOnly } from 'ol/events/condition'; import { platformModifierKeyOnly } from 'ol/events/condition';
import { createBaseLayers } from './layers/baseLayer'; import { createBaseLayers } from './layers/baseLayer';
import { satelliteLayer, csvDeckLayer } from './layers/satelliteLayer';
import { useMapStore, BASE_MAP_TYPES } from '../stores/mapStore'; import { useMapStore, BASE_MAP_TYPES } from '../stores/mapStore';
import useShipStore from '../stores/shipStore'; import useShipStore from '../stores/shipStore';
import useShipData from '../hooks/useShipData'; import useShipData from '../hooks/useShipData';
@ -347,6 +348,8 @@ export default function MapContainer() {
worldMap, worldMap,
encMap, encMap,
darkMap, darkMap,
satelliteLayer,
csvDeckLayer,
eastAsiaMap, eastAsiaMap,
korMap, korMap,
], ],
@ -376,7 +379,7 @@ export default function MapContainer() {
const state = useShipStore.getState(); const state = useShipStore.getState();
const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility,
nationalVisibility, darkSignalVisible } = state; nationalVisibility, darkSignalVisible } = state;
// (shipStore.js ) // (shipStore.js )
const mapNational = (code) => { const mapNational = (code) => {
@ -439,32 +442,32 @@ export default function MapContainer() {
}, []); }, []);
return ( return (
<> <>
<div id="map" ref={mapRef} className="map-container" /> <div id="map" ref={mapRef} className="map-container" />
<TopBar /> <TopBar />
{showLegend && (replayCompleted ? <ReplayLegend /> : <ShipLegend />)} {showLegend && (replayCompleted ? <ReplayLegend /> : <ShipLegend />)}
{hoverInfo && ( {hoverInfo && (
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} /> <ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
)} )}
{detailModals.map((modal) => ( {detailModals.map((modal) => (
<ShipDetailModal key={modal.id} modal={modal} /> <ShipDetailModal key={modal.id} modal={modal} />
))} ))}
<ShipContextMenu /> <ShipContextMenu />
<GlobalTrackQueryViewer /> <GlobalTrackQueryViewer />
<ReplayLoadingOverlay /> <ReplayLoadingOverlay />
{replayCompleted && ( {replayCompleted && (
<ReplayTimeline <ReplayTimeline
fromDate={replayQuery?.startTime} fromDate={replayQuery?.startTime}
toDate={replayQuery?.endTime} toDate={replayQuery?.endTime}
onClose={() => { onClose={() => {
useReplayStore.getState().reset(); useReplayStore.getState().reset();
useAnimationStore.getState().reset(); useAnimationStore.getState().reset();
unregisterReplayLayers(); unregisterReplayLayers();
showLiveShips(); // showLiveShips(); //
shipBatchRenderer.immediateRender(); shipBatchRenderer.immediateRender();
}} }}
/> />
)} )}
</> </>
); );
} }

파일 보기

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

파일 보기

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

파일 보기

@ -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 (
<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>
{/* 아코디언 — 상세검색 */}
<div className={`accordion pt8 ${isAccordionOpen ? 'is-open' : ''}`}>
<li>
<label>
<span>영상 종류</span>
<select
value={satelliteVideoKind}
onChange={(e) => setSatelliteVideoKind(e.target.value)}
>
<option value="">전체</option>
<option value="VIRS">VIRS</option>
<option value="ICEYE_SAR">ICEYE_SAR</option>
<option value="광학">광학</option>
<option value="예약">예약</option>
<option value="RF">RF</option>
</select>
</label>
<label>
<span>영상 출처</span>
<select
value={satelliteVideoOrigin}
onChange={(e) => setSatelliteVideoOrigin(e.target.value)}
>
<option value="">전체</option>
<option value="국내/자동">국내/자동</option>
<option value="국내/수동">국내/수동</option>
<option value="국외/수동">국외/수동</option>
<option value="기타">기타</option>
</select>
</label>
</li>
<li>
<label>
<span>위성 궤도</span>
<select
value={satelliteVideoOrbit}
onChange={(e) => setSatelliteVideoOrbit(e.target.value)}
>
<option value="">전체</option>
<option value="저궤도">저궤도</option>
<option value="중궤도">중궤도</option>
<option value="정지궤도">정지궤도</option>
<option value="기타">기타</option>
</select>
</label>
<label>
<span>주기</span>
<select
value={satelliteVideoTransmissionCycle}
onChange={(e) => setSatelliteVideoTransmissionCycle(e.target.value)}
>
<option value="">전체</option>
<option value="0">0</option>
<option value="10">10</option>
<option value="30">30</option>
<option value="60">60</option>
</select>
</label>
</li>
</div>
<li>
<label>
<span>위성영상명</span>
<input
type="text"
placeholder="위성영상명"
value={satelliteVideoName}
onChange={(e) => setSatelliteVideoName(e.target.value)}
/>
</label>
</li>
<li>
<button
type="button"
className={`btn btnS semi btnToggle ${isAccordionOpen ? 'is-open' : ''}`}
aria-expanded={isAccordionOpen}
onClick={toggleAccordion}
>
상세검색
{isAccordionOpen ? ' 닫기' : ' 열기'}
</button>
</li>
<li className="fgBtn rowSB">
<>
<div className="row gap10">
<span>투명도</span>
<div>
<Slider
label="투명도 조절"
value={Math.round(opacity * 100)}
onChange={handleOpacityChange}
/>
</div>
</div>
<div className="row gap10">
<span>밝기</span>
<div>
<Slider
label="밝기 조절"
min={0}
max={200}
value={brightness}
onChange={handleBrightnessChange}
/>
</div>
</div>
</>
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm noSc">
<div className="tabBtmInner">
{/* 스크롤영역 */}
<div className="tabBtmCnt">
{isLoading && <div className="loading">조회 ...</div>}
{error && <div className="error">{error}</div>}
{!isLoading && !error && list.length === 0 && (
<div className="empty">검색 결과가 없습니다.</div>
)}
{!isLoading && list.length > 0 && (
<div className="detailWrap">
{list.map((item) => (
<ul key={item.satelliteManageId} className="detailBox stretch">
<li className="dbHeader">
<div className="headerL item2">
<span className="name">{item.satelliteVideoName}</span>
<span className="type">{item.photographDate}</span>
</div>
</li>
<li>
<ul className="dbList">
<li>
<span className="label">위성명</span>
<span className="value">{item.satelliteName}</span>
</li>
<li>
<span className="label">위성영상파일</span>
<span className="value">{item.tifFileName}</span>
</li>
<li>
<span className="label">영상 종류</span>
<span className="value">{item.satelliteVideoKind}</span>
</li>
<li>
<span className="label">영상 출처</span>
<span className="value">{item.satelliteVideoOrigin}</span>
</li>
</ul>
<div className="btnArea">
<button
type="button"
className="btnEdit"
onClick={() => setDetailPopupId(item.satelliteId)}
></button>
<button type="button" className="btnDel"></button>
<button
type="button"
className={`btnMap ${activeImageId === item.satelliteManageId ? 'is-active' : ''}`}
onClick={() => handleShowOnMap(item)}
></button>
</div>
</li>
</ul>
))}
</div>
)}
{!isLoading && 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 className="btnBox rowSB">
{/*<button type="button" className="btn btnLine">위성영상 폴더 업로드</button>*/}
<button type="button" className="btn btnLine" onClick={() => setIsRegisterOpen(true)}>위성영상 등록</button>
</div>
</div>
</div>
{detailPopupId && (
<SatelliteRegisterPopup
satelliteId={detailPopupId}
onClose={() => setDetailPopupId(null)}
onSaved={() => search(page)}
/>
)}
{isRegisterOpen && (
<SatelliteRegisterPopup
onClose={() => setIsRegisterOpen(false)}
onSaved={() => search(page)}
/>
)}
</div>
);
}

파일 보기

@ -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 (
<div className="tabWrap is-active">
<div className="tabTop">
<div className="title">위성 관리</div>
<div className="formGroup">
<ul>
<li>
<label>
<span>사업자명</span>
<select
value={companyNo}
onChange={(e) => setCompanyNo(e.target.value)}
>
<option value="">전체</option>
{companyOptions.map((opt) => (
<option key={opt.companyNo} value={opt.companyNo}>
{opt.companyName}
</option>
))}
</select>
</label>
<label>
<span>센서 타입</span>
<select
value={sensorType}
onChange={(e) => setSensorType(e.target.value)}
>
<option value="">전체</option>
{sensorTypeOptions.map((opt) => (
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
{opt.commonCodeTypeName}
</option>
))}
</select>
</label>
</li>
<li>
<label>
<span>위성명</span>
<input
type="text"
placeholder="위성명"
value={satelliteName}
onChange={(e) => setSatelliteName(e.target.value)}
/>
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm noSc">
<div className="tabBtmInner">
{/* 스크롤영역 */}
<div className="tabBtmCnt">
{isLoading && <div className="loading">조회 ...</div>}
{error && <div className="error">{error}</div>}
{!isLoading && !error && list.length === 0 && (
<div className="empty">검색 결과가 없습니다.</div>
)}
{!isLoading && list.length > 0 && (
<div className="detailWrap">
{list.map((item) => (
<ul key={item.satelliteManageId} className="detailBox" style={{ cursor: 'pointer' }} onClick={() => setDetailManageId(item.satelliteManageId)}>
<li>
<span className="label">사업자명</span>
<span className="value">{item.companyName}</span>
</li>
<li>
<span className="label">위성명</span>
<span className="value">{item.satelliteName}</span>
</li>
<li>
<span className="label">센서 타입</span>
<span className="value">{item.sensorType}</span>
</li>
<li>
<span className="label">촬영 해상도</span>
<span className="value">{item.photoResolution}</span>
</li>
</ul>
))}
</div>
)}
{!isLoading && 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 className="btnBox">
<button type="button" className="btn btnLine" onClick={() => setIsRegisterOpen(true)}>등록</button>
</div>
</div>
</div>
{isRegisterOpen && (
<SatelliteManageRegisterPopup
onClose={() => setIsRegisterOpen(false)}
onSaved={() => search(page)}
/>
)}
{detailManageId && (
<SatelliteManageRegisterPopup
satelliteManageId={detailManageId}
onClose={() => setDetailManageId(null)}
onSaved={() => search(page)}
/>
)}
</div>
);
}

파일 보기

@ -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 (
<div className="popupUtillWrap" style={{ transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))` }}>
<div className="popupUtill">
<div className="puHeader" onMouseDown={handleMouseDown} style={{ cursor: 'grab' }}>
<span className="title">
{isEditMode ? '위성 관리 상세' : '위성 관리 등록'}
</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={onClose}
/>
</div>
<div className="puBody">
{isLoading && <div className="loading">조회 ...</div>}
{error && <div className="error">{error}</div>}
{!isLoading && (
<table className="table">
<caption>위성 관리 등록 - 사업자명, 위성명, 센서 타입, 촬영 해상도, 주파수, 상세내역에 대한 내용을 등록하는 표입니다.</caption>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">사업자명 <span className="required">*</span></th>
<td>
<input
type="text"
placeholder="사업자명"
aria-label="사업자명"
value={companyNo}
onChange={(e) => setCompanyNo(e.target.value)}
/>
</td>
</tr>
<tr>
<th scope="row">위성명 <span className="required">*</span></th>
<td>
<input
type="text"
placeholder="위성명"
aria-label="위성명"
value={satelliteName}
onChange={(e) => setSatelliteName(e.target.value)}
/>
</td>
</tr>
<tr>
<th scope="row">센서 타입</th>
<td>
<select
aria-label="센서 타입"
value={sensorType}
onChange={(e) => setSensorType(e.target.value)}
>
<option value="">선택</option>
{sensorTypeOptions.map((opt) => (
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
{opt.commonCodeTypeName}
</option>
))}
</select>
</td>
</tr>
<tr>
<th scope="row">촬영 해상도</th>
<td>
<input
type="text"
placeholder="촬영 해상도"
aria-label="촬영 해상도"
value={photoResolution}
onChange={(e) => setPhotoResolution(e.target.value)}
/>
</td>
</tr>
<tr>
<th scope="row">주파수</th>
<td>
<input
type="text"
placeholder="주파수"
aria-label="주파수"
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
/>
</td>
</tr>
<tr>
<th scope="row">상세내역</th>
<td>
<textarea
placeholder="내용을 입력하세요"
aria-label="상세내역"
value={photoDetail}
onChange={(e) => setPhotoDetail(e.target.value)}
/>
</td>
</tr>
</tbody>
</table>
)}
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button
type="button"
className="btn basic"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? '저장 중...' : isEditMode ? '수정' : '저장'}
</button>
<button
type="button"
className="btn dark"
onClick={onClose}
>
취소
</button>
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,182 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchCommonCodeList } from '@/api/commonApi';
import { searchSatelliteCompany } from '@/api/satelliteApi';
import SatelliteProviderRegisterPopup from './SatelliteProviderRegisterPopup';
export default function SatelliteProviderManage() {
//
const [companyTypeOptions, setCompanyTypeOptions] = useState([]);
//
const [companyTypeCode, setCompanyTypeCode] = useState('');
const [companyName, setCompanyName] = 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 [detailCompanyNo, setDetailCompanyNo] = useState(null);
//
useEffect(() => {
fetchCommonCodeList('000039')
.then(setCompanyTypeOptions)
.catch(() => setCompanyTypeOptions([]));
}, []);
const search = useCallback(async (targetPage) => {
setIsLoading(true);
setError(null);
try {
const limit = 10;
const page = targetPage != null ? targetPage : 1;
const result = await searchSatelliteCompany({
companyTypeCode,
companyName,
page,
limit,
});
setList(result.list);
setTotalPage(result.totalPage);
setPage(targetPage);
} catch {
setError('위성 사업자 조회 중 오류가 발생했습니다.');
setList([]);
setTotalPage(0);
} finally {
setIsLoading(false);
}
}, [companyTypeCode, companyName]);
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>
<select
value={companyTypeCode}
onChange={(e) => setCompanyTypeCode(e.target.value)}
>
<option value="">전체</option>
{companyTypeOptions.map((opt) => (
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
{opt.commonCodeTypeName}
</option>
))}
</select>
</label>
<label>
<span>사업자명</span>
<input
type="text"
placeholder="사업자명"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
/>
</label>
</li>
<li className="fgBtn">
<button type="button" className="schBtn" onClick={handleSearch}>검색</button>
</li>
</ul>
</div>
</div>
<div className="tabBtm noSc">
<div className="tabBtmInner">
{/* 스크롤영역 */}
<div className="tabBtmCnt">
{isLoading && <div className="loading">조회 ...</div>}
{error && <div className="error">{error}</div>}
{!isLoading && !error && list.length === 0 && (
<div className="empty">검색 결과가 없습니다.</div>
)}
{!isLoading && list.length > 0 && (
<div className="detailWrap">
{list.map((item) => (
<ul key={item.companyNo} className="detailBox" style={{ cursor: 'pointer' }} onClick={() => setDetailCompanyNo(item.companyNo)}>
<li className="dbHeader">
<div className="headerL item1">
<span className="name">{item.companyName}</span>
</div>
</li>
<li>
<span className="label">사업자 분류</span>
<span className="value">{item.companyTypeName}</span>
</li>
<li>
<span className="label">국가</span>
<span className="value">{item.nationalCodeName}</span>
</li>
<li>
<span className="label">소재지</span>
<span className="value">{item.location}</span>
</li>
</ul>
))}
</div>
)}
{!isLoading && 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 className="btnBox">
<button type="button" className="btn btnLine" onClick={() => setIsRegisterOpen(true)}>등록</button>
</div>
</div>
</div>
{isRegisterOpen && (
<SatelliteProviderRegisterPopup
onClose={() => setIsRegisterOpen(false)}
onSaved={() => search(page)}
/>
)}
{detailCompanyNo && (
<SatelliteProviderRegisterPopup
companyNo={detailCompanyNo}
onClose={() => setDetailCompanyNo(null)}
onSaved={() => search(page)}
/>
)}
</div>
);
}

파일 보기

@ -0,0 +1,241 @@
import { useState, useEffect } from 'react';
import { fetchCommonCodeList } from '@/api/commonApi';
import {
saveSatelliteCompany,
fetchSatelliteCompanyDetail,
updateSatelliteCompany,
} from '@/api/satelliteApi';
import useDraggable from '../hooks/useDraggable';
const NATIONAL_OPTIONS = [
{ code: '440', name: '대한민국' },
{ code: '338', name: '미국' },
{ code: '413', name: '중국' },
{ code: '431', name: '일본' },
];
/**
* 위성 사업자 등록/수정 팝업
* @param {{ companyNo?: number, onClose: () => void, onSaved: () => void }} props
* companyNo가 있으면 수정 모드, 없으면 등록 모드
*/
export default function SatelliteProviderRegisterPopup({ companyNo, onClose, onSaved }) {
const isEditMode = !!companyNo;
const [companyTypeOptions, setCompanyTypeOptions] = useState([]);
const [companyTypeCode, setCompanyTypeCode] = useState('');
const [companyName, setCompanyName] = useState('');
const [nationalCode, setNationalCode] = useState('');
const [location, setLocation] = useState('');
const [companyDetail, setCompanyDetail] = 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('000039');
if (cancelled) return;
setCompanyTypeOptions(codeList);
// :
if (companyNo) {
const data = await fetchSatelliteCompanyDetail(companyNo);
if (cancelled || !data) return;
// companyTypeName companyTypeCode
const matchedType = codeList.find(
(opt) => opt.commonCodeTypeName === data.companyTypeName
);
setCompanyTypeCode(matchedType?.commonCodeTypeNumber || '');
// nationalCodeName nationalCode
const matchedNation = NATIONAL_OPTIONS.find(
(opt) => opt.name === data.nationalCodeName
);
setNationalCode(matchedNation?.code || '');
setCompanyName(data.companyName || '');
setLocation(data.location || '');
setCompanyDetail(data.companyDetail || '');
}
} catch {
setError('데이터 조회 중 오류가 발생했습니다.');
} finally {
if (!cancelled) setIsLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [companyNo]);
const handleSave = async () => {
setIsSaving(true);
setError(null);
try {
if (isEditMode) {
await updateSatelliteCompany({
companyNo,
companyTypeCode,
companyName,
nationalCode,
location,
companyDetail,
});
} else {
await saveSatelliteCompany({
companyTypeCode,
companyName,
nationalCode,
location,
companyDetail,
});
}
onSaved?.();
onClose();
} catch {
setError('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
return (
<div className="popupUtillWrap" style={{ transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))` }}>
<div className="popupUtill">
<div className="puHeader" onMouseDown={handleMouseDown} style={{ cursor: 'grab' }}>
<span className="title">
{isEditMode ? '위성 사업자 상세' : '위성 사업자 등록'}
</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={onClose}
/>
</div>
<div className="puBody">
{isLoading && <div className="loading">조회 ...</div>}
{error && <div className="error">{error}</div>}
{!isLoading && (
<table className="table">
<caption>
위성 사업자 {isEditMode ? '상세' : '등록'} - 사업자 분류, 사업자명, 국가, 소재지, 상세내역에 대한 내용을 {isEditMode ? '조회/수정' : '등록'}하는 표입니다.
</caption>
<colgroup>
<col style={{ width: '30%' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">사업자 분류 <span className="required">*</span></th>
<td>
<select
aria-label="사업자 분류"
value={companyTypeCode}
onChange={(e) => setCompanyTypeCode(e.target.value)}
>
<option value="">선택</option>
{companyTypeOptions.map((opt) => (
<option key={opt.commonCodeTypeNumber} value={opt.commonCodeTypeNumber}>
{opt.commonCodeTypeName}
</option>
))}
</select>
</td>
</tr>
<tr>
<th scope="row">사업자명 <span className="required">*</span></th>
<td>
<input
type="text"
placeholder="사업자명"
aria-label="사업자명"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
/>
</td>
</tr>
<tr>
<th scope="row">국가 <span className="required">*</span></th>
<td>
<select
aria-label="국가"
value={nationalCode}
onChange={(e) => setNationalCode(e.target.value)}
>
<option value="">선택</option>
{NATIONAL_OPTIONS.map((opt) => (
<option key={opt.code} value={opt.code}>
{opt.name}
</option>
))}
</select>
</td>
</tr>
<tr>
<th scope="row">소재지</th>
<td>
<input
type="text"
placeholder="소재지"
aria-label="소재지"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</td>
</tr>
<tr>
<th scope="row">상세내역</th>
<td>
<textarea
placeholder="내용을 입력하세요"
aria-label="상세내역"
value={companyDetail}
onChange={(e) => setCompanyDetail(e.target.value)}
/>
</td>
</tr>
</tbody>
</table>
)}
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button
type="button"
className="btn basic"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? '저장 중...' : isEditMode ? '수정' : '저장'}
</button>
<button
type="button"
className="btn dark"
onClick={onClose}
>
취소
</button>
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,483 @@
import { useState, useEffect } from 'react';
import {
fetchSatelliteCompanyList,
fetchSatelliteManageList,
saveSatelliteVideo,
fetchSatelliteVideoDetail,
updateSatelliteVideo,
} from '@/api/satelliteApi';
import useDraggable from '../hooks/useDraggable';
const truncateMiddle = (str, maxLen) => {
if (!str) return '';
if (str.length <= maxLen) return str;
const keep = Math.floor((maxLen - 3) / 2);
return str.slice(0, keep) + '...' + str.slice(str.length - keep);
};
/**
* 위성영상 등록/상세 팝업
* @param {{ satelliteId?: number, onClose: () => void, onSaved: () => void }} props
* satelliteId가 있으면 수정 모드, 없으면 등록 모드
*/
export default function SatelliteRegisterPopup({ satelliteId, onClose, onSaved }) {
const isEditMode = !!satelliteId;
// / ( )
const [companyList, setCompanyList] = useState([]);
const [satelliteList, setSatelliteList] = useState([]);
// state
const [companyNo, setCompanyNo] = useState('');
const [satelliteManageId, setSatelliteManageId] = useState('');
const [photographDate, setPhotographDate] = useState('');
const [satelliteVideoName, setSatelliteVideoName] = useState('');
const [satelliteVideoTransmissionCycle, setSatelliteVideoTransmissionCycle] = useState('');
const [satelliteVideoKind, setSatelliteVideoKind] = useState('');
const [satelliteVideoOrbit, setSatelliteVideoOrbit] = useState('');
const [satelliteVideoOrigin, setSatelliteVideoOrigin] = useState('');
const [photographPurpose, setPhotographPurpose] = useState('');
const [photographMode, setPhotographMode] = useState('');
const [purchaseCode, setPurchaseCode] = useState('');
const [purchasePrice, setPurchasePrice] = useState(0);
// state ( )
const [tifFile, setTifFile] = useState(null);
const [csvFile, setCsvFile] = useState(null);
const [cloudMaskFile, setCloudMaskFile] = useState(null);
//
const [satelliteName, setSatelliteName] = useState('');
const [tifFileName, setTifFileName] = useState('');
const [csvFileName, setCsvFileName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
const { position, handleMouseDown } = useDraggable();
//
useEffect(() => {
let cancelled = false;
if (isEditMode) {
setIsLoading(true);
setError(null);
fetchSatelliteVideoDetail(satelliteId)
.then((data) => {
if (cancelled || !data) return;
let dateValue = '';
if (data.photographDate) {
dateValue = typeof data.photographDate === 'string'
? data.photographDate.substring(0, 10)
: '';
}
setSatelliteManageId(data.satelliteManageId ?? '');
setSatelliteName(data.satelliteName || '');
setPhotographDate(dateValue);
setTifFileName(data.tifFileName || '');
setCsvFileName(data.csvFileName || '');
setSatelliteVideoName(data.satelliteVideoName || '');
setSatelliteVideoTransmissionCycle(data.satelliteVideoTransmissionCycle || '');
setSatelliteVideoKind(data.satelliteVideoKind || '');
setSatelliteVideoOrbit(data.satelliteVideoOrbit || '');
setSatelliteVideoOrigin(data.satelliteVideoOrigin || '');
setPhotographPurpose(data.photographPurpose || '');
setPhotographMode(data.photographMode || '');
setPurchaseCode(data.purchaseCode || '');
setPurchasePrice(data.purchasePrice || 0);
})
.catch(() => {
if (!cancelled) setError('상세정보 조회 중 오류가 발생했습니다.');
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
} else {
fetchSatelliteCompanyList()
.then(setCompanyList)
.catch(() => setCompanyList([]));
}
return () => { cancelled = true; };
}, [satelliteId]);
// ( )
useEffect(() => {
if (isEditMode) return;
if (!companyNo) {
setSatelliteList([]);
setSatelliteManageId('');
return;
}
fetchSatelliteManageList(Number(companyNo))
.then(setSatelliteList)
.catch(() => setSatelliteList([]));
setSatelliteManageId('');
}, [companyNo, isEditMode]);
const handleSave = async () => {
setIsSaving(true);
setError(null);
try {
if (isEditMode) {
await updateSatelliteVideo({
satelliteId,
satelliteManageId,
photographDate,
satelliteVideoName,
satelliteVideoTransmissionCycle,
satelliteVideoKind,
satelliteVideoOrbit,
satelliteVideoOrigin,
photographPurpose,
photographMode,
purchaseCode,
purchasePrice,
});
} else {
const fd = new FormData();
if (tifFile) fd.append('tifFile', tifFile);
if (csvFile) fd.append('csvFile', csvFile);
if (cloudMaskFile) fd.append('cloudMaskFile', cloudMaskFile);
fd.append('satelliteManageId', satelliteManageId);
fd.append('photographDate', photographDate);
fd.append('satelliteVideoName', satelliteVideoName);
fd.append('satelliteVideoTransmissionCycle', satelliteVideoTransmissionCycle);
fd.append('satelliteVideoKind', satelliteVideoKind);
fd.append('satelliteVideoOrbit', satelliteVideoOrbit);
fd.append('satelliteVideoOrigin', satelliteVideoOrigin);
fd.append('photographPurpose', photographPurpose);
fd.append('photographMode', photographMode);
fd.append('purchaseCode', purchaseCode);
fd.append('purchasePrice', purchasePrice);
await saveSatelliteVideo(fd);
}
onSaved?.();
onClose();
} catch {
setError('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// const handlePhotoTimeFormatChange = (value) => {
// const changedFormat = value.replace('T', '') + ':00';
// setPhotographDate(changedFormat);
// }
return (
<div className="popupUtillWrap" style={{ transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))` }}>
<div className="popupUtill w61r">
<div className="puHeader" onMouseDown={handleMouseDown} style={{ cursor: 'grab' }}>
<span className="title">{isEditMode ? '위성 영상 상세' : '위성 영상 등록'}</span>
<button
type="button"
className="puClose"
aria-label="닫기"
onClick={onClose}
/>
</div>
<div className="puBody">
{isLoading && <div className="loading">조회 ...</div>}
{error && <div className="error">{error}</div>}
{!isLoading && (
<table className="table">
<caption>
위성 영상 {isEditMode ? '상세' : '등록'} - 사업자명/위성명, 영상 촬영일, 위성영상파일, CSV 파일, 위성영상명, 영상전송 주기, 영상 종류, 위성 궤도, 영상 출처, 촬영 목적, 촬영 모드, 취득방법, 구매가격에 대한 내용을 {isEditMode ? '조회/수정' : '등록'}하는 표입니다.
</caption>
<colgroup>
<col style={{ width: '125px' }} />
<col style={{ width: '' }} />
<col style={{ width: '125px' }} />
<col style={{ width: '' }} />
</colgroup>
<tbody>
<tr>
<th scope="row">사업자명/위성명 {!isEditMode && <span className="required">*</span>}</th>
<td colSpan={3}>
{isEditMode ? (
<span>{satelliteName}</span>
) : (
<div className="row flex1">
<select
aria-label="사업자명"
value={companyNo}
onChange={(e) => setCompanyNo(e.target.value)}
>
<option value="">선택</option>
{companyList.map((c) => (
<option key={c.companyNo} value={c.companyNo}>
{c.companyName}
</option>
))}
</select>
<select
aria-label="위성명"
value={satelliteManageId}
onChange={(e) => setSatelliteManageId(e.target.value)}
>
<option value="">선택</option>
{satelliteList.map((s) => (
<option key={s.satelliteManageId} value={s.satelliteManageId}>
{s.satelliteName}
</option>
))}
</select>
</div>
)}
</td>
</tr>
<tr>
<th scope="row">영상 촬영일 <span className="required">*</span></th>
<td colSpan={3}>
<input
className="dateInput"
type="datetime-local"
aria-label="영상 촬영일"
value={photographDate}
onChange={(e) => setPhotographDate(e.target.value.replace('T', ' '))}
step="1"
/>
</td>
</tr>
<tr>
<th scope="row">위성영상파일 {!isEditMode && <span className="required">*</span>}</th>
<td colSpan={3}>
{isEditMode ? (
<span>{tifFileName}</span>
) : (
<div className="rowC">
<div className="fileWrap">
<input
type="file"
id="registerTifFile"
className="fileInput"
accept=".tif,.tiff,.geotiff"
onChange={(e) => setTifFile(e.target.files[0] || null)}
/>
<label htmlFor="registerTifFile" className="fileLabel">
파일 선택
</label>
<span className="fileName">
{tifFile ? truncateMiddle(tifFile.name, 40) : '선택된 파일 없음'}
</span>
</div>
</div>
)}
</td>
</tr>
<tr>
<th scope="row">CSV 파일</th>
<td colSpan={3}>
{isEditMode ? (
<span>{csvFileName}</span>
) : (
<div className="rowC">
<div className="fileWrap">
<input
type="file"
id="registerCsvFile"
className="fileInput"
accept=".csv"
onChange={(e) => setCsvFile(e.target.files[0] || null)}
/>
<label htmlFor="registerCsvFile" className="fileLabel">
파일 선택
</label>
<span className="fileName">
{csvFile ? truncateMiddle(csvFile.name, 45) : '선택된 파일 없음'}
</span>
</div>
</div>
)}
</td>
</tr>
{!isEditMode && (
<tr>
<th scope="row">Cloud Mask 파일</th>
<td colSpan={3}>
<div className="rowC">
<div className="fileWrap">
<input
type="file"
id="registerCloudMaskFile"
className="fileInput"
onChange={(e) => setCloudMaskFile(e.target.files[0] || null)}
/>
<label htmlFor="registerCloudMaskFile" className="fileLabel">
파일 선택
</label>
<span className="fileName">
{cloudMaskFile ? truncateMiddle(cloudMaskFile.name, 45) : '선택된 파일 없음'}
</span>
</div>
</div>
</td>
</tr>
)}
<tr>
<th scope="row">위성영상명 <span className="required">*</span></th>
<td colSpan={3}>
<input
type="text"
aria-label="위성영상명"
value={satelliteVideoName}
onChange={(e) => setSatelliteVideoName(e.target.value)}
/>
</td>
</tr>
<tr>
<th scope="row">영상전송 주기</th>
<td colSpan={3}>
<select
aria-label="영상전송 주기"
value={satelliteVideoTransmissionCycle}
onChange={(e) => setSatelliteVideoTransmissionCycle(e.target.value)}
>
<option value="">선택</option>
<option value="0">0</option>
<option value="10">10</option>
<option value="30">30</option>
<option value="60">60</option>
</select>
</td>
</tr>
<tr>
<th scope="row">영상 종류</th>
<td colSpan={3}>
<div className="row">
{['VIRS', 'ICEYE_SAR', '광학', '예약', 'RF'].map((kind) => (
<label key={kind} className="radio radioL">
<input
type="radio"
name="satelliteVideoKind"
checked={satelliteVideoKind === kind}
onChange={() => setSatelliteVideoKind(kind)}
/>
<span>{kind}</span>
</label>
))}
</div>
</td>
</tr>
<tr>
<th scope="row">위성 궤도</th>
<td>
<select
aria-label="위성 궤도"
value={satelliteVideoOrbit}
onChange={(e) => setSatelliteVideoOrbit(e.target.value)}
>
<option value="">선택</option>
<option value="저궤도">저궤도</option>
<option value="중궤도">중궤도</option>
<option value="정지궤도">정지궤도</option>
<option value="기타">기타</option>
</select>
</td>
<th scope="row">영상 출처</th>
<td>
<select
aria-label="영상 출처"
value={satelliteVideoOrigin}
onChange={(e) => setSatelliteVideoOrigin(e.target.value)}
>
<option value="">선택</option>
<option value="국내/자동">국내/자동</option>
<option value="국내/수동">국내/수동</option>
<option value="국외/수동">국외/수동</option>
<option value="기타">기타</option>
</select>
</td>
</tr>
<tr>
<th scope="row">촬영 목적</th>
<td>
<input
type="text"
placeholder="촬영 목적"
aria-label="촬영 목적"
value={photographPurpose}
onChange={(e) => setPhotographPurpose(e.target.value)}
/>
</td>
<th scope="row">촬영 모드</th>
<td>
<select
aria-label="촬영 모드"
value={photographMode}
onChange={(e) => setPhotographMode(e.target.value)}
>
<option value="">선택</option>
<option value="스핏모드">스핏모드</option>
<option value="스트랩모드">스트랩모드</option>
<option value="기타">기타</option>
</select>
</td>
</tr>
<tr>
<th scope="row">취득방법</th>
<td>
<select
aria-label="취득방법"
value={purchaseCode}
onChange={(e) => setPurchaseCode(e.target.value)}
>
<option value="">선택</option>
<option value="무료">무료</option>
<option value="개별구매">개별구매</option>
<option value="단가계약">단가계약</option>
<option value="연간계약">연간계약</option>
<option value="기타">기타</option>
</select>
</td>
<th scope="row">구매가격</th>
<td>
<div className="numInput">
<input
type="number"
placeholder="0"
aria-label="구매가격"
value={purchasePrice}
onChange={(e) => setPurchasePrice(Number(e.target.value))}
/>
</div>
</td>
</tr>
</tbody>
</table>
)}
</div>
<div className="puFooter">
<div className="popBtnWrap">
<button
type="button"
className="btn basic"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? '저장 중...' : isEditMode ? '수정' : '저장'}
</button>
<button
type="button"
className="btn dark"
onClick={onClose}
>
취소
</button>
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,34 @@
import { useState } from "react";
function Slider({ label = "", min = 0, max = 100, defaultValue = 50, value: controlledValue, onChange }) {
const [internalValue, setInternalValue] = useState(defaultValue);
const value = controlledValue !== undefined ? controlledValue : internalValue;
const percent = ((value - min) / (max - min)) * 100;
const handleChange = (e) => {
const v = Number(e.target.value);
if (onChange) {
onChange(v);
} else {
setInternalValue(v);
}
};
return (
<label className="rangeWrap">
<span className="rangeLabel">{label}</span>
<input
type="range"
min={min}
max={max}
value={value}
onChange={handleChange}
style={{ "--percent": `${percent}%` }}
aria-label={label}
/>
</label>
);
}
export default Slider;

파일 보기

@ -0,0 +1,36 @@
import { useState, useRef, useCallback, useEffect } from 'react';
export default function useDraggable() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const dragging = useRef(false);
const dragStart = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback((e) => {
dragging.current = true;
dragStart.current = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
e.preventDefault();
}, [position]);
useEffect(() => {
const handleMouseMove = (e) => {
if (!dragging.current) return;
setPosition({
x: e.clientX - dragStart.current.x,
y: e.clientY - dragStart.current.y,
});
};
const handleMouseUp = () => { dragging.current = false; };
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
return { position, handleMouseDown };
}

파일 보기

@ -0,0 +1,13 @@
import { create } from 'zustand';
/**
* 위성영상 표출 상태 관리
* - opacity: OL TileLayer 투명도 (0~1)
* - brightness: CSS filter brightness (0~200%)
*/
export const useSatelliteStore = create((set) => ({
opacity: 1,
brightness: 100,
setOpacity: (opacity) => set({ opacity }),
setBrightness: (brightness) => set({ brightness }),
}));

파일 보기

@ -151,7 +151,7 @@ export default function TidalInfo() {
if (!coord) return; if (!coord) return;
const feature = new Feature({ const feature = new Feature({
geometry: new Point(fromLonLat([coord.longitude, coord.latitude])), geometry: new Point(fromLonLat([coord[0], coord[1]])),
name: station.observatoryName, name: station.observatoryName,
featureType: 'observatory', featureType: 'observatory',
stationData: station, stationData: station,