Files
frontend/packages/common/business_components/src/ui/map/Gis.js
T

414 lines
14 KiB
JavaScript

import { Search } from "lucide-react";
import OlMap from "ol/Map";
import View from "ol/View";
import { defaults as defaultControls, OverviewMap } from "ol/control";
import { defaults } from "ol/interaction";
import * as olProj from "ol/proj";
import { register } from "ol/proj/proj4";
import proj4 from "proj4";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import GisMapToggle from "./GisMapToggle";
import GisSelect from "./GisSelect";
import GisToolBar from "./GisToolBar";
import GisTree from "./GisTree";
import GisZoomLevel from "./GisZoomLevel";
import "./lib/css/gis.common.css";
import GisApp from "./lib/gis.core";
import LoadingOverlay from "@common/business_components/ui/loadingBar";
export const GisContext = createContext({
gisCore: null,
gisMap: null,
treeData: null,
checkedLayerIds: null,
});
export const useGisContext = () => useContext(GisContext);
/**
* @param {any} props
*/
function Gis({
isOpen, //우측 상세 레이어 팝업
gisMaps,
isSearchModalOpen,
treeData = [],
isSearch = false,
isMiniMap = false,
setIsOpen,
setFeatureData,
featureData,
imgVisble = false,
searchView: SearchView,
detailView: DetailView,
onFeatureSelect,
onMapBlankClick,
canSelectFeature,
sinkholeLegendIconVisible = false,
setIsSearchModalOpen,
onCheckedLayersChange,
// 외부 업무 흐름에서 특정 시설물 레이어를 기존 체크 상태에 추가하도록 요청한다.
checkedLayerRequest = null,
searchBar: SearchBar,
// 화면별 상단 정렬과 인접 패널 회피를 위한 검색바 위치 전용 클래스다.
searchBarContainerClassName = "",
// sn 관망도처럼 베이스맵 선택지를 항상 노출하는 화면에서만 탭 형태를 사용한다.
alwaysVisibleMapTabs = false,
waterDepth: WaterDepth = null,
layerSettingView: LayerSettingView = null,
inlineLayerSettingView,
treeSectionClassName = "",
layerIconVisible,
layerLegendIconVisible,
// 목록·액션 아이콘이 지도 레이어 체크를 선행 조건으로 요구하는지 여부다.
layerIconRequiresChecked = true,
// 목록 액션의 기능은 유지하면서 텍스트를 목록 아이콘으로 표시할지 여부다.
layerActionIconOnly = false,
isPlaying = false,
setIsPlaying = () => { },
targetLayerTypes = [],
checkInterval = 3000,
// 체크 변경 콜백 지연 시간. 기본값은 기존 동작 유지, sn 유하방향처럼 즉시 반응이 필요한 화면은 0으로 전달한다.
checkedLayerChangeDelay = 1000,
height: height = "h-[calc(100vh-5rem)]",
onOpenPopup = (data) => { },
droughtDates = { startYmd: '', endYmd: '' },
defaultZoom = 15,
defaultLonLat = [127.4875, 37.4916],
viewExtent,
viewMinZoom,
showZoomLevel = false,
toolBarIconSize,
zoomGuide,
showFloatingSearchButton = true,
treeTogglePosition = "center",
detachedTreeToggleButton = false,
showLayerPanelTitle = true,
// sn 관망도처럼 트리와 선택 설정을 카드 두 개로 구분해야 하는 화면에서만 활성화한다.
sectionedTreePanel = false,
// 전체 체크 조작은 필요한 화면에서만 명시적으로 활성화한다.
showBulkLayerToggle = false,
showZoomScaleInfo = false,
}) {
const mapRef = useRef();
const [mapObject, setMapObject] = useState({});
const [tabVisible, setTabVisible] = useState(true);
const [layerNm, setLayerNm] = useState("");
const [totalSteps, setTotalSteps] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
// const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
// const [featureData, setFeatureData] = useState(null) // 선택된 피쳐 데이터
// const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태
const gisCore = useMemo(
() => new GisApp.core({ targetId: mapRef.current }),
[],
);
const mapInstanceRef = useRef(null);
const checkedLayerIds = useRef([]);
const layerNmRef = useRef("");
// 팝업 데이터를 저장할 상태와 오버레이 참조를 저장할 ref 추가
// const [facilityInfoPopupData, setFacilityInfoPopupData] = useState([]);
// const facilityOverlaysRef = useRef([]);
// 체크된 레이어 상태 관리
const [checkedLayers, setCheckedLayers] = useState([]);
const [checkedLayersData, setCheckedLayersData] = useState([]);
const [isLoading, setIsLoading] = useState(true) //로딩 상태
let gisMap = null;
const closePopup = () => {
setIsOpen(false); // 팝업 닫기
setFeatureData(null);
};
// 피처 클릭시
const handleFeatureSelect = useCallback(
(data) => {
onFeatureSelect?.(data); // 선택된 피쳐 데이터 콜백 호출
},
[onFeatureSelect],
);
// 지도 생성 및 초기화
useEffect(() => {
if (!mapRef.current) return;
// EPSG:5179 등록
proj4.defs([
[
"EPSG:5179",
"+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs",
],
]);
register(proj4);
let map = mapInstanceRef.current;
// OpenLayers 지도 객체 생성
if (!map) {
map = new OlMap({
layers: [gisCore.baseLayers["base"], gisCore.baseLayers["satellite"]],
renderer: "webgl",
target: mapRef.current,
interactions: defaults({ doubleClickZoom: false }),
controls: defaultControls({
zoom: false,
rotate: false,
attributionOptions: {
collapsible: false,
},
}).extend([]),
view: new View({
projection: GisApp.TO_PROJ,
zoom: defaultZoom ?? gisCore.defaults.zoom,
center: olProj.transform(
defaultLonLat,
GisApp.FROM_PROJ,
GisApp.TO_PROJ,
),
minZoom: viewMinZoom ?? gisCore.defaults.minZoom,
maxZoom: gisCore.defaults.maxZoom,
extent: viewExtent,
}),
});
} else {
// 재마운트 시 target 없으면 다시 지정
if (!map.getTarget()) {
map.setTarget(mapRef.current);
}
}
map.on('loadstart', function () {
setIsLoading(false)
});
const select = GisApp.Module.select;
if (select) {
if (select.selectInteraction) {
map.removeInteraction(select.selectInteraction);
}
select.map = map; // map 바인딩
select.setInteraction();
select.pointerMoveEventHandler();
}
// 미니맵 추가
if (isMiniMap) {
const overviewMap = new OverviewMap({
layers: [GisApp.Module.core.baseMiniMapLayers["base"]],
view: new View({
projection: GisApp.TO_PROJ,
minZoom: 9,
}),
collapsed: false,
collapseLabel: "\u00BB",
label: "\u00AB",
});
map.addControl(overviewMap);
}
gisMaps.current = map;
// 스케일 컨트롤 및 베이스 레이어 설정
gisCore.__proto__.map = map;
gisCore.scaleControl();
gisCore.changeBaseLayer("base");
setMapObject({ map: map });
mapInstanceRef.current = map; // 생성 완료 플래그 설정
// 📌 언마운트 시 완전 제거
return () => {
map.setTarget(null); // DOM 연결 해제
mapInstanceRef.current = null; // 참조 초기화
};
}, []);
// treeData 변경 시 레이어 코드 생성
useEffect(() => {
if (!treeData || treeData.length === 0) return;
// 기존 오버레이 및 레이어 모두 삭제
gisCore.deleteAllOverlay();
gisCore.deleteAllLayer();
// 트리 데이터를 레이어 코드 객체로 변환
const layerCode = gisCore.convertLayerInfosToLayerCode(treeData, "lyrSn");
GisApp.layerCode = layerCode; // 전역 레이어 코드 설정
}, [treeData]);
// droughtDates를 전역(GisApp)에 반영
useEffect(() => {
if (typeof window === "undefined") return;
if (droughtDates?.startYmd && droughtDates?.endYmd) {
GisApp.DROUGHT_DATES = {
startYmd: String(droughtDates.startYmd),
endYmd: String(droughtDates.endYmd),
};
}
}, [droughtDates?.startYmd, droughtDates?.endYmd]);
const getLayerItems = (layerCode) => {
const layerItems = Object.entries(layerCode)
.filter(([key, obj], idx) => obj?.updtPsblYn === "Y")
.map(([key, obj]) => ({
...obj,
name: obj.lyrNm,
label: obj.lyrNm,
value: obj.lyrSn + "",
}))
.sort((a, b) => {
if (a.sortOrdr > b.sortOrdr) return 1;
if (a.sortOrdr < b.sortOrdr) return -1;
return 0;
});
return layerItems;
};
if (mapObject.map) {
try {
gisMap = mapObject.map;
} catch (e) {
console.error("오류 발생:", e); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력
}
}
// 체크된 레이어 정보를 처리하는 함수
const handleCheckedLayersChange = useCallback(
(checkedIds, checkedNodesData) => {
// 콜백 지연 옵션을 한 곳에서 처리해 기존 화면과 즉시 반응 화면의 동작 차이를 prop으로 제어한다.
const applyCheckedLayersChange = () => {
setCheckedLayers(checkedIds);
setCheckedLayersData(checkedNodesData);
onCheckedLayersChange?.(checkedIds, checkedNodesData);
};
const delayMs = Math.max(0, Number(checkedLayerChangeDelay) || 0);
setTimeout(applyCheckedLayersChange, delayMs);
},
[checkedLayerChangeDelay, onCheckedLayersChange],
);
// 진행률 변경 핸들러
const handleProgressChange = useCallback((total, current) => {
setTotalSteps(total);
setCurrentStep(current);
}, []);
return (
<>
<LoadingOverlay isLoading={isLoading ?? false} />
<GisContext.Provider
value={{
gisCore,
gisMap: mapInstanceRef.current,
treeData,
checkedLayerIds,
}}
>
<div className={`relative flex ${height}`}>
<div
ref={mapRef}
className="gis-map relative flex-1"
style={{ height: "100%", padding: 0 }}
>
<GisTree
visible={tabVisible}
setVisible={setTabVisible}
onCheckedChange={handleCheckedLayersChange}
checkedLayerRequest={checkedLayerRequest}
onLayerIconClick={(layerNm, node) => {
layerNmRef.current = layerNm;
setLayerNm(layerNm);
const shouldOpenDetail = onOpenPopup(layerNm, node)
if (shouldOpenDetail !== false) setIsOpen(true);
}}
imageVisible={imgVisble}
layerIconVisible={layerIconVisible}
layerLegendIconVisible={layerLegendIconVisible}
layerIconRequiresChecked={layerIconRequiresChecked}
layerActionIconOnly={layerActionIconOnly}
sinkholeLegendIconVisible={sinkholeLegendIconVisible}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
targetLayerTypes={targetLayerTypes}
checkInterval={checkInterval}
onProgressChange={handleProgressChange}
layerSettingView={LayerSettingView}
inlineLayerSettingView={inlineLayerSettingView}
treeSectionClassName={treeSectionClassName}
toggleButtonPosition={treeTogglePosition}
detachedToggleButton={detachedTreeToggleButton}
showLayerPanelTitle={showLayerPanelTitle}
sectionedPanel={sectionedTreePanel}
showBulkLayerToggle={showBulkLayerToggle}
/>
{typeof WaterDepth === "function" && (
<div className={`absolute bottom-4 z-40 ${tabVisible ? 'left-[260px]' : 'left-4'}`}>
<WaterDepth
onClose={() => setIsOpen(false)}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
totalSteps={totalSteps}
currentStep={currentStep}
/>
</div>
)}
{/* DetailView가 존재할 때만 렌더링 isSearchModalOpen이 true면 visible=false로 팝업 숨김 */}
{isOpen && !isSearchModalOpen && DetailView && (
<DetailView
featureData={featureData || { getProperties: () => ({}) }}
seletectedLayerNm={layerNm}
onClose={closePopup} // 닫기 콜백 전달
onFeatureSelect={handleFeatureSelect}
/>
)}
{/* SearchView가 존재할 때만 렌더링 */}
{SearchView && (
<SearchView />
)}
{/* 시도/시군구 조회 영역 */}
{SearchBar && (
<div className={`absolute left-1/2 top-4 z-30 w-max -translate-x-1/2 rounded ${searchBarContainerClassName}`}>
{SearchBar}
</div>
)}
<GisToolBar iconSize={toolBarIconSize} />
{showZoomLevel && (
<GisZoomLevel zoomGuide={zoomGuide} showZoomScaleInfo={showZoomScaleInfo} />
)}
<GisMapToggle alwaysVisibleTabs={alwaysVisibleMapTabs} />
<GisSelect
onFeatureSelect={handleFeatureSelect}
onMapBlankClick={onMapBlankClick}
canSelectFeature={canSelectFeature}
/>
</div>
</div>
{isSearch && showFloatingSearchButton && (
<div className="absolute right-8 top-[248px] z-20">
<button
onClick={() => setIsSearchModalOpen(true)}
title="정보 검색"
className="flex h-[40px] w-[40px] items-center justify-center overflow-hidden rounded border border-[#9aa7b8] bg-white p-0 shadow-[0_0_5px_#999]"
>
<Search size={24} className="text-gray-700" />
</button>
</div>
)}
</GisContext.Provider>
</>
);
}
export default React.memo(Gis);