3370 lines
115 KiB
TypeScript
3370 lines
115 KiB
TypeScript
/**
|
|
* ➡️ 경로 : 하수관망도 메인 페이지
|
|
*
|
|
* 📄 Map Page
|
|
* 지도 페이지 컴포넌트
|
|
*
|
|
* - OpenLayers 기반 GIS 지도 렌더링 및 상호작용
|
|
* - 레이어 트리 데이터 관리 및 동적 레이어 표시/숨김
|
|
* - 피처 검색 및 선택 기능 (SearchModal, ProcSearchBar)
|
|
* - 피처 클릭 이벤트 처리 및 상세 정보 팝업 표시
|
|
* - 수심(WaterDepth) 레이어 애니메이션 재생 기능
|
|
* - Zustand를 통한 검색 모달 상태 관리
|
|
*
|
|
* 🔗 사용 API
|
|
* - 레이어 설정 조회: GET /api/v1/map/lyrStngs/{lyrGrp} (sewerage-service)
|
|
*/
|
|
|
|
"use client";
|
|
import { useFloodPredictionMap } from "@/features/fldng-predc-prst/lib/use-flood-prediction-map";
|
|
import {
|
|
FLOOD_DNGR_MAP_LAYER_NAME,
|
|
FLOOD_DNGR_MAP_LAYER_TYPE,
|
|
} from "../components/flood-dngr-map/flood-dngr-map-types";
|
|
import {
|
|
findAgedPipeLayer,
|
|
prepareAgedPipeLayerRows,
|
|
validateAgedPipeLayer,
|
|
} from "../components/aged-pipe/aged-pipe-layer";
|
|
import { useFloodHistoryClick } from "../lib/use-flood-history-click";
|
|
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
|
|
import { useFloodHistoryVisibility } from "../lib/use-flood-history-visibility";
|
|
import { useFloodHistorySelection } from "../lib/use-flood-history-selection";
|
|
import { useSinkholeLayers } from "../lib/use-sinkhole-layers";
|
|
import { useExmnResultMap } from "../components/exmn/use-exmn-result-map";
|
|
import {
|
|
createExmnPipeStyle,
|
|
createExmnManholeStyle,
|
|
} from "../lib/analysis-layer-styles";
|
|
import { useGisEditSelection } from "../lib/use-gis-edit-selection";
|
|
import { useGisEditDraw } from "../lib/use-gis-edit-draw";
|
|
import { useGisEditTransform } from "../lib/use-gis-edit-transform";
|
|
import { useGisEditPreview } from "../lib/use-gis-edit-preview";
|
|
import { useGisEditPipeSnap } from "../lib/use-gis-edit-pipe-snap";
|
|
import { useGisEditOriginalMask } from "../lib/use-gis-edit-original-mask";
|
|
import { useWaterRiskLayer } from "../lib/use-water-risk-layer";
|
|
import { useFlowDirectionLayer } from "@/features/map-layers/lib/use-flow-direction-layer";
|
|
import dynamic from "next/dynamic";
|
|
import { type VertexHandleMeta } from "@/features/gis-edit/model/vertex-handles";
|
|
import {
|
|
type RightPanelTab,
|
|
findRightPanelTabOverflowCandidate,
|
|
removeRightPanelTabGroup,
|
|
RightPanelBookmarkTabs,
|
|
} from "../components/right-panel/right-panel-bookmark-tabs";
|
|
import {
|
|
FLOW_DIRECTION_DEFAULT_MIN_ZOOM,
|
|
getFlowDirectionModeByPathname,
|
|
SWAGE_PRCS_FCLT_LAYER_TYPE,
|
|
type SwagePrcsFcltLayerMode,
|
|
getSwagePrcsFcltLayerMode,
|
|
normalizeFlowDirectionLayerInfos,
|
|
normalizeRainObservationLayerInteraction,
|
|
getFlowDirectionMinZoom,
|
|
} from "@/features/map-layers";
|
|
import { ListTree } from "lucide-react";
|
|
import {
|
|
Fragment,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
type UIEvent,
|
|
} from "react";
|
|
import { usePathname, useSearchParams } from "next/navigation";
|
|
import GeoJSON from "ol/format/GeoJSON.js";
|
|
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
|
|
import {
|
|
isWindowPopupKey,
|
|
type WindowPopupKey,
|
|
} from "@/features/window-popup/model/popup-definitions";
|
|
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
|
import Gis from "@common/business_components/ui/map/Gis";
|
|
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
|
import MapSearchBar from "./map-search-bar";
|
|
import { Button } from "@common/components";
|
|
import { getData, getRawJson, postRawJson } from "@/shared/api/sewerage-api";
|
|
import useSearchModalStore from "@/features/map-search/model/search-store";
|
|
import useStore from "@/entities/reference-data/model/reference-data-store";
|
|
import { useAlertStore } from "@/shared/lib";
|
|
import { useUserStore } from "@/shared/lib/use-user-store";
|
|
import AgedPipeLayerSettingPanel from "../components/aged-pipe/aged-pipe-layer-setting-panel";
|
|
import GisEditSessionPanel from "../components/gis-edit/gis-edit-session-panel";
|
|
import GisEditToolbar from "../components/gis-edit/gis-edit-toolbar";
|
|
import GisEditPipeSessionPopup from "../components/gis-edit/gis-edit-pipe-session-popup";
|
|
import { useGisEdit } from "../components/gis-edit/use-gis-edit";
|
|
import type {
|
|
GisEditPipeBasicInfo,
|
|
GisEditSessionSnapshot,
|
|
} from "../components/gis-edit/gis-edit-types";
|
|
import {
|
|
PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
|
|
type PipeAnalysisDetailRow,
|
|
} from "../components/pipe-analysis/pipe-analysis-utils";
|
|
import {
|
|
AGED_PIPE_DEFAULT_FILTERS,
|
|
AGED_PIPE_GUIDE_MIN_ZOOM,
|
|
AGED_PIPE_LAYER_TYPE,
|
|
AGED_PIPE_PAGE_SIZE,
|
|
type AgedPipeFilters,
|
|
type AgedPipeRow,
|
|
buildAgedPipeCqlFilters,
|
|
isAgedPipeLayerNode,
|
|
normalizeAgedPipeFilters,
|
|
toDisplayPipeRow,
|
|
validateElapsedYears,
|
|
} from "@/entities/facility/lib/aged-pipe-utils";
|
|
import { downloadAgedPipeExcel } from "../components/aged-pipe/aged-pipe-export";
|
|
import { useInftwtResultMap } from "../components/inftwt-result/use-inftwt-result-map";
|
|
import { useRdiiResultMap } from "../components/rdii-result/use-rdii-result-map";
|
|
import SinkholeDetailPanel from "../components/sinkhole/sinkhole-detail-panel";
|
|
import SinkholeLayerSettingPanel from "../components/sinkhole/sinkhole-layer-setting-panel";
|
|
import FldngHstryDetailPanel from "../components/fldng-hstry/fldng-hstry-detail-panel";
|
|
import FldngHstryLayerSettingPanel from "../components/fldng-hstry/fldng-hstry-layer-setting-panel";
|
|
import ExmnLayerSettingPanel from "../components/exmn/exmn-layer-setting-panel";
|
|
import LayerSettingCard from "../components/layer-setting/layer-setting-card";
|
|
import {
|
|
EMPTY_EXMN_MAP_FILTERS,
|
|
appendExmnResultControlNodes,
|
|
buildExmnMapEntryContext,
|
|
isExmnResultMnhlNode,
|
|
isExmnResultPplNode,
|
|
type ExmnMapFilters,
|
|
} from "../components/exmn/exmn-map-types";
|
|
import {
|
|
buildSinkholeDefaultYearRange,
|
|
buildSinkholeFiltersFromYearRange,
|
|
buildSinkholeScope,
|
|
isSinkholeLayerNode,
|
|
isSinkholeRootNode,
|
|
normalizeSinkholeFilters,
|
|
normalizeSinkholeYearRange,
|
|
SINKHOLE_DEFAULT_RADIUS,
|
|
SINKHOLE_FILTER_SYNC_MESSAGE,
|
|
SINKHOLE_LAYER_TYPE,
|
|
SINKHOLE_POPUP_KEY,
|
|
SINKHOLE_ROW_SELECT_MESSAGE,
|
|
SINKHOLE_SYNC_COMPLETED_MESSAGE,
|
|
type SinkholeDetail,
|
|
type SinkholeFilters,
|
|
type SinkholeListItem,
|
|
type SinkholeNearbyPipe,
|
|
type SinkholeSyncSummary,
|
|
type SinkholeYearRange,
|
|
} from "../components/sinkhole/sinkhole-types";
|
|
import {
|
|
FLDNG_HSTRY_LAYER_TYPE,
|
|
FLDNG_HSTRY_POPUP_KEY,
|
|
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
|
|
type FldngHstryYearRange,
|
|
type FldngHstryItem,
|
|
} from "@/entities/flood-history/model/flood-history-types";
|
|
import {
|
|
buildFldngHstryDefaultYearRange,
|
|
isFldngHstryDepthNode,
|
|
isFldngHstryWtlvNode,
|
|
isFldngHstryRootNode,
|
|
} from "@/entities/flood-history/lib/flood-history-utils";
|
|
import {
|
|
SBMRG_DNGR_MAP_POPUP_KEY,
|
|
isSbmrgDngrMapRootNode,
|
|
} from "../components/sbmrg-dngr-map/sbmrg-dngr-map-types";
|
|
import {
|
|
FLOOD_DNGR_MAP_POPUP_KEY,
|
|
isFloodDngrMapRootNode,
|
|
} from "../components/flood-dngr-map/flood-dngr-map-types";
|
|
import FeatureClickEventPopup from "@/features/facility-detail/ui/feature-click-event-popup";
|
|
import SearchModal from "../search-modal/search-modal";
|
|
import {
|
|
WATER_DEPTH_LAYER_TYPES,
|
|
extractWaterDepthLegendItems,
|
|
} from "../water-depth/water-depth";
|
|
import { buildWaterRiskStatusLabel } from "../components/water-risk/water-risk-types";
|
|
|
|
const AnalysisPopupMenu = dynamic(
|
|
() => import("../components/analysis-menu/analysis-popup-menu"),
|
|
{ ssr: false },
|
|
);
|
|
import {
|
|
AGED_PIPE_ROW_SELECT_MESSAGE,
|
|
AGED_PIPE_POPUP_MINIMIZE_MESSAGE,
|
|
AGED_PIPE_FILTER_SYNC_MESSAGE,
|
|
EXMN_PROC_SEARCH_EVENT,
|
|
SOUTH_KOREA_VIEW_EXTENT,
|
|
FLOW_DIRECTION_SRS,
|
|
SHORT_TERM_FORECAST_GRID_LAYER_NAME,
|
|
RIGHT_PANEL_TAB_LIMIT,
|
|
RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID,
|
|
AGED_PIPE_FOCUS_PADDING,
|
|
DEFAULT_FEATURE_FIT_PADDING,
|
|
} from "../model/map-constants";
|
|
import { getFeatureCollection } from "../lib/map-values";
|
|
import {
|
|
getSinkholeAccidentId,
|
|
getSinkholeFallbackDetailFromSelection,
|
|
getSinkholePointFromSelection,
|
|
createSinkholeFocusedPipeStyle,
|
|
appendSinkholeControlNode,
|
|
} from "../lib/sinkhole-features";
|
|
import {
|
|
getGisEditFeatureFid,
|
|
isGisEditPreviewFeatureData,
|
|
highlightGisEditSelectedFeature,
|
|
isVisibleGisEditSessionChange,
|
|
getLatestNumericEditSn,
|
|
findInvalidPipeRequiredChange,
|
|
} from "../lib/gis-edit-features";
|
|
import {
|
|
getSelectedFeatureLayerTypeName,
|
|
getRightPanelFeatureIdentifier,
|
|
getRightPanelFeatureProperty,
|
|
getRightPanelTabCategory,
|
|
getRightPanelFeatureDisplayLabel,
|
|
} from "../lib/right-panel-features";
|
|
import { normalizeTreeRowSpacing } from "../lib/layer-tree";
|
|
import { useMapRuntimeBridge } from "../lib/use-map-runtime-bridge";
|
|
import MapControlStyles from "./map-control-styles";
|
|
|
|
const MapPageClient = () => {
|
|
const facilityCodes = useFacilityCodeCatalog();
|
|
const searchParams = useSearchParams();
|
|
const pathname = usePathname();
|
|
const alert = useAlertStore((state) => state.setMessage);
|
|
const exmnMapEntryContext = useMemo(
|
|
() => buildExmnMapEntryContext(searchParams),
|
|
[searchParams],
|
|
);
|
|
const initialAnalysisPopupKey = useMemo(() => {
|
|
const popupKey = searchParams.get("analysisPopupKey");
|
|
if (!popupKey || !isWindowPopupKey(popupKey)) return null;
|
|
if (popupKey !== "inftwtResultPopup" && popupKey !== "rdiiResultPopup")
|
|
return null;
|
|
return popupKey;
|
|
}, [searchParams]);
|
|
const flowDirectionMode = useMemo(
|
|
() => getFlowDirectionModeByPathname(pathname),
|
|
[pathname],
|
|
);
|
|
const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore();
|
|
const userInfo = useUserStore((state) => state.userInfo);
|
|
const sinkholeScope = useMemo(() => buildSinkholeScope(userInfo), [userInfo]);
|
|
|
|
const [treeData, setTreeData] = useState<any[]>([]); // 초기값을 객체 또는 null로 설정
|
|
// 통수위험 결과 행을 선택할 때 기존 체크를 유지하면서 강우 격자 레이어를 켜기 위한 요청이다.
|
|
const [checkedLayerRequest, setCheckedLayerRequest] = useState<{
|
|
requestKey: number;
|
|
layerNames: string[];
|
|
} | null>(null);
|
|
const [layerLoadError, setLayerLoadError] = useState<string | null>(null);
|
|
const gisMaps = useRef<any | null>(null);
|
|
const requestFloodPredictionLayer = useCallback(() => {
|
|
setCheckedLayerRequest((current) => ({
|
|
requestKey: (current?.requestKey ?? 0) + 1,
|
|
layerNames: [FLOOD_DNGR_MAP_LAYER_NAME],
|
|
}));
|
|
}, []);
|
|
const { handleFloodPredictionPopupSelection } = useFloodPredictionMap({
|
|
mapRef: gisMaps,
|
|
floodLayerType: FLOOD_DNGR_MAP_LAYER_TYPE,
|
|
onRequestFloodLayer: requestFloodPredictionLayer,
|
|
});
|
|
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
|
|
const [featureData, setFeatureData] = useState<any>(null); // 선택된 피쳐 데이터
|
|
// 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다.
|
|
const [rightPanelTabs, setRightPanelTabs] = useState<RightPanelTab[]>([]);
|
|
const [activeRightPanelTabId, setActiveRightPanelTabId] = useState<
|
|
string | null
|
|
>(null);
|
|
// 단일 관로 탭이 어느 지반침하 상세에서 열렸는지 비동기 피처 조회 사이에 보관한다.
|
|
const pipeParentTabIdRef = useRef<string | null>(null);
|
|
const [isWaterDepthLayerActive, setIsWaterDepthLayerActive] = useState(false); // 침수심 레이어 활성 여부
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [mapReady, setMapReady] = useState(false);
|
|
const handleWaterRiskSelection = useCallback(() => {
|
|
setCheckedLayerRequest((current) => ({
|
|
requestKey: (current?.requestKey ?? 0) + 1,
|
|
layerNames: [SHORT_TERM_FORECAST_GRID_LAYER_NAME],
|
|
}));
|
|
setIsOpen(false);
|
|
setFeatureData(null);
|
|
}, []);
|
|
const {
|
|
selectedWaterRiskResultRow,
|
|
waterRiskInfoCardStyle,
|
|
selectedWaterRiskMapView,
|
|
selectedWaterRiskDisplayStatus,
|
|
watchPopup: watchWaterRiskPopup,
|
|
} = useWaterRiskLayer({
|
|
gisMaps,
|
|
mapReady,
|
|
onSelectResult: handleWaterRiskSelection,
|
|
});
|
|
const [checkedLayerLegendVersion, setCheckedLayerLegendVersion] = useState(0);
|
|
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
|
|
const [isSinkholeChecked, setIsSinkholeChecked] = useState(false);
|
|
const [isSinkholeSettingEnabled, setIsSinkholeSettingEnabled] =
|
|
useState(true);
|
|
const [isSinkholeSettingVisible, setIsSinkholeSettingVisible] =
|
|
useState(true);
|
|
const [isAgedPipeSettingEnabled, setIsAgedPipeSettingEnabled] =
|
|
useState(true);
|
|
const [isAgedPipeSettingVisible, setIsAgedPipeSettingVisible] =
|
|
useState(true);
|
|
const [isExmnPplSettingEnabled, setIsExmnPplSettingEnabled] = useState(true);
|
|
const [isExmnPplSettingVisible, setIsExmnPplSettingVisible] = useState(true);
|
|
const [isExmnMnhlSettingEnabled, setIsExmnMnhlSettingEnabled] =
|
|
useState(true);
|
|
const [isExmnMnhlSettingVisible, setIsExmnMnhlSettingVisible] =
|
|
useState(true);
|
|
const [isWaterDepthLegendEnabled, setIsWaterDepthLegendEnabled] =
|
|
useState(true);
|
|
const [isWaterDepthLegendVisible, setIsWaterDepthLegendVisible] =
|
|
useState(true);
|
|
const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false);
|
|
// 침수흔적위선과 심선은 하나의 연도 범위를 공유하지만 체크 상태는 독립적으로 관리한다.
|
|
const [isFldngHstryWtlvChecked, setIsFldngHstryWtlvChecked] = useState(false);
|
|
const [isFldngHstryDepthChecked, setIsFldngHstryDepthChecked] =
|
|
useState(false);
|
|
// 심선은 6개 깊이 등급 중 사용자가 실제 체크한 lyrSn만 필터 레이어로 생성한다.
|
|
const [checkedFldngHstryDepthLayerKeys, setCheckedFldngHstryDepthLayerKeys] =
|
|
useState<string[]>([]);
|
|
const [fldngHstryYearRange, setFldngHstryYearRange] =
|
|
useState<FldngHstryYearRange>(() => buildFldngHstryDefaultYearRange());
|
|
const [isFldngHstryYearFilterEnabled, setIsFldngHstryYearFilterEnabled] =
|
|
useState(true);
|
|
const [isFldngHstryYearSettingVisible, setIsFldngHstryYearSettingVisible] =
|
|
useState(true);
|
|
// 닫힌 설정 카드는 레이어가 완전히 해제된 뒤 다시 체크될 때만 자동으로 연다.
|
|
const fldngHstryLayerCheckedRef = useRef(false);
|
|
const sinkholeLayerCheckedRef = useRef(false);
|
|
const agedPipeLayerCheckedRef = useRef(false);
|
|
const exmnPplLayerCheckedRef = useRef(false);
|
|
const exmnMnhlLayerCheckedRef = useRef(false);
|
|
const waterDepthLayerCheckedRef = useRef(false);
|
|
const [isExmnPplChecked, setIsExmnPplChecked] = useState(false);
|
|
const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false);
|
|
const [exmnPplFilters, setExmnPplFilters] = useState<ExmnMapFilters>({
|
|
...EMPTY_EXMN_MAP_FILTERS,
|
|
});
|
|
const [exmnMnhlFilters, setExmnMnhlFilters] = useState<ExmnMapFilters>({
|
|
...EMPTY_EXMN_MAP_FILTERS,
|
|
});
|
|
const [swagePrcsFcltLayerMode, setSwagePrcsFcltLayerMode] =
|
|
useState<SwagePrcsFcltLayerMode>("all");
|
|
const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState<
|
|
string[]
|
|
>([]);
|
|
const [draftAgedPipeFilters, setDraftAgedPipeFilters] =
|
|
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
|
const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] =
|
|
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
|
const [sinkholeYearRange, setSinkholeYearRange] = useState<SinkholeYearRange>(
|
|
() => buildSinkholeDefaultYearRange(),
|
|
);
|
|
const [appliedSinkholeFilters, setAppliedSinkholeFilters] =
|
|
useState<SinkholeFilters>(() =>
|
|
normalizeSinkholeFilters(
|
|
buildSinkholeFiltersFromYearRange(
|
|
buildSinkholeDefaultYearRange(),
|
|
sinkholeScope,
|
|
),
|
|
sinkholeScope,
|
|
),
|
|
);
|
|
const [isSinkholePopupFilterActive, setIsSinkholePopupFilterActive] =
|
|
useState(false);
|
|
const [sinkholeRadiusMeters, setSinkholeRadiusMeters] = useState(
|
|
SINKHOLE_DEFAULT_RADIUS,
|
|
);
|
|
const [selectedFldngHstryDetail, setSelectedFldngHstryDetail] =
|
|
useState<FldngHstryItem | null>(null);
|
|
const [fldngHstryDetailPopupPosition, setFldngHstryDetailPopupPosition] =
|
|
useState<{ left: number; top: number } | null>(null);
|
|
const [selectedSinkholeDetail, setSelectedSinkholeDetail] =
|
|
useState<SinkholeDetail | null>(null);
|
|
const [selectedSinkholeNearbyPipes, setSelectedSinkholeNearbyPipes] =
|
|
useState<SinkholeNearbyPipe[]>([]);
|
|
const [lastSinkholeSyncSummary, setLastSinkholeSyncSummary] =
|
|
useState<SinkholeSyncSummary | null>(null);
|
|
const [sinkholeSelectionVersion, setSinkholeSelectionVersion] = useState(0);
|
|
const [isAgedPipeListOpen, setIsAgedPipeListOpen] = useState(false);
|
|
const [isAgedPipeListMinimized, setIsAgedPipeListMinimized] = useState(false);
|
|
const [agedPipeRows, setAgedPipeRows] = useState<AgedPipeRow[]>([]);
|
|
const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0);
|
|
const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1);
|
|
const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false);
|
|
const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] =
|
|
useState("");
|
|
const [isGisEditSaving, setIsGisEditSaving] = useState(false);
|
|
const { ctpv, sgg } = useStore();
|
|
// GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다.
|
|
const gisEdit = useGisEdit(treeData);
|
|
// 침입수 분석결과 popup과 지도 highlight 흐름은 별도 hook으로 묶어 기존 지도 로직과 충돌을 줄인다.
|
|
const inftwtResultMap = useInftwtResultMap({
|
|
gisMaps,
|
|
setFeatureData,
|
|
setIsOpen,
|
|
setIsSearchModalOpen,
|
|
});
|
|
const rdiiResultMap = useRdiiResultMap({
|
|
gisMaps,
|
|
setFeatureData,
|
|
setIsOpen,
|
|
setIsSearchModalOpen,
|
|
});
|
|
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
|
|
const agedPipePopupWindowRef = useRef<Window | null>(null);
|
|
const sinkholePopupWindowRef = useRef<Window | null>(null);
|
|
const sinkholePopupCloseWatcherRef = useRef<number | null>(null);
|
|
const pipeAnalysisPopupWindowRef = useRef<Window | null>(null);
|
|
const gisEditSessionPopupWindowRef = useRef<Window | null>(null);
|
|
const gisEditSessionPopupCloseWatcherRef = useRef<number | null>(null);
|
|
const [gisEditSessionView, setGisEditSessionView] = useState<
|
|
"inline" | "popup"
|
|
>("inline");
|
|
const [gisEditDetailChangeId, setGisEditDetailChangeId] = useState<
|
|
string | null
|
|
>(null);
|
|
const gisEditDetailChangeRef = useRef<any>(null);
|
|
const gisEditDetailFeatureDataRef = useRef<any>(null);
|
|
const gisEditIsEditModeRef = useRef(false);
|
|
const gisEditMoveGripMetaRef = useRef<{
|
|
changeId: string;
|
|
meta: VertexHandleMeta | null;
|
|
} | null>(null);
|
|
// 줌레벨 컨트롤에 현재 체크된 특수 레이어의 최소 표출 기준을 안내한다.
|
|
// 유하방향 API의 관종 필터를 만들기 위해 현재 체크된 트리 노드 목록을 ref로 보관한다.
|
|
const checkedNodesDataRef = useRef<any[]>([]);
|
|
const {
|
|
isFlowDirectionChecked,
|
|
flowDirectionLayerInfo,
|
|
updateSelection: updateFlowDirectionSelection,
|
|
} = useFlowDirectionLayer({
|
|
gisMaps,
|
|
checkedNodesDataRef,
|
|
mapReady,
|
|
flowDirectionMode,
|
|
});
|
|
const zoomGuide = useMemo(() => {
|
|
const flowDirectionGuideZoom = flowDirectionLayerInfo
|
|
? getFlowDirectionMinZoom(flowDirectionLayerInfo)
|
|
: FLOW_DIRECTION_DEFAULT_MIN_ZOOM;
|
|
|
|
if (isAgedPipeChecked && isFlowDirectionChecked) {
|
|
return {
|
|
label: "노후관로/유하방향 기준",
|
|
zoom: Math.max(AGED_PIPE_GUIDE_MIN_ZOOM, flowDirectionGuideZoom),
|
|
};
|
|
}
|
|
|
|
if (isFlowDirectionChecked) {
|
|
return {
|
|
label: "유하방향 기준",
|
|
zoom: flowDirectionGuideZoom,
|
|
};
|
|
}
|
|
|
|
if (isAgedPipeChecked) {
|
|
return {
|
|
label: "노후관로 기준",
|
|
zoom: AGED_PIPE_GUIDE_MIN_ZOOM,
|
|
};
|
|
}
|
|
|
|
return undefined;
|
|
}, [flowDirectionLayerInfo, isAgedPipeChecked, isFlowDirectionChecked]);
|
|
const { ensureGisEditPipeSnapSource, shouldUsePipeSnapSource } =
|
|
useGisEditPipeSnap({ gisEdit, gisMaps, mapReady });
|
|
|
|
useGisEditOriginalMask({ gisMaps, gisEdit, mapReady });
|
|
|
|
const gisEditVisibleSessionChanges = useMemo(
|
|
() => gisEdit.changes.filter(isVisibleGisEditSessionChange),
|
|
[gisEdit.changes],
|
|
);
|
|
const shouldShowGisEditSession = gisEdit.isEditMode;
|
|
const isGisEditSessionInlineVisible =
|
|
shouldShowGisEditSession && gisEditSessionView === "inline";
|
|
const gisEditSessionSnapshot = useMemo<GisEditSessionSnapshot>(
|
|
() => ({
|
|
isEditMode: gisEdit.isEditMode,
|
|
selectedLayerName: gisEdit.selectedLayer?.layerName ?? "",
|
|
activeTool: gisEdit.activeTool,
|
|
isSnapEnabled: gisEdit.isSnapEnabled,
|
|
changes: gisEditVisibleSessionChanges,
|
|
lastSavedPayload: gisEdit.lastSavedPayload,
|
|
canUndo: gisEdit.canUndo,
|
|
focusedChangeId: gisEdit.focusedChangeId,
|
|
}),
|
|
[
|
|
gisEdit.activeTool,
|
|
gisEdit.canUndo,
|
|
gisEdit.focusedChangeId,
|
|
gisEdit.isEditMode,
|
|
gisEdit.isSnapEnabled,
|
|
gisEdit.lastSavedPayload,
|
|
gisEdit.selectedLayer,
|
|
gisEditVisibleSessionChanges,
|
|
],
|
|
);
|
|
const gisEditDetailChange = useMemo(
|
|
() =>
|
|
gisEdit.changes.find(
|
|
(change) => change.changeId === gisEditDetailChangeId,
|
|
) ?? null,
|
|
[gisEdit.changes, gisEditDetailChangeId],
|
|
);
|
|
const gisEditDetailFeatureData = useMemo(() => {
|
|
if (!gisEditDetailChange) return null;
|
|
if (featureData && !isGisEditPreviewFeatureData(featureData))
|
|
return featureData;
|
|
|
|
if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl") return null;
|
|
|
|
const savedFeatureProperties = gisEditDetailChange.featureProperties ?? {};
|
|
return {
|
|
...savedFeatureProperties,
|
|
lyrTypeNm: "tb_ms_swage_ppl",
|
|
fcltMngNo:
|
|
gisEditDetailChange.featureRef?.fcltMngNo ??
|
|
savedFeatureProperties["fclt_mng_no"] ??
|
|
gisEditDetailChange.objectId ??
|
|
"",
|
|
mngInstCd:
|
|
gisEditDetailChange.featureRef?.mngInstCd ??
|
|
savedFeatureProperties["mng_inst_cd"] ??
|
|
"",
|
|
fclt_mng_no:
|
|
gisEditDetailChange.featureRef?.fcltMngNo ??
|
|
savedFeatureProperties["fclt_mng_no"] ??
|
|
gisEditDetailChange.objectId ??
|
|
"",
|
|
mng_inst_cd:
|
|
gisEditDetailChange.featureRef?.mngInstCd ??
|
|
savedFeatureProperties["mng_inst_cd"] ??
|
|
"",
|
|
};
|
|
}, [featureData, gisEditDetailChange]);
|
|
gisEditDetailChangeRef.current = gisEditDetailChange;
|
|
gisEditDetailFeatureDataRef.current = gisEditDetailFeatureData;
|
|
gisEditIsEditModeRef.current = gisEdit.isEditMode;
|
|
const syncGisEditSessionPopup = useCallback(() => {
|
|
const popup = gisEditSessionPopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
return;
|
|
}
|
|
|
|
popup.postMessage(
|
|
{
|
|
type: WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC,
|
|
payload: gisEditSessionSnapshot,
|
|
},
|
|
window.location.origin,
|
|
);
|
|
}, [gisEditSessionSnapshot]);
|
|
|
|
const closeGisEditSessionPopup = useCallback(() => {
|
|
if (gisEditSessionPopupCloseWatcherRef.current) {
|
|
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
|
|
gisEditSessionPopupCloseWatcherRef.current = null;
|
|
}
|
|
|
|
const popup = gisEditSessionPopupWindowRef.current;
|
|
if (popup && !popup.closed) {
|
|
popup.close();
|
|
}
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
}, []);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (gisEditSessionPopupCloseWatcherRef.current) {
|
|
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
|
|
gisEditSessionPopupCloseWatcherRef.current = null;
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const openGisEditPipeDetail = useCallback((changeId: string | null) => {
|
|
if (!changeId) {
|
|
setGisEditDetailChangeId(null);
|
|
return;
|
|
}
|
|
setGisEditDetailChangeId(changeId);
|
|
}, []);
|
|
|
|
const focusGisEditChangeOnMap = useCallback(
|
|
(changeId: string) => {
|
|
const map = gisMaps.current;
|
|
const change = gisEdit.changes.find((item) => item.changeId === changeId);
|
|
const geometryObject =
|
|
change?.editType === "DELETE"
|
|
? change?.beforeGeometry
|
|
: change?.afterGeometry;
|
|
if (!map || !geometryObject) return;
|
|
|
|
try {
|
|
const geometry = new GeoJSON().readGeometry(geometryObject, {
|
|
dataProjection: FLOW_DIRECTION_SRS,
|
|
featureProjection: GisApp.TO_PROJ,
|
|
});
|
|
const extent = geometry?.getExtent?.();
|
|
if (
|
|
!extent ||
|
|
extent.length < 4 ||
|
|
extent.some((value: number) => !Number.isFinite(value))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const view = map.getView?.();
|
|
const currentZoom = Number(view?.getZoom?.());
|
|
const maxZoom =
|
|
Number.isFinite(currentZoom) && currentZoom > 18 ? currentZoom : 18;
|
|
view?.fit?.(extent, {
|
|
duration: 350,
|
|
padding: [80, 420, 80, 80],
|
|
maxZoom,
|
|
});
|
|
} catch (error) {
|
|
console.warn("GIS edit change focus failed:", error);
|
|
}
|
|
},
|
|
[gisEdit.changes],
|
|
);
|
|
const {
|
|
gisEditPreviewLayerRef,
|
|
gisEditPreviewSourceRef,
|
|
setGisEditPreviewCursor,
|
|
ensureGisEditPreviewLayer,
|
|
clearGisEditPreviewLayer,
|
|
} = useGisEditPreview({
|
|
gisMaps,
|
|
mapReady,
|
|
gisEdit,
|
|
focusGisEditChangeOnMap,
|
|
});
|
|
|
|
// 인라인 세션과 윈도우 팝업이 동일한 지도 focus/상세 열기 흐름을 공유한다.
|
|
const handleFocusGisEditSessionChange = useCallback(
|
|
(changeId: string) => {
|
|
if (!changeId) return;
|
|
gisEdit.focusChange(changeId);
|
|
focusGisEditChangeOnMap(changeId);
|
|
setFeatureData(null);
|
|
openGisEditPipeDetail(changeId);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[
|
|
focusGisEditChangeOnMap,
|
|
gisEdit,
|
|
openGisEditPipeDetail,
|
|
setIsSearchModalOpen,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setAppliedSinkholeFilters((currentFilters) => {
|
|
if (isSinkholePopupFilterActive) {
|
|
return normalizeSinkholeFilters(currentFilters, sinkholeScope);
|
|
}
|
|
if (!isSinkholeSettingEnabled) {
|
|
return normalizeSinkholeFilters(
|
|
{
|
|
...buildSinkholeFiltersFromYearRange(
|
|
sinkholeYearRange,
|
|
sinkholeScope,
|
|
),
|
|
startYmd: "",
|
|
endYmd: "",
|
|
},
|
|
sinkholeScope,
|
|
);
|
|
}
|
|
return normalizeSinkholeFilters(
|
|
buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope),
|
|
sinkholeScope,
|
|
);
|
|
});
|
|
}, [
|
|
isSinkholePopupFilterActive,
|
|
isSinkholeSettingEnabled,
|
|
sinkholeScope,
|
|
sinkholeYearRange,
|
|
]);
|
|
|
|
useMapRuntimeBridge({
|
|
gisMaps,
|
|
setMapReady,
|
|
handleFloodPredictionPopupSelection,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (shouldShowGisEditSession) return;
|
|
|
|
const popup = gisEditSessionPopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
return;
|
|
}
|
|
|
|
popup.close();
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
}, [shouldShowGisEditSession]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
const popup = gisEditSessionPopupWindowRef.current;
|
|
if (popup && !popup.closed) {
|
|
popup.close();
|
|
}
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!gisEdit.isEditMode) {
|
|
setGisEditDetailChangeId(null);
|
|
return;
|
|
}
|
|
|
|
if (!gisEditDetailChangeId) return;
|
|
if (!gisEditDetailChange) {
|
|
setGisEditDetailChangeId(null);
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
gisEditDetailChange.layerId !== "tb_ms_swage_ppl" ||
|
|
gisEditDetailChange.editType === "DELETE"
|
|
) {
|
|
setGisEditDetailChangeId(null);
|
|
}
|
|
}, [gisEdit.isEditMode, gisEditDetailChange, gisEditDetailChangeId]);
|
|
|
|
useEffect(() => {
|
|
if (!gisEdit.isEditMode) return;
|
|
syncGisEditSessionPopup();
|
|
}, [
|
|
gisEdit.isEditMode,
|
|
gisEditVisibleSessionChanges.length,
|
|
syncGisEditSessionPopup,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const handleGisEditSessionPopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
const data = event.data;
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_READY) {
|
|
syncGisEditSessionPopup();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SHOW_INLINE
|
|
) {
|
|
closeGisEditSessionPopup();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE
|
|
) {
|
|
const changeId = String(data.payload?.changeId ?? "").trim();
|
|
handleFocusGisEditSessionChange(changeId);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type ===
|
|
WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE
|
|
) {
|
|
gisEdit.removeFocusedChange();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type ===
|
|
WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE
|
|
) {
|
|
gisEdit.removeLastChange();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES
|
|
) {
|
|
gisEdit.clearChanges();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleGisEditSessionPopupMessage);
|
|
return () => {
|
|
window.removeEventListener("message", handleGisEditSessionPopupMessage);
|
|
};
|
|
}, [
|
|
closeGisEditSessionPopup,
|
|
gisEdit,
|
|
handleFocusGisEditSessionChange,
|
|
syncGisEditSessionPopup,
|
|
]);
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const data = await getRawJson(
|
|
"/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK",
|
|
);
|
|
// DB 필터 치환 실패는 노후관로에만 한정하고 다른 시설물 레이어는 유지한다.
|
|
const preparedData = prepareAgedPipeLayerRows(
|
|
data,
|
|
appliedAgedPipeFilters,
|
|
);
|
|
const agedPipeConfigError =
|
|
findAgedPipeLayer(preparedData)?.agedPipeConfigError;
|
|
if (agedPipeConfigError) alert(agedPipeConfigError);
|
|
setTreeData(
|
|
appendExmnResultControlNodes(
|
|
appendSinkholeControlNode(
|
|
normalizeTreeRowSpacing(
|
|
normalizeRainObservationLayerInteraction(
|
|
normalizeFlowDirectionLayerInfos(preparedData),
|
|
),
|
|
),
|
|
),
|
|
exmnMapEntryContext.jobType,
|
|
),
|
|
);
|
|
setLayerLoadError(null);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "레이어 설정 조회 중 알 수 없는 오류가 발생했습니다.";
|
|
console.error("레이어 설정 조회 실패:", error);
|
|
setTreeData([]);
|
|
setLayerLoadError(message);
|
|
}
|
|
};
|
|
|
|
// SearchModal, GisContent 등에서 호출
|
|
const onFeatureSelect = (data: any) => {
|
|
const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data);
|
|
if (clickedLayerTypeName === "tb_ms_swage_ppl") {
|
|
pipeParentTabIdRef.current = null;
|
|
}
|
|
|
|
// 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다.
|
|
clearSinkholeFocusedPipeLayer();
|
|
if (rdiiResultMap.handleOverlayFeatureSelect(data)) {
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
if (inftwtResultMap.handleOverlayFeatureSelect(data)) {
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
gisEdit.isEditMode &&
|
|
(gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") &&
|
|
gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" &&
|
|
clickedLayerTypeName !== "tb_ms_swage_ppl"
|
|
) {
|
|
gisEdit.announceStatus("하수관로를 선택해주세요.");
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (clickedLayerTypeName === SINKHOLE_LAYER_TYPE) {
|
|
const acdntNo = getSinkholeAccidentId(data);
|
|
if (acdntNo) {
|
|
void loadSinkholeDetail(
|
|
acdntNo,
|
|
false,
|
|
getSinkholePointFromSelection(data),
|
|
getSinkholeFallbackDetailFromSelection(data),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 편집 도구가 켜져 있을 때는 상세 팝업보다 도구별 세션 처리를 우선한다.
|
|
// ADD는 Draw interaction만 geometry를 받아야 하므로 기존 관로 선택 이벤트는 무시한다.
|
|
let editSelection: any = gisEdit.registerFeatureSelection(data);
|
|
if (
|
|
!editSelection.handled &&
|
|
gisEdit.isEditMode &&
|
|
!gisEdit.activeTool &&
|
|
clickedLayerTypeName === "tb_ms_swage_ppl"
|
|
) {
|
|
editSelection = gisEdit.registerPipeDetailSelection(data);
|
|
}
|
|
if (editSelection.handled) {
|
|
const closeGisEditDetail = () => {
|
|
const select = GisApp.Module.select as any;
|
|
select?.resetSelectedFeature?.();
|
|
setFeatureData(null);
|
|
setGisEditDetailChangeId(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
};
|
|
const loadLatestEditSn = (changeId: string) => {
|
|
const fid =
|
|
editSelection.isPreviewSelection === true
|
|
? ""
|
|
: getGisEditFeatureFid(data);
|
|
if (!fid) return;
|
|
getRawJson(
|
|
`/sewerage-service/api/v1/map/gisEdit/swagePpl/histories?fid=${encodeURIComponent(fid)}`,
|
|
)
|
|
.then((historyResponse) => {
|
|
const latestEditSn = getLatestNumericEditSn(
|
|
historyResponse?.histories ?? [],
|
|
);
|
|
if (latestEditSn !== null) {
|
|
gisEdit.updateChangeBaseEditSn(changeId, String(latestEditSn));
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("GIS 편집 관로 이력 조회 실패:", error);
|
|
gisEdit.announceStatus(
|
|
"관로 이력 정보를 확인하지 못했습니다. 저장 시 충돌 검증이 제한될 수 있습니다.",
|
|
);
|
|
});
|
|
};
|
|
|
|
if (gisEdit.activeTool === "ADD" || gisEdit.activeTool === "DELETE") {
|
|
closeGisEditDetail();
|
|
return;
|
|
}
|
|
|
|
const isGeometryEditTargetSelection =
|
|
editSelection.isPreviewSelection !== true &&
|
|
(gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT");
|
|
|
|
if (isGeometryEditTargetSelection) {
|
|
if (
|
|
typeof editSelection.changeId === "string" &&
|
|
editSelection.changeId
|
|
) {
|
|
loadLatestEditSn(String(editSelection.changeId));
|
|
}
|
|
closeGisEditDetail();
|
|
return;
|
|
}
|
|
|
|
const canOpenPreviewPipeDetail =
|
|
clickedLayerTypeName === "tb_ms_swage_ppl" &&
|
|
editSelection.isPreviewSelection === true &&
|
|
editSelection.editType !== "DELETE";
|
|
const shouldOpenPipeDetail =
|
|
clickedLayerTypeName === "tb_ms_swage_ppl" &&
|
|
typeof editSelection.changeId === "string" &&
|
|
editSelection.changeId &&
|
|
(editSelection.isPreviewSelection !== true || canOpenPreviewPipeDetail);
|
|
|
|
if (shouldOpenPipeDetail) {
|
|
openGisEditPipeDetail(String(editSelection.changeId));
|
|
loadLatestEditSn(String(editSelection.changeId));
|
|
if (
|
|
editSelection.isPreviewSelection !== true &&
|
|
!isGeometryEditTargetSelection
|
|
) {
|
|
highlightGisEditSelectedFeature(data);
|
|
}
|
|
setFeatureData(data);
|
|
window.setTimeout(() => {
|
|
setGisEditDetailChangeId(String(editSelection.changeId));
|
|
setIsOpen(true);
|
|
}, 0);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
// 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해
|
|
// 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다.
|
|
closeGisEditDetail();
|
|
return;
|
|
}
|
|
|
|
let map = gisMaps.current;
|
|
if (!map) {
|
|
console.warn("지도가 초기화되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
// 항상 같은 select 객체 사용
|
|
const select = GisApp.Module.select as any;
|
|
if (!select) return;
|
|
|
|
// 1. 기존 선택 초기화
|
|
select.resetSelectedFeature();
|
|
|
|
// 2. data가 OpenLayers Feature 인스턴스인지 확인
|
|
let foundFeature = null;
|
|
if (
|
|
data &&
|
|
typeof data.getGeometry === "function" &&
|
|
typeof data.setStyle === "function"
|
|
) {
|
|
// 이미 Feature 인스턴스라면 그대로 사용
|
|
foundFeature = data;
|
|
} else {
|
|
// 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용
|
|
const featureId = data.fid || data.ftrIdn || data.id;
|
|
map
|
|
.getLayers()
|
|
.getArray()
|
|
.forEach((layer: any) => {
|
|
const source = layer.getSource?.();
|
|
if (source?.getFeatureById) {
|
|
const feature = source.getFeatureById(String(featureId).trim());
|
|
if (feature) foundFeature = feature;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 3. 피처 선택
|
|
if (foundFeature) {
|
|
let geometry = foundFeature.getGeometry();
|
|
if (geometry) {
|
|
let extent = geometry.getExtent();
|
|
|
|
const layerTypeName =
|
|
typeof foundFeature.getId === "function"
|
|
? String(foundFeature.getId()).split(".")[0]
|
|
: data?.lyrTypeNm;
|
|
const shouldUseAgedPipeFocus =
|
|
shouldFocusAgedPipeOnNextSelectRef.current &&
|
|
layerTypeName === AGED_PIPE_LAYER_TYPE;
|
|
|
|
map.getView().fit(extent, {
|
|
duration: 300,
|
|
padding: shouldUseAgedPipeFocus
|
|
? AGED_PIPE_FOCUS_PADDING
|
|
: DEFAULT_FEATURE_FIT_PADDING,
|
|
maxZoom: shouldUseAgedPipeFocus ? 18 : 16,
|
|
constrainResolution: true,
|
|
nearest: true,
|
|
});
|
|
|
|
if (shouldUseAgedPipeFocus) {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
|
}
|
|
}
|
|
select.selectFeature(foundFeature, true);
|
|
} else {
|
|
console.warn("OpenLayers Feature를 찾을 수 없습니다:", data);
|
|
}
|
|
|
|
setFeatureData(data);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
};
|
|
|
|
const swagePrcsFcltSearchFilter = useMemo(() => {
|
|
switch (swagePrcsFcltLayerMode) {
|
|
case "gte500":
|
|
return {
|
|
label: "하수처리시설(500톤 이상)",
|
|
cqlFilter: "fclt_cpct >= 500",
|
|
};
|
|
case "lt500":
|
|
return {
|
|
label: "하수처리시설(500톤 미만)",
|
|
cqlFilter: "fclt_cpct < 500",
|
|
};
|
|
case "none":
|
|
return { label: "하수처리시설(레이어 꺼짐)", disabled: true };
|
|
case "all":
|
|
default:
|
|
return { label: "하수처리시설(전체)" };
|
|
}
|
|
}, [swagePrcsFcltLayerMode]);
|
|
|
|
const facilityTypes = [
|
|
{ text: "하수처리시설", val: SWAGE_PRCS_FCLT_LAYER_TYPE },
|
|
];
|
|
|
|
const fetchAgedPipeRows = useCallback(
|
|
async (page: number, filters: AgedPipeFilters, size: number) => {
|
|
const normalized = normalizeAgedPipeFilters(filters);
|
|
const layer = findAgedPipeLayer(treeData);
|
|
validateAgedPipeLayer(layer);
|
|
const queryParams = new URLSearchParams({
|
|
lyrTypeNm: layer.lyrTyNm,
|
|
page: String(page - 1),
|
|
size: String(size),
|
|
sort: "string",
|
|
});
|
|
|
|
const cqlFilters = buildAgedPipeCqlFilters(
|
|
normalized,
|
|
layer.agedPipeFilterTemplate ?? layer.flterSql,
|
|
);
|
|
if (cqlFilters.length > 0) {
|
|
queryParams.append("cqlFiter", cqlFilters.join(" and "));
|
|
}
|
|
|
|
const response = await getData(`v1/fclts?${queryParams.toString()}`);
|
|
const rows = Array.isArray(response?.data) ? response.data : [];
|
|
|
|
return {
|
|
rows: rows.map((item: Record<string, unknown>, index: number) =>
|
|
toDisplayPipeRow(item, index, page, ctpv, sgg, facilityCodes),
|
|
),
|
|
total: Number(response?.count ?? 0),
|
|
};
|
|
},
|
|
[ctpv, sgg, treeData, facilityCodes],
|
|
);
|
|
|
|
const loadAgedPipeList = useCallback(
|
|
async (page = 1, filters = appliedAgedPipeFilters) => {
|
|
setIsAgedPipeListLoading(true);
|
|
try {
|
|
const response = await fetchAgedPipeRows(
|
|
page,
|
|
filters,
|
|
AGED_PIPE_PAGE_SIZE,
|
|
);
|
|
setAgedPipeRows(response.rows);
|
|
setAgedPipeTotalCount(response.total);
|
|
setAgedPipeCurrentPage(page);
|
|
} catch (error) {
|
|
console.error("노후관로 목록 조회 중 오류:", error);
|
|
setAgedPipeRows([]);
|
|
setAgedPipeTotalCount(0);
|
|
} finally {
|
|
setIsAgedPipeListLoading(false);
|
|
}
|
|
},
|
|
[appliedAgedPipeFilters, fetchAgedPipeRows],
|
|
);
|
|
|
|
const syncAgedPipePopupFilters = useCallback((filters: AgedPipeFilters) => {
|
|
const popup = agedPipePopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
agedPipePopupWindowRef.current = null;
|
|
return;
|
|
}
|
|
|
|
popup.postMessage(
|
|
{
|
|
type: AGED_PIPE_FILTER_SYNC_MESSAGE,
|
|
filters: normalizeAgedPipeFilters(filters),
|
|
},
|
|
window.location.origin,
|
|
);
|
|
}, []);
|
|
|
|
const handleApplyAgedPipeFilters = useCallback(
|
|
(filters = draftAgedPipeFilters) => {
|
|
if (!validateElapsedYears(Number(filters.elapsedYears))) {
|
|
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
const normalized = normalizeAgedPipeFilters(filters);
|
|
setDraftAgedPipeFilters(normalized);
|
|
setAppliedAgedPipeFilters(normalized);
|
|
syncAgedPipePopupFilters(normalized);
|
|
|
|
if (isAgedPipeListOpen) {
|
|
loadAgedPipeList(1, normalized);
|
|
}
|
|
},
|
|
[
|
|
draftAgedPipeFilters,
|
|
isAgedPipeListOpen,
|
|
loadAgedPipeList,
|
|
syncAgedPipePopupFilters,
|
|
],
|
|
);
|
|
|
|
const openAgedPipePopupWindow = useCallback(() => {
|
|
const popup = openWindowPopup("agedPipePopup", {
|
|
elapsedYears: String(appliedAgedPipeFilters.elapsedYears),
|
|
ctpvCd: appliedAgedPipeFilters.ctpvCd,
|
|
sggCd: appliedAgedPipeFilters.sggCd,
|
|
});
|
|
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
return null;
|
|
}
|
|
|
|
agedPipePopupWindowRef.current = popup;
|
|
setIsAgedPipeListOpen(true);
|
|
setIsAgedPipeListMinimized(false);
|
|
return popup;
|
|
}, [appliedAgedPipeFilters]);
|
|
|
|
const openAgedPipeList = useCallback(() => {
|
|
openAgedPipePopupWindow();
|
|
}, [openAgedPipePopupWindow]);
|
|
const { removeLayer: removeExmnResultLayer, messages: exmnLayerMessages } =
|
|
useExmnResultMap({
|
|
gisApp: GisApp,
|
|
mapRef: gisMaps,
|
|
ready: mapReady,
|
|
treeData,
|
|
entry: exmnMapEntryContext,
|
|
pipeChecked: isExmnPplChecked,
|
|
manholeChecked: isExmnMnhlChecked,
|
|
pipeEnabled: isExmnPplSettingEnabled,
|
|
manholeEnabled: isExmnMnhlSettingEnabled,
|
|
pipeFilters: exmnPplFilters,
|
|
manholeFilters: exmnMnhlFilters,
|
|
pipeStyle: createExmnPipeStyle,
|
|
manholeStyle: createExmnManholeStyle,
|
|
});
|
|
const {
|
|
removeSinkholeLayer,
|
|
clearSinkholeSelectionLayer,
|
|
clearSinkholeFocusedPipeLayer,
|
|
ensureSinkholeFocusedPipeLayer,
|
|
renderSelectedSinkhole,
|
|
focusSinkholeOnMap,
|
|
} = useSinkholeLayers({
|
|
gisMaps,
|
|
ctpv,
|
|
sgg,
|
|
appliedSinkholeFilters,
|
|
mapReady,
|
|
isSinkholeChecked,
|
|
sinkholeScope,
|
|
sinkholeSelectionVersion,
|
|
selectedSinkholeDetail,
|
|
sinkholeRadiusMeters,
|
|
});
|
|
|
|
// 우측 상세 탭 전환 시 패널의 활성 대상과 지도 선택/강조 상태를 맞춘다.
|
|
const syncRightPanelFeatureSelectionOnMap = useCallback(
|
|
(data: any) => {
|
|
const select = GisApp.Module.select as any;
|
|
select?.resetSelectedFeature?.();
|
|
|
|
const layerTypeName = getSelectedFeatureLayerTypeName(data);
|
|
if (layerTypeName === SINKHOLE_LAYER_TYPE) {
|
|
clearSinkholeFocusedPipeLayer();
|
|
|
|
return;
|
|
}
|
|
|
|
// 연계 심선/위선 조회는 공용 search-layer를 교체한다. 이때 탭에 보관된
|
|
// 기존 침수흔적 feature는 더 이상 지도 레이어에 속하지 않으므로, 단순히
|
|
// Select 컬렉션에 다시 넣어도 강조가 렌더링되지 않는다. 탭으로 돌아올
|
|
// 때는 FID로 search-layer를 복원한 뒤 로드된 feature를 선택하게 한다.
|
|
if (
|
|
layerTypeName === FLDNG_HSTRY_LAYER_TYPE ||
|
|
layerTypeName === "tb_ms_fldng_dpth_map"
|
|
) {
|
|
clearSinkholeFocusedPipeLayer();
|
|
const featureIdentifier = getRightPanelFeatureIdentifier(data).replace(
|
|
new RegExp(`^${layerTypeName}\\.`),
|
|
"",
|
|
);
|
|
if (featureIdentifier) {
|
|
const core = GisApp.Module.layer as any;
|
|
void core?.showSearchResultsFromGeoJSON?.(
|
|
layerTypeName,
|
|
`${layerTypeName}.${featureIdentifier}`,
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (!data || typeof data.getGeometry !== "function") {
|
|
clearSinkholeFocusedPipeLayer();
|
|
|
|
return;
|
|
}
|
|
|
|
if (layerTypeName === "tb_ms_swage_ppl") {
|
|
const source = ensureSinkholeFocusedPipeLayer();
|
|
if (!source) return;
|
|
|
|
source.clear();
|
|
const focusedFeature =
|
|
typeof data.clone === "function" ? data.clone() : data;
|
|
if (
|
|
typeof focusedFeature.setId === "function" &&
|
|
typeof data.getId === "function"
|
|
) {
|
|
focusedFeature.setId(data.getId());
|
|
}
|
|
focusedFeature.setStyle(createSinkholeFocusedPipeStyle());
|
|
source.addFeature(focusedFeature);
|
|
select?.selectFeature?.(focusedFeature, true);
|
|
return;
|
|
}
|
|
|
|
clearSinkholeFocusedPipeLayer();
|
|
|
|
select?.selectFeature?.(data, true);
|
|
},
|
|
[clearSinkholeFocusedPipeLayer, ensureSinkholeFocusedPipeLayer],
|
|
);
|
|
const {
|
|
fldngHstryAreaLayerRef,
|
|
fldngHstryDepthLayerRef,
|
|
clearFldngHstryLayers,
|
|
renderSelectedFldngHstry,
|
|
focusFldngHstryOnMap,
|
|
} = useFloodHistorySelection({ mapReady, gisMaps, selectedFldngHstryDetail });
|
|
|
|
const clearFldngHstrySelection = useCallback(() => {
|
|
setSelectedFldngHstryDetail(null);
|
|
setFldngHstryDetailPopupPosition(null);
|
|
clearFldngHstryLayers();
|
|
}, [clearFldngHstryLayers]);
|
|
|
|
const closeFldngHstryDetail = useCallback(() => {
|
|
setFldngHstryDetailPopupPosition(null);
|
|
}, []);
|
|
|
|
const handleFldngHstryRowSelect = useCallback(
|
|
(detail: FldngHstryItem) => {
|
|
setSelectedFldngHstryDetail(detail);
|
|
renderSelectedFldngHstry(detail);
|
|
focusFldngHstryOnMap(detail);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[focusFldngHstryOnMap, renderSelectedFldngHstry, setIsSearchModalOpen],
|
|
);
|
|
|
|
const openFldngHstryPopupWindow = useCallback(() => {
|
|
const popup = openWindowPopup(FLDNG_HSTRY_POPUP_KEY as WindowPopupKey);
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
return null;
|
|
}
|
|
return popup;
|
|
}, []);
|
|
|
|
const closeSinkholeDetail = useCallback(() => {
|
|
setSelectedSinkholeDetail(null);
|
|
setSelectedSinkholeNearbyPipes([]);
|
|
clearSinkholeSelectionLayer();
|
|
clearSinkholeFocusedPipeLayer();
|
|
|
|
if (getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE) {
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
}
|
|
}, [clearSinkholeFocusedPipeLayer, clearSinkholeSelectionLayer, featureData]);
|
|
|
|
const loadSinkholeDetail = useCallback(
|
|
async (
|
|
acdntNo: string,
|
|
focusMap = true,
|
|
fallbackPoint?: { x: number; y: number } | null,
|
|
fallbackDetail?: Partial<SinkholeDetail> | null,
|
|
) => {
|
|
if (!acdntNo) return;
|
|
|
|
try {
|
|
const hasFallbackCoordinate =
|
|
(fallbackDetail?.x != null && fallbackDetail?.y != null) ||
|
|
(fallbackPoint != null &&
|
|
Number.isFinite(fallbackPoint.x) &&
|
|
Number.isFinite(fallbackPoint.y));
|
|
const fallbackCoordStatus = String(
|
|
fallbackDetail?.coordStatus ?? fallbackDetail?.geocodeStatus ?? "",
|
|
)
|
|
.trim()
|
|
.toUpperCase();
|
|
|
|
if (
|
|
fallbackDetail &&
|
|
!hasFallbackCoordinate &&
|
|
fallbackCoordStatus &&
|
|
fallbackCoordStatus !== "SUCCESS"
|
|
) {
|
|
const noCoordDetail: SinkholeDetail = {
|
|
acdntNo,
|
|
ctpvNm: String(fallbackDetail.ctpvNm ?? ""),
|
|
sggNm: String(fallbackDetail.sggNm ?? ""),
|
|
emdNm: String(fallbackDetail.emdNm ?? ""),
|
|
daddr: String(fallbackDetail.daddr ?? ""),
|
|
fullAddress: String(
|
|
fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? "",
|
|
),
|
|
ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""),
|
|
restoreStatus: String(fallbackDetail.restoreStatus ?? ""),
|
|
restoreCompletedYmd: String(
|
|
fallbackDetail.restoreCompletedYmd ?? "",
|
|
),
|
|
coordStatus: String(
|
|
fallbackDetail.coordStatus ?? fallbackDetail.geocodeStatus ?? "",
|
|
),
|
|
geocodeStatus: String(
|
|
fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? "",
|
|
),
|
|
geocodeSource: String(fallbackDetail.geocodeSource ?? ""),
|
|
geocodeErrorMessage: String(
|
|
fallbackDetail.geocodeErrorMessage ?? "",
|
|
),
|
|
latitude: fallbackDetail.latitude ?? null,
|
|
longitude: fallbackDetail.longitude ?? null,
|
|
x: fallbackDetail.x ?? null,
|
|
y: fallbackDetail.y ?? null,
|
|
sizeWidth: fallbackDetail.sizeWidth ?? null,
|
|
sizeLength: fallbackDetail.sizeLength ?? null,
|
|
sizeDepth: fallbackDetail.sizeDepth ?? null,
|
|
detailCause: fallbackDetail.detailCause ?? null,
|
|
deathCount: fallbackDetail.deathCount ?? null,
|
|
injuryCount: fallbackDetail.injuryCount ?? null,
|
|
vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null,
|
|
};
|
|
|
|
setSelectedSinkholeDetail(noCoordDetail);
|
|
setSelectedSinkholeNearbyPipes([]);
|
|
renderSelectedSinkhole(null, sinkholeRadiusMeters);
|
|
setFeatureData({
|
|
lyrTypeNm: SINKHOLE_LAYER_TYPE,
|
|
acdntNo: noCoordDetail.acdntNo,
|
|
});
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
let detail: SinkholeDetail;
|
|
try {
|
|
const detailResponse = await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}`,
|
|
);
|
|
detail = (detailResponse?.data ?? detailResponse) as SinkholeDetail;
|
|
} catch (detailError) {
|
|
if (!fallbackDetail) {
|
|
throw detailError;
|
|
}
|
|
console.warn(
|
|
"지반침하 상세 API 실패, 목록 데이터로 대체합니다.",
|
|
detailError,
|
|
);
|
|
detail = {
|
|
acdntNo,
|
|
ctpvNm: String(fallbackDetail.ctpvNm ?? ""),
|
|
sggNm: String(fallbackDetail.sggNm ?? ""),
|
|
emdNm: String(fallbackDetail.emdNm ?? ""),
|
|
daddr: String(fallbackDetail.daddr ?? ""),
|
|
fullAddress: String(
|
|
fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? "",
|
|
),
|
|
ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""),
|
|
restoreStatus: String(fallbackDetail.restoreStatus ?? ""),
|
|
restoreCompletedYmd: String(
|
|
fallbackDetail.restoreCompletedYmd ?? "",
|
|
),
|
|
coordStatus: String(fallbackDetail.coordStatus ?? ""),
|
|
geocodeStatus: String(
|
|
fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? "",
|
|
),
|
|
geocodeSource: String(fallbackDetail.geocodeSource ?? ""),
|
|
geocodeErrorMessage: String(
|
|
fallbackDetail.geocodeErrorMessage ?? "",
|
|
),
|
|
latitude: fallbackDetail.latitude ?? null,
|
|
longitude: fallbackDetail.longitude ?? null,
|
|
x: fallbackDetail.x ?? null,
|
|
y: fallbackDetail.y ?? null,
|
|
sizeWidth: fallbackDetail.sizeWidth ?? null,
|
|
sizeLength: fallbackDetail.sizeLength ?? null,
|
|
sizeDepth: fallbackDetail.sizeDepth ?? null,
|
|
detailCause: fallbackDetail.detailCause ?? null,
|
|
deathCount: fallbackDetail.deathCount ?? null,
|
|
injuryCount: fallbackDetail.injuryCount ?? null,
|
|
vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null,
|
|
};
|
|
}
|
|
const resolvedPoint =
|
|
detail.x != null && detail.y != null
|
|
? { x: detail.x, y: detail.y }
|
|
: fallbackPoint &&
|
|
Number.isFinite(fallbackPoint.x) &&
|
|
Number.isFinite(fallbackPoint.y)
|
|
? fallbackPoint
|
|
: null;
|
|
|
|
let nearbyPipes: SinkholeNearbyPipe[] = [];
|
|
try {
|
|
const nearbyRawResponse = resolvedPoint
|
|
? await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${resolvedPoint.x}&y=${resolvedPoint.y}&radiusMeters=${sinkholeRadiusMeters}`,
|
|
)
|
|
: await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}/nearbyPipes?radiusMeters=${sinkholeRadiusMeters}`,
|
|
);
|
|
const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse;
|
|
nearbyPipes = Array.isArray(nearbyResponse?.items)
|
|
? (nearbyResponse.items as SinkholeNearbyPipe[])
|
|
: [];
|
|
} catch (nearbyError) {
|
|
console.warn(
|
|
"지반침하 반경 내 관로 조회 실패, 상세는 계속 표시합니다.",
|
|
nearbyError,
|
|
);
|
|
}
|
|
const normalizedDetail = resolvedPoint
|
|
? {
|
|
...detail,
|
|
x: resolvedPoint.x,
|
|
y: resolvedPoint.y,
|
|
coordStatus: "SUCCESS",
|
|
}
|
|
: detail;
|
|
|
|
setSelectedSinkholeDetail(normalizedDetail);
|
|
setSelectedSinkholeNearbyPipes(nearbyPipes);
|
|
renderSelectedSinkhole(normalizedDetail, sinkholeRadiusMeters);
|
|
setFeatureData({
|
|
lyrTypeNm: SINKHOLE_LAYER_TYPE,
|
|
acdntNo: normalizedDetail.acdntNo,
|
|
});
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
|
|
if (
|
|
focusMap &&
|
|
normalizedDetail.x != null &&
|
|
normalizedDetail.y != null
|
|
) {
|
|
focusSinkholeOnMap(normalizedDetail, sinkholeRadiusMeters);
|
|
}
|
|
} catch (error) {
|
|
console.error("지반침하 상세 조회 중 오류:", error);
|
|
alert("지반침하 상세 정보를 불러오지 못했습니다.");
|
|
}
|
|
},
|
|
[
|
|
focusSinkholeOnMap,
|
|
renderSelectedSinkhole,
|
|
setIsSearchModalOpen,
|
|
sinkholeRadiusMeters,
|
|
],
|
|
);
|
|
|
|
const applySinkholeRadius = useCallback(
|
|
async (radiusMeters: number) => {
|
|
setSinkholeRadiusMeters(radiusMeters);
|
|
if (!selectedSinkholeDetail?.acdntNo) {
|
|
return;
|
|
}
|
|
|
|
const selectedPoint =
|
|
selectedSinkholeDetail.x != null && selectedSinkholeDetail.y != null
|
|
? {
|
|
x: Number(selectedSinkholeDetail.x),
|
|
y: Number(selectedSinkholeDetail.y),
|
|
}
|
|
: null;
|
|
|
|
try {
|
|
const nearbyRawResponse =
|
|
selectedPoint &&
|
|
Number.isFinite(selectedPoint.x) &&
|
|
Number.isFinite(selectedPoint.y)
|
|
? await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${selectedPoint.x}&y=${selectedPoint.y}&radiusMeters=${radiusMeters}`,
|
|
)
|
|
: await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(selectedSinkholeDetail.acdntNo)}/nearbyPipes?radiusMeters=${radiusMeters}`,
|
|
);
|
|
const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse;
|
|
setSelectedSinkholeNearbyPipes(
|
|
Array.isArray(nearbyResponse?.items)
|
|
? (nearbyResponse.items as SinkholeNearbyPipe[])
|
|
: [],
|
|
);
|
|
renderSelectedSinkhole(selectedSinkholeDetail, radiusMeters);
|
|
focusSinkholeOnMap(selectedSinkholeDetail, radiusMeters);
|
|
} catch (error) {
|
|
console.error("지반침하 반경 적용 중 오류:", error);
|
|
alert("반경 내 하수관로 정보를 다시 조회하지 못했습니다.");
|
|
throw error;
|
|
}
|
|
},
|
|
[focusSinkholeOnMap, renderSelectedSinkhole, selectedSinkholeDetail],
|
|
);
|
|
|
|
const focusSinkholeNearbyPipe = useCallback(
|
|
async (pipe: SinkholeNearbyPipe) => {
|
|
const fid = String(pipe?.fid ?? "").trim();
|
|
if (!fid) {
|
|
alert("표시할 관로 식별값이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const activeSinkholeTab = rightPanelTabs.find(
|
|
(tab) =>
|
|
tab.id === activeRightPanelTabId && tab.category === "sinkhole",
|
|
);
|
|
pipeParentTabIdRef.current = activeSinkholeTab?.id ?? null;
|
|
|
|
try {
|
|
const layerTypeName = String(pipe?.lyrTypeNm ?? "tb_ms_swage_ppl");
|
|
const layerModule = GisApp.Module.layer as {
|
|
storageNm?: string;
|
|
} | null;
|
|
const storageNm = String(layerModule?.storageNm ?? "").trim();
|
|
if (!storageNm)
|
|
throw new Error("GeoServer 저장소 이름을 확인할 수 없습니다.");
|
|
const params = new URLSearchParams({
|
|
SERVICE: "WFS",
|
|
VERSION: "1.1.0",
|
|
REQUEST: "GetFeature",
|
|
TYPENAME: `${storageNm}${layerTypeName}`,
|
|
SRSNAME: GisApp.TO_PROJ,
|
|
OUTPUTFORMAT: "application/json",
|
|
FEATUREID: fid,
|
|
});
|
|
const response = await getRawJson(
|
|
`${GisApp.GEOSERVER_URL}?${params.toString()}`,
|
|
);
|
|
const featureCollection = getFeatureCollection(response);
|
|
const features = new GeoJSON().readFeatures(featureCollection, {
|
|
dataProjection: GisApp.TO_PROJ,
|
|
featureProjection: GisApp.TO_PROJ,
|
|
});
|
|
|
|
const firstFeature = features[0];
|
|
if (firstFeature) {
|
|
syncRightPanelFeatureSelectionOnMap(firstFeature);
|
|
setFeatureData(firstFeature);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
}
|
|
} catch (error) {
|
|
console.error("지반침하 반경 내 관로 지도 표시 실패:", error);
|
|
alert("선택한 관로를 지도에서 표시하지 못했습니다.");
|
|
}
|
|
},
|
|
[
|
|
activeRightPanelTabId,
|
|
rightPanelTabs,
|
|
setIsSearchModalOpen,
|
|
syncRightPanelFeatureSelectionOnMap,
|
|
],
|
|
);
|
|
|
|
const buildRightPanelTab = useCallback(
|
|
(data: any): RightPanelTab | null => {
|
|
if (!data) return null;
|
|
|
|
const layerTypeName = getSelectedFeatureLayerTypeName(data);
|
|
const now = Date.now();
|
|
|
|
if (layerTypeName === SINKHOLE_LAYER_TYPE) {
|
|
const detail = selectedSinkholeDetail;
|
|
const acdntNo = String(
|
|
detail?.acdntNo ?? getSinkholeAccidentId(data) ?? "",
|
|
).trim();
|
|
if (!acdntNo) return null;
|
|
|
|
return {
|
|
id: `sinkhole:${acdntNo}`,
|
|
kind: "sinkhole",
|
|
category: "sinkhole",
|
|
displayLabel: `지반침하(${acdntNo})`,
|
|
parentTabId: null,
|
|
featureData: {
|
|
lyrTypeNm: SINKHOLE_LAYER_TYPE,
|
|
acdntNo,
|
|
},
|
|
sinkholeDetail: detail,
|
|
nearbyPipes: selectedSinkholeNearbyPipes,
|
|
lastActiveAt: now,
|
|
};
|
|
}
|
|
|
|
if (gisEditDetailChangeId) {
|
|
return {
|
|
id: `gisEdit:${gisEditDetailChangeId}`,
|
|
kind: "gisEdit",
|
|
category: "gisEdit",
|
|
displayLabel: "관로편집",
|
|
parentTabId: null,
|
|
featureData: data,
|
|
gisEditChangeId: gisEditDetailChangeId,
|
|
lastActiveAt: now,
|
|
};
|
|
}
|
|
|
|
const category = getRightPanelTabCategory(data);
|
|
const surveyJobTypeValue = getRightPanelFeatureProperty(data, [
|
|
"jobType",
|
|
]);
|
|
const surveyJobType =
|
|
surveyJobTypeValue === "PPL" || surveyJobTypeValue === "MNHL"
|
|
? surveyJobTypeValue
|
|
: undefined;
|
|
const featureId =
|
|
getRightPanelFeatureIdentifier(data) ||
|
|
`${layerTypeName || "feature"}:${now}`;
|
|
// Direct map selections accumulate per pipe; only a pipe with a sinkhole parent replaces the linked slot.
|
|
const pipeParentTabId =
|
|
category === "pipe" ? pipeParentTabIdRef.current : null;
|
|
return {
|
|
id:
|
|
category === "exmnSurvey"
|
|
? `exmnSurvey:${getRightPanelFeatureProperty(data, ["exmnFacilityId"])}:${getRightPanelFeatureProperty(data, ["manholePosition"])}`
|
|
: category === "pipe" && pipeParentTabId
|
|
? RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID
|
|
: `feature:${layerTypeName || "unknown"}:${featureId}`,
|
|
kind: "feature",
|
|
category,
|
|
displayLabel: getRightPanelFeatureDisplayLabel(data, category),
|
|
surveyJobType,
|
|
parentTabId: pipeParentTabId,
|
|
featureData: data,
|
|
lastActiveAt: now,
|
|
};
|
|
},
|
|
[
|
|
gisEditDetailChangeId,
|
|
selectedSinkholeDetail,
|
|
selectedSinkholeNearbyPipes,
|
|
],
|
|
);
|
|
|
|
const upsertRightPanelTab = useCallback((tab: RightPanelTab) => {
|
|
setRightPanelTabs((currentTabs) => {
|
|
const nextTab = { ...tab, lastActiveAt: Date.now() };
|
|
const existingIndex = currentTabs.findIndex((item) => item.id === tab.id);
|
|
const mergedTabs =
|
|
existingIndex < 0
|
|
? [...currentTabs, nextTab]
|
|
: currentTabs.map((item, index) =>
|
|
index === existingIndex ? { ...item, ...nextTab } : item,
|
|
);
|
|
|
|
if (mergedTabs.length <= RIGHT_PANEL_TAB_LIMIT) return mergedTabs;
|
|
|
|
const overflowCandidate = findRightPanelTabOverflowCandidate(
|
|
mergedTabs,
|
|
nextTab.id,
|
|
);
|
|
if (!overflowCandidate) return mergedTabs.slice(-RIGHT_PANEL_TAB_LIMIT);
|
|
|
|
return removeRightPanelTabGroup(mergedTabs, overflowCandidate.id);
|
|
});
|
|
setActiveRightPanelTabId(tab.id);
|
|
}, []);
|
|
|
|
const activateRightPanelTab = useCallback(
|
|
(tabId: string) => {
|
|
const targetTab = rightPanelTabs.find((tab) => tab.id === tabId);
|
|
if (!targetTab) return;
|
|
|
|
setRightPanelTabs((currentTabs) =>
|
|
currentTabs.map((tab) =>
|
|
tab.id === tabId ? { ...tab, lastActiveAt: Date.now() } : tab,
|
|
),
|
|
);
|
|
setActiveRightPanelTabId(tabId);
|
|
if (targetTab.category === "pipe") {
|
|
pipeParentTabIdRef.current = targetTab.parentTabId ?? null;
|
|
}
|
|
|
|
if (targetTab.kind === "sinkhole") {
|
|
const detail = targetTab.sinkholeDetail ?? null;
|
|
setSelectedSinkholeDetail(detail);
|
|
setSelectedSinkholeNearbyPipes(targetTab.nearbyPipes ?? []);
|
|
setGisEditDetailChangeId(null);
|
|
syncRightPanelFeatureSelectionOnMap(targetTab.featureData);
|
|
if (detail) {
|
|
renderSelectedSinkhole(detail, sinkholeRadiusMeters);
|
|
}
|
|
} else if (targetTab.kind === "gisEdit") {
|
|
setGisEditDetailChangeId(targetTab.gisEditChangeId ?? null);
|
|
} else {
|
|
setGisEditDetailChangeId(null);
|
|
syncRightPanelFeatureSelectionOnMap(targetTab.featureData);
|
|
}
|
|
|
|
setFeatureData(targetTab.featureData);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[
|
|
renderSelectedSinkhole,
|
|
rightPanelTabs,
|
|
setIsSearchModalOpen,
|
|
sinkholeRadiusMeters,
|
|
syncRightPanelFeatureSelectionOnMap,
|
|
],
|
|
);
|
|
|
|
const closeRightPanelTab = useCallback(
|
|
(tabId: string | null) => {
|
|
if (!tabId) return;
|
|
const closingTab = rightPanelTabs.find((tab) => tab.id === tabId);
|
|
const remainingTabs = removeRightPanelTabGroup(rightPanelTabs, tabId);
|
|
const closingLayerTypeName = getSelectedFeatureLayerTypeName(
|
|
closingTab?.featureData,
|
|
);
|
|
|
|
// 침수흔적 연계 조회로 만든 공용 search-layer는 선택 해제만으로
|
|
// 제거되지 않는다. 마지막 탭을 닫는 경우와 다른 일반 feature 탭으로
|
|
// 전환되는 경우 모두 잔상으로 남지 않도록 여기서 명시적으로 제거한다.
|
|
if (
|
|
closingLayerTypeName === FLDNG_HSTRY_LAYER_TYPE ||
|
|
closingLayerTypeName === "tb_ms_fldng_dpth_map"
|
|
) {
|
|
const layerModule = GisApp.Module.layer as any;
|
|
if (layerModule?.searchLayer) {
|
|
layerModule.clearSearchLayer?.();
|
|
}
|
|
}
|
|
|
|
if (closingTab?.kind === "sinkhole") {
|
|
setSelectedSinkholeDetail(null);
|
|
setSelectedSinkholeNearbyPipes([]);
|
|
clearSinkholeSelectionLayer();
|
|
}
|
|
if (closingTab?.kind === "feature") {
|
|
const select = GisApp.Module.select as any;
|
|
select?.resetSelectedFeature?.();
|
|
clearSinkholeFocusedPipeLayer();
|
|
}
|
|
if (closingTab?.kind === "gisEdit") {
|
|
setGisEditDetailChangeId(null);
|
|
}
|
|
if (
|
|
closingTab?.category === "pipe" ||
|
|
pipeParentTabIdRef.current === tabId
|
|
) {
|
|
pipeParentTabIdRef.current = null;
|
|
}
|
|
|
|
setRightPanelTabs(remainingTabs);
|
|
|
|
if (remainingTabs.length === 0) {
|
|
setActiveRightPanelTabId(null);
|
|
pipeParentTabIdRef.current = null;
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
const parentFallbackTab =
|
|
closingTab?.category === "pipe" && closingTab.parentTabId
|
|
? remainingTabs.find((tab) => tab.id === closingTab.parentTabId)
|
|
: null;
|
|
const fallbackTab =
|
|
parentFallbackTab ??
|
|
[...remainingTabs].sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0]!;
|
|
activateRightPanelTab(fallbackTab.id);
|
|
},
|
|
[
|
|
activateRightPanelTab,
|
|
clearSinkholeFocusedPipeLayer,
|
|
clearSinkholeSelectionLayer,
|
|
rightPanelTabs,
|
|
],
|
|
);
|
|
|
|
// 선택 피처가 없는 지도 본체 클릭을 기존 지반침하 상세 닫기 흐름과 연결한다.
|
|
const handleSinkholeMapBlankClick = useCallback(() => {
|
|
if (gisEdit.isEditMode || !selectedSinkholeDetail?.acdntNo) return;
|
|
|
|
const sinkholeTabId = `sinkhole:${selectedSinkholeDetail.acdntNo}`;
|
|
const hasSinkholeTab = rightPanelTabs.some(
|
|
(tab) => tab.id === sinkholeTabId,
|
|
);
|
|
|
|
if (hasSinkholeTab && activeRightPanelTabId === sinkholeTabId) {
|
|
closeRightPanelTab(sinkholeTabId);
|
|
} else {
|
|
// 비활성 지반침하 탭은 현재 활성 탭을 바꾸지 않고 종속 관로와 함께 제거한다.
|
|
setRightPanelTabs((currentTabs) =>
|
|
removeRightPanelTabGroup(currentTabs, sinkholeTabId),
|
|
);
|
|
setSelectedSinkholeDetail(null);
|
|
setSelectedSinkholeNearbyPipes([]);
|
|
clearSinkholeSelectionLayer();
|
|
if (pipeParentTabIdRef.current === sinkholeTabId) {
|
|
pipeParentTabIdRef.current = null;
|
|
}
|
|
if (
|
|
!hasSinkholeTab &&
|
|
getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE
|
|
) {
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
const select = GisApp.Module.select as any;
|
|
select?.resetSelectedFeature?.();
|
|
clearSinkholeFocusedPipeLayer();
|
|
}, [
|
|
activeRightPanelTabId,
|
|
clearSinkholeFocusedPipeLayer,
|
|
clearSinkholeSelectionLayer,
|
|
closeRightPanelTab,
|
|
featureData,
|
|
gisEdit.isEditMode,
|
|
rightPanelTabs,
|
|
selectedSinkholeDetail,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !featureData) return;
|
|
const nextTab = buildRightPanelTab(featureData);
|
|
if (nextTab) upsertRightPanelTab(nextTab);
|
|
}, [buildRightPanelTab, featureData, isOpen, upsertRightPanelTab]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) return;
|
|
pipeParentTabIdRef.current = null;
|
|
setRightPanelTabs([]);
|
|
setActiveRightPanelTabId(null);
|
|
}, [isOpen]);
|
|
|
|
const openSinkholePopupWindow = useCallback(() => {
|
|
const effectiveFilters = normalizeSinkholeFilters(
|
|
appliedSinkholeFilters,
|
|
sinkholeScope,
|
|
);
|
|
const popup = openWindowPopup(
|
|
SINKHOLE_POPUP_KEY as WindowPopupKey,
|
|
effectiveFilters as Record<string, string>,
|
|
);
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
return null;
|
|
}
|
|
|
|
setIsSinkholePopupFilterActive(true);
|
|
setAppliedSinkholeFilters(effectiveFilters);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
|
|
if (sinkholePopupCloseWatcherRef.current) {
|
|
window.clearInterval(sinkholePopupCloseWatcherRef.current);
|
|
sinkholePopupCloseWatcherRef.current = null;
|
|
}
|
|
sinkholePopupWindowRef.current = popup;
|
|
sinkholePopupCloseWatcherRef.current = window.setInterval(() => {
|
|
if (!popup.closed) return;
|
|
if (sinkholePopupCloseWatcherRef.current) {
|
|
window.clearInterval(sinkholePopupCloseWatcherRef.current);
|
|
sinkholePopupCloseWatcherRef.current = null;
|
|
}
|
|
sinkholePopupWindowRef.current = null;
|
|
setIsSinkholePopupFilterActive(false);
|
|
setAppliedSinkholeFilters(
|
|
normalizeSinkholeFilters(
|
|
buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope),
|
|
sinkholeScope,
|
|
),
|
|
);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
}, 300);
|
|
return popup;
|
|
}, [appliedSinkholeFilters, sinkholeScope, sinkholeYearRange]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (sinkholePopupCloseWatcherRef.current) {
|
|
window.clearInterval(sinkholePopupCloseWatcherRef.current);
|
|
sinkholePopupCloseWatcherRef.current = null;
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleAgedPipeRowDoubleClick = useCallback(
|
|
async (row: AgedPipeRow) => {
|
|
try {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = true;
|
|
const core = GisApp.Module.layer as any;
|
|
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
|
|
} catch (error) {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
|
console.error("노후관로 위치 이동 중 오류:", error);
|
|
}
|
|
|
|
setFeatureData(row as any);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[setIsSearchModalOpen],
|
|
);
|
|
|
|
const handlePipeAnalysisRowSelect = useCallback(
|
|
async (row: PipeAnalysisDetailRow) => {
|
|
// 관로현황 팝업에서 선택한 경우 메시지를 받은 최상위 지도창을 먼저 앞으로 가져온다.
|
|
window.focus();
|
|
try {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = true;
|
|
const core = GisApp.Module.layer as any;
|
|
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
|
|
} catch (error) {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
|
console.error("관로 위치 이동 중 오류:", error);
|
|
}
|
|
|
|
setFeatureData(row as any);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
// 지도 이동과 상세 패널 렌더링 이후에도 최상위 창 포커스를 한 번 더 요청한다.
|
|
window.focus();
|
|
},
|
|
[setIsSearchModalOpen],
|
|
);
|
|
|
|
const handleAgedPipeDownload = useCallback(async () => {
|
|
if (agedPipeTotalCount === 0) {
|
|
alert("다운로드할 데이터가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsAgedPipeListLoading(true);
|
|
setAgedPipeDownloadProgressText("Excel 파일을 준비하고 있습니다.");
|
|
await downloadAgedPipeExcel(
|
|
appliedAgedPipeFilters,
|
|
(job) => {
|
|
if (job.status === "QUEUED") {
|
|
setAgedPipeDownloadProgressText(
|
|
"Excel 다운로드 작업이 대기 중입니다.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (job.status === "RUNNING") {
|
|
setAgedPipeDownloadProgressText(
|
|
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
|
|
);
|
|
}
|
|
},
|
|
agedPipeTotalCount,
|
|
);
|
|
setAgedPipeDownloadProgressText("");
|
|
} catch (error) {
|
|
console.error("노후관로 목록 다운로드 중 오류:", error);
|
|
setAgedPipeDownloadProgressText("");
|
|
alert(
|
|
error instanceof Error
|
|
? error.message
|
|
: "노후관로 Excel 다운로드 중 오류가 발생했습니다.",
|
|
);
|
|
} finally {
|
|
setIsAgedPipeListLoading(false);
|
|
}
|
|
}, [agedPipeTotalCount, appliedAgedPipeFilters]);
|
|
|
|
useEffect(() => {
|
|
const handleAgedPipePopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
if (event.data?.type === AGED_PIPE_ROW_SELECT_MESSAGE) {
|
|
if (!event.data?.row) return;
|
|
handleAgedPipeRowDoubleClick(event.data.row as AgedPipeRow);
|
|
return;
|
|
}
|
|
|
|
if (event.data?.type === AGED_PIPE_POPUP_MINIMIZE_MESSAGE) {
|
|
agedPipePopupWindowRef.current = null;
|
|
if (event.data?.filters) {
|
|
const normalized = normalizeAgedPipeFilters(
|
|
event.data.filters as AgedPipeFilters,
|
|
);
|
|
setDraftAgedPipeFilters(normalized);
|
|
setAppliedAgedPipeFilters(normalized);
|
|
}
|
|
setAgedPipeTotalCount(Number(event.data?.total ?? agedPipeTotalCount));
|
|
setIsAgedPipeListMinimized(true);
|
|
return;
|
|
}
|
|
|
|
if (event.data?.type === PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE) {
|
|
if (!event.data?.row) return;
|
|
handlePipeAnalysisRowSelect(event.data.row as PipeAnalysisDetailRow);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleAgedPipePopupMessage);
|
|
return () =>
|
|
window.removeEventListener("message", handleAgedPipePopupMessage);
|
|
}, [
|
|
agedPipeTotalCount,
|
|
handleAgedPipeRowDoubleClick,
|
|
handlePipeAnalysisRowSelect,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const handleSinkholePopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
if (
|
|
event.data?.type === SINKHOLE_FILTER_SYNC_MESSAGE &&
|
|
event.data?.filters
|
|
) {
|
|
const normalized = normalizeSinkholeFilters(
|
|
event.data.filters as SinkholeFilters,
|
|
sinkholeScope,
|
|
);
|
|
setIsSinkholePopupFilterActive(true);
|
|
setAppliedSinkholeFilters(normalized);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
return;
|
|
}
|
|
|
|
if (event.data?.type === SINKHOLE_ROW_SELECT_MESSAGE && event.data?.row) {
|
|
const row = event.data.row as SinkholeListItem;
|
|
const fallbackPoint =
|
|
row.x != null && row.y != null
|
|
? { x: Number(row.x), y: Number(row.y) }
|
|
: null;
|
|
void loadSinkholeDetail(
|
|
String(row.acdntNo ?? ""),
|
|
true,
|
|
fallbackPoint,
|
|
row,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
event.data?.type === SINKHOLE_SYNC_COMPLETED_MESSAGE &&
|
|
event.data?.payload
|
|
) {
|
|
setLastSinkholeSyncSummary(event.data.payload as SinkholeSyncSummary);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleSinkholePopupMessage);
|
|
return () =>
|
|
window.removeEventListener("message", handleSinkholePopupMessage);
|
|
}, [loadSinkholeDetail, sinkholeScope]);
|
|
|
|
useEffect(() => {
|
|
const handleFldngHstryPopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
if (
|
|
event.data?.type !== FLDNG_HSTRY_ROW_SELECT_MESSAGE ||
|
|
!event.data?.row
|
|
)
|
|
return;
|
|
|
|
handleFldngHstryRowSelect(event.data.row as FldngHstryItem);
|
|
};
|
|
|
|
window.addEventListener("message", handleFldngHstryPopupMessage);
|
|
return () =>
|
|
window.removeEventListener("message", handleFldngHstryPopupMessage);
|
|
}, [handleFldngHstryRowSelect]);
|
|
|
|
// WaterDepth 표시 여부를 결정하는 함수
|
|
const handleWaterDepthVisibility = useCallback(
|
|
(checkedIds: string[], checkedNodesData: any[]) => {
|
|
const waterDepthLayers = checkedNodesData.filter((node) =>
|
|
WATER_DEPTH_LAYER_TYPES.includes(node.data?.lyrTyNm),
|
|
);
|
|
|
|
const hasWaterDepthLayer = waterDepthLayers.length > 0;
|
|
setIsWaterDepthLayerActive(hasWaterDepthLayer);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 체크된 레이어 변경 처리 함수
|
|
const handleCheckedLayersChange = useCallback(
|
|
(checkedIds: string[], checkedNodesData: any[]) => {
|
|
checkedNodesDataRef.current = checkedNodesData;
|
|
setCheckedLayerLegendVersion((version) => version + 1);
|
|
setSwagePrcsFcltLayerMode(getSwagePrcsFcltLayerMode(checkedNodesData));
|
|
|
|
updateFlowDirectionSelection(checkedNodesData);
|
|
|
|
handleWaterDepthVisibility(checkedIds, checkedNodesData);
|
|
const agedPipeChecked = checkedNodesData.some((node) =>
|
|
isAgedPipeLayerNode(node),
|
|
);
|
|
const waterDepthChecked = checkedNodesData.some((node) =>
|
|
WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")),
|
|
);
|
|
if (agedPipeChecked && !agedPipeLayerCheckedRef.current) {
|
|
setIsAgedPipeSettingVisible(true);
|
|
}
|
|
if (waterDepthChecked && !waterDepthLayerCheckedRef.current) {
|
|
setIsWaterDepthLegendVisible(true);
|
|
}
|
|
agedPipeLayerCheckedRef.current = agedPipeChecked;
|
|
waterDepthLayerCheckedRef.current = waterDepthChecked;
|
|
setIsAgedPipeChecked(agedPipeChecked);
|
|
const fldngHstryWtlvChecked = checkedNodesData.some((node) =>
|
|
isFldngHstryWtlvNode(node),
|
|
);
|
|
const checkedFldngHstryDepthNodes = checkedNodesData.filter((node) =>
|
|
isFldngHstryDepthNode(node),
|
|
);
|
|
const checkedFldngHstryDepthLayerKeys = Array.from(
|
|
new Set(
|
|
checkedFldngHstryDepthNodes
|
|
.map((node) => String(node?.data?.lyrSn ?? node?.id ?? "").trim())
|
|
.filter(Boolean),
|
|
),
|
|
).sort((left, right) => Number(left) - Number(right));
|
|
const fldngHstryDepthChecked = checkedFldngHstryDepthLayerKeys.length > 0;
|
|
const fldngHstryChecked = fldngHstryWtlvChecked || fldngHstryDepthChecked;
|
|
setIsFldngHstryWtlvChecked(fldngHstryWtlvChecked);
|
|
setIsFldngHstryDepthChecked(fldngHstryDepthChecked);
|
|
setCheckedFldngHstryDepthLayerKeys((currentKeys) =>
|
|
currentKeys.join(",") === checkedFldngHstryDepthLayerKeys.join(",")
|
|
? currentKeys
|
|
: checkedFldngHstryDepthLayerKeys,
|
|
);
|
|
setIsFldngHstryChecked(fldngHstryChecked);
|
|
if (fldngHstryChecked && !fldngHstryLayerCheckedRef.current) {
|
|
setIsFldngHstryYearSettingVisible(true);
|
|
}
|
|
fldngHstryLayerCheckedRef.current = fldngHstryChecked;
|
|
if (!fldngHstryChecked) {
|
|
clearFldngHstrySelection();
|
|
}
|
|
const sinkholeChecked = checkedNodesData.some((node) =>
|
|
isSinkholeLayerNode(node),
|
|
);
|
|
if (sinkholeChecked && !sinkholeLayerCheckedRef.current) {
|
|
setIsSinkholeSettingVisible(true);
|
|
}
|
|
sinkholeLayerCheckedRef.current = sinkholeChecked;
|
|
setIsSinkholeChecked(sinkholeChecked);
|
|
const exmnPplChecked = checkedNodesData.some((node) =>
|
|
isExmnResultPplNode(node),
|
|
);
|
|
const exmnMnhlChecked = checkedNodesData.some((node) =>
|
|
isExmnResultMnhlNode(node),
|
|
);
|
|
if (exmnPplChecked && !exmnPplLayerCheckedRef.current) {
|
|
setIsExmnPplSettingVisible(true);
|
|
}
|
|
if (exmnMnhlChecked && !exmnMnhlLayerCheckedRef.current) {
|
|
setIsExmnMnhlSettingVisible(true);
|
|
}
|
|
exmnPplLayerCheckedRef.current = exmnPplChecked;
|
|
exmnMnhlLayerCheckedRef.current = exmnMnhlChecked;
|
|
setIsExmnPplChecked(exmnPplChecked);
|
|
setIsExmnMnhlChecked(exmnMnhlChecked);
|
|
setActiveLayerSettingOrder((currentOrder) => {
|
|
const activeKeys = [
|
|
waterDepthChecked ? "waterDepthLegend" : null,
|
|
fldngHstryChecked ? "fldngHstry" : null,
|
|
sinkholeChecked ? "sinkhole" : null,
|
|
agedPipeChecked ? "agedPipe" : null,
|
|
exmnPplChecked ? "exmnPpl" : null,
|
|
exmnMnhlChecked ? "exmnMnhl" : null,
|
|
].filter((key): key is string => Boolean(key));
|
|
|
|
return [
|
|
...currentOrder.filter((key) => activeKeys.includes(key)),
|
|
...activeKeys.filter((key) => !currentOrder.includes(key)),
|
|
];
|
|
});
|
|
if (sinkholeChecked) {
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
}
|
|
if (!sinkholeChecked) {
|
|
removeSinkholeLayer();
|
|
closeSinkholeDetail();
|
|
}
|
|
if (!exmnPplChecked) {
|
|
removeExmnResultLayer("PPL");
|
|
}
|
|
if (!exmnMnhlChecked) {
|
|
removeExmnResultLayer("MNHL");
|
|
}
|
|
},
|
|
[
|
|
clearFldngHstrySelection,
|
|
closeSinkholeDetail,
|
|
handleWaterDepthVisibility,
|
|
removeExmnResultLayer,
|
|
updateFlowDirectionSelection,
|
|
removeSinkholeLayer,
|
|
],
|
|
);
|
|
|
|
const waterDepthLegendItems = useMemo(
|
|
() => extractWaterDepthLegendItems(checkedNodesDataRef.current ?? []),
|
|
[checkedLayerLegendVersion],
|
|
);
|
|
|
|
const applySinkholeYearRange = useCallback(
|
|
(nextRange: SinkholeYearRange) => {
|
|
const normalizedRange = normalizeSinkholeYearRange(
|
|
nextRange,
|
|
sinkholeYearRange,
|
|
);
|
|
setSinkholeYearRange(normalizedRange);
|
|
setIsSinkholePopupFilterActive(false);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
},
|
|
[sinkholeYearRange],
|
|
);
|
|
|
|
/** 침수위험지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */
|
|
const openSbmrgDngrMapPopupWindow = useCallback(() => {
|
|
const popup = openWindowPopup(SBMRG_DNGR_MAP_POPUP_KEY as WindowPopupKey);
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
}
|
|
return popup;
|
|
}, []);
|
|
|
|
/** 도시침수지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */
|
|
const openFloodDngrMapPopupWindow = useCallback(() => {
|
|
const popup = openWindowPopup(FLOOD_DNGR_MAP_POPUP_KEY as WindowPopupKey);
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
}
|
|
return popup;
|
|
}, []);
|
|
|
|
const handleOpenLayerPopup = useCallback(
|
|
(_layerNm: string, node?: any) => {
|
|
if (isAgedPipeLayerNode(node)) {
|
|
openAgedPipeList();
|
|
return false;
|
|
}
|
|
if (isSinkholeRootNode(node)) {
|
|
openSinkholePopupWindow();
|
|
return false;
|
|
}
|
|
if (isSbmrgDngrMapRootNode(node)) {
|
|
openSbmrgDngrMapPopupWindow();
|
|
return false;
|
|
}
|
|
if (isFloodDngrMapRootNode(node)) {
|
|
openFloodDngrMapPopupWindow();
|
|
return false;
|
|
}
|
|
if (isFldngHstryRootNode(node)) {
|
|
openFldngHstryPopupWindow();
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
[
|
|
openAgedPipeList,
|
|
openFldngHstryPopupWindow,
|
|
openFloodDngrMapPopupWindow,
|
|
openSbmrgDngrMapPopupWindow,
|
|
openSinkholePopupWindow,
|
|
],
|
|
);
|
|
|
|
// 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다.
|
|
const activeLayerSettingItems = useMemo<
|
|
{ key: string; label: string; content: ReactNode }[]
|
|
>(() => {
|
|
const nextItems: {
|
|
key: string;
|
|
label: string;
|
|
content: ReactNode;
|
|
order: number;
|
|
}[] = [];
|
|
const resolveSettingOrder = (key: string, fallback: number) => {
|
|
const index = activeLayerSettingOrder.indexOf(key);
|
|
return index >= 0 ? index : fallback;
|
|
};
|
|
|
|
if (isExmnPplChecked && isExmnPplSettingVisible) {
|
|
nextItems.push({
|
|
key: "exmnPpl",
|
|
label: "관로조사",
|
|
order: resolveSettingOrder("exmnPpl", 9995),
|
|
content: (
|
|
<ExmnLayerSettingPanel
|
|
jobType="PPL"
|
|
statusMessage={exmnLayerMessages.PPL}
|
|
filters={exmnPplFilters}
|
|
onFiltersChange={setExmnPplFilters}
|
|
enabled={isExmnPplSettingEnabled}
|
|
onEnabledChange={setIsExmnPplSettingEnabled}
|
|
onClose={() => setIsExmnPplSettingVisible(false)}
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (isExmnMnhlChecked && isExmnMnhlSettingVisible) {
|
|
nextItems.push({
|
|
key: "exmnMnhl",
|
|
label: "맨홀조사",
|
|
order: resolveSettingOrder("exmnMnhl", 9996),
|
|
content: (
|
|
<ExmnLayerSettingPanel
|
|
jobType="MNHL"
|
|
statusMessage={exmnLayerMessages.MNHL}
|
|
filters={exmnMnhlFilters}
|
|
onFiltersChange={setExmnMnhlFilters}
|
|
enabled={isExmnMnhlSettingEnabled}
|
|
onEnabledChange={setIsExmnMnhlSettingEnabled}
|
|
onClose={() => setIsExmnMnhlSettingVisible(false)}
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (isFldngHstryChecked && isFldngHstryYearSettingVisible) {
|
|
nextItems.push({
|
|
key: "fldngHstry",
|
|
label: "침수흔적도",
|
|
order: resolveSettingOrder("fldngHstry", 9997),
|
|
content: (
|
|
<FldngHstryLayerSettingPanel
|
|
yearRange={fldngHstryYearRange}
|
|
enabled={isFldngHstryYearFilterEnabled}
|
|
onYearRangeChange={setFldngHstryYearRange}
|
|
onEnabledChange={setIsFldngHstryYearFilterEnabled}
|
|
onClose={() => setIsFldngHstryYearSettingVisible(false)}
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (isSinkholeChecked && isSinkholeSettingVisible) {
|
|
nextItems.push({
|
|
key: "sinkhole",
|
|
label: "지반침하정보",
|
|
order: resolveSettingOrder("sinkhole", 9998),
|
|
content: (
|
|
<SinkholeLayerSettingPanel
|
|
radiusMeters={sinkholeRadiusMeters}
|
|
onApplyRadius={applySinkholeRadius}
|
|
yearRange={sinkholeYearRange}
|
|
onApplyYearRange={applySinkholeYearRange}
|
|
lastSyncSummary={lastSinkholeSyncSummary}
|
|
isAdmin={userInfo?.adminRoleYn === "Y"}
|
|
enabled={isSinkholeSettingEnabled}
|
|
onEnabledChange={setIsSinkholeSettingEnabled}
|
|
onClose={() => setIsSinkholeSettingVisible(false)}
|
|
compact
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (isAgedPipeChecked && isAgedPipeSettingVisible) {
|
|
nextItems.push({
|
|
key: "agedPipe",
|
|
label: "노후관로",
|
|
order: resolveSettingOrder("agedPipe", 9999),
|
|
content: (
|
|
<AgedPipeLayerSettingPanel
|
|
filters={draftAgedPipeFilters}
|
|
onFiltersChange={setDraftAgedPipeFilters}
|
|
onApply={handleApplyAgedPipeFilters}
|
|
enabled={isAgedPipeSettingEnabled}
|
|
onEnabledChange={setIsAgedPipeSettingEnabled}
|
|
onClose={() => setIsAgedPipeSettingVisible(false)}
|
|
compact
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (isWaterDepthLayerActive && isWaterDepthLegendVisible) {
|
|
nextItems.push({
|
|
key: "waterDepthLegend",
|
|
label: "침수심 범례",
|
|
order: resolveSettingOrder("waterDepthLegend", 9997),
|
|
content: (
|
|
<LayerSettingCard
|
|
title="침수심 범례"
|
|
enabled={isWaterDepthLegendEnabled}
|
|
onEnabledChange={setIsWaterDepthLegendEnabled}
|
|
onClose={() => setIsWaterDepthLegendVisible(false)}
|
|
showToggle={false}
|
|
>
|
|
{isWaterDepthLegendEnabled ? (
|
|
<div className="space-y-[6px]">
|
|
{waterDepthLegendItems.map((item) => (
|
|
<div key={item.key} className="flex items-center gap-2">
|
|
<span
|
|
className="inline-block h-6 w-10 shrink-0"
|
|
style={{
|
|
backgroundColor: item.color,
|
|
border: `1px solid ${item.borderColor}`,
|
|
}}
|
|
/>
|
|
<span className="text-[11px] font-medium text-label">
|
|
{item.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</LayerSettingCard>
|
|
),
|
|
});
|
|
}
|
|
|
|
return nextItems
|
|
.sort((a, b) => a.order - b.order)
|
|
.map(({ key, label, content }) => ({ key, label, content }));
|
|
}, [
|
|
activeLayerSettingOrder,
|
|
applySinkholeRadius,
|
|
applySinkholeYearRange,
|
|
draftAgedPipeFilters,
|
|
exmnLayerMessages,
|
|
exmnMnhlFilters,
|
|
exmnPplFilters,
|
|
handleApplyAgedPipeFilters,
|
|
fldngHstryYearRange,
|
|
isAgedPipeChecked,
|
|
isAgedPipeSettingEnabled,
|
|
isAgedPipeSettingVisible,
|
|
isExmnMnhlChecked,
|
|
isExmnMnhlSettingEnabled,
|
|
isExmnMnhlSettingVisible,
|
|
isExmnPplChecked,
|
|
isExmnPplSettingEnabled,
|
|
isExmnPplSettingVisible,
|
|
isFldngHstryChecked,
|
|
isFldngHstryYearFilterEnabled,
|
|
isFldngHstryYearSettingVisible,
|
|
isSinkholeChecked,
|
|
isSinkholeSettingEnabled,
|
|
isSinkholeSettingVisible,
|
|
isWaterDepthLayerActive,
|
|
isWaterDepthLegendEnabled,
|
|
isWaterDepthLegendVisible,
|
|
lastSinkholeSyncSummary,
|
|
sinkholeRadiusMeters,
|
|
sinkholeYearRange,
|
|
userInfo?.adminRoleYn,
|
|
waterDepthLegendItems,
|
|
]);
|
|
|
|
// 설정값 변경으로 설정 컴포넌트가 다시 만들어져도 사용자가 보던 내부 스크롤 위치를 유지한다.
|
|
const activeLayerSettingScrollTopRef = useRef(0);
|
|
const handleActiveLayerSettingScroll = useCallback(
|
|
(event: UIEvent<HTMLDivElement>) => {
|
|
activeLayerSettingScrollTopRef.current = event.currentTarget.scrollTop;
|
|
},
|
|
[],
|
|
);
|
|
const restoreActiveLayerSettingScroll = useCallback(
|
|
(element: HTMLDivElement | null) => {
|
|
if (!element) return;
|
|
|
|
const maxScrollTop = Math.max(
|
|
0,
|
|
element.scrollHeight - element.clientHeight,
|
|
);
|
|
element.scrollTop = Math.min(
|
|
activeLayerSettingScrollTopRef.current,
|
|
maxScrollTop,
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 선택된 설정이 있을 때만 하단 전용 섹션을 노출하고, 세로 공간이 부족하면 내부 스크롤로 처리한다.
|
|
const ActiveLayerSettingView = useCallback(() => {
|
|
if (activeLayerSettingItems.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
className="flex h-[34vh] flex-col overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_4px_14px_rgba(15,23,42,0.08)]"
|
|
style={{
|
|
// 기본 높이는 유지하고, 창이 낮을 때만 지도 내부의 남은 높이에 맞춰 축소한다.
|
|
minHeight: "min(24rem, max(38px, calc(100dvh - 112px)))",
|
|
maxHeight: "min(32rem, max(38px, calc(100dvh - 112px)))",
|
|
}}
|
|
>
|
|
<div className="flex h-[38px] shrink-0 items-center gap-2 border-b border-border px-3">
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-[#EDF8EA] text-[#65B84A]">
|
|
<ListTree size={14} strokeWidth={2} />
|
|
</span>
|
|
<span className="text-[1.4rem] font-bold text-foreground">
|
|
선택 레이어 설정
|
|
</span>
|
|
</div>
|
|
<div
|
|
ref={restoreActiveLayerSettingScroll}
|
|
onScroll={handleActiveLayerSettingScroll}
|
|
className="min-h-0 flex-1 overflow-y-scroll px-2 py-2 [scrollbar-gutter:stable] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]"
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
{activeLayerSettingItems.map((item) => (
|
|
<Fragment key={item.key}>{item.content}</Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}, [
|
|
activeLayerSettingItems,
|
|
handleActiveLayerSettingScroll,
|
|
restoreActiveLayerSettingScroll,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
const row = findAgedPipeLayer(treeData);
|
|
if (!row) return;
|
|
const core = GisApp.Module.core as any;
|
|
const id = String(row.lyrSn);
|
|
const layerCode = (GisApp.layerCode as any)?.[id];
|
|
if (!layerCode) return;
|
|
try {
|
|
validateAgedPipeLayer(row);
|
|
const filter = buildAgedPipeCqlFilters(
|
|
appliedAgedPipeFilters,
|
|
row.agedPipeFilterTemplate ?? row.flterSql,
|
|
).join(" AND ");
|
|
layerCode.flterSql = filter;
|
|
const visible = isAgedPipeChecked && isAgedPipeSettingEnabled;
|
|
let layer = core?.getLayerById?.(id);
|
|
if (!visible) {
|
|
layer?.setVisible?.(false);
|
|
return;
|
|
}
|
|
// 숨긴 동안 바뀐 조건도 다시 켤 때 반영하도록 실제 레이어의 생성 당시 필터와 비교한다.
|
|
if (!layer || layer.get?.("flterSql") !== filter) {
|
|
layer = core?.addWfsLayer?.(layerCode);
|
|
core?.setSelectInteraction?.();
|
|
}
|
|
layer?.setVisible?.(true);
|
|
} catch (error) {
|
|
core?.getLayerById?.(id)?.setVisible?.(false);
|
|
console.error("노후관로 DB 레이어 설정 오류:", error);
|
|
}
|
|
}, [
|
|
appliedAgedPipeFilters,
|
|
isAgedPipeChecked,
|
|
isAgedPipeSettingEnabled,
|
|
mapReady,
|
|
treeData,
|
|
checkedLayerLegendVersion,
|
|
]);
|
|
useFloodHistoryVisibility({
|
|
mapReady,
|
|
gisMaps,
|
|
fldngHstryYearRange,
|
|
isFldngHstryYearFilterEnabled,
|
|
clearFldngHstrySelection,
|
|
isFldngHstryWtlvChecked,
|
|
isFldngHstryDepthChecked,
|
|
checkedFldngHstryDepthLayerKeys,
|
|
isFldngHstryChecked,
|
|
});
|
|
|
|
const openAnalysisPopup = useCallback(
|
|
(popupKey: WindowPopupKey) => {
|
|
const popup = openWindowPopup(popupKey);
|
|
if (!popup) {
|
|
alert(
|
|
"브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (popupKey === "pipeAnalysisPopup") {
|
|
pipeAnalysisPopupWindowRef.current = popup;
|
|
}
|
|
|
|
if (popupKey === "flowRiskPopup") {
|
|
watchWaterRiskPopup(popup);
|
|
}
|
|
|
|
return popup;
|
|
},
|
|
[watchWaterRiskPopup],
|
|
);
|
|
|
|
const openGisEditSessionPopup = useCallback(() => {
|
|
const existingPopup = gisEditSessionPopupWindowRef.current;
|
|
if (existingPopup && !existingPopup.closed) {
|
|
existingPopup.focus();
|
|
window.focus?.();
|
|
return existingPopup;
|
|
}
|
|
|
|
const popup = openWindowPopup("gisEditSessionPopup");
|
|
if (!popup) return null;
|
|
|
|
gisEditSessionPopupWindowRef.current = popup;
|
|
if (gisEditSessionPopupCloseWatcherRef.current) {
|
|
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
|
|
gisEditSessionPopupCloseWatcherRef.current = null;
|
|
}
|
|
gisEditSessionPopupCloseWatcherRef.current = window.setInterval(() => {
|
|
if (!popup.closed) return;
|
|
if (gisEditSessionPopupCloseWatcherRef.current) {
|
|
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
|
|
gisEditSessionPopupCloseWatcherRef.current = null;
|
|
}
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
}, 300);
|
|
window.focus?.();
|
|
return popup;
|
|
}, []);
|
|
const {
|
|
gisEditSelectedVertexHandleKeyRef,
|
|
gisEditSelectedVertexMetaListRef,
|
|
gisEditVertexLayerRef,
|
|
clearGisEditVertexLayer,
|
|
applySelectedVertexHandleStyle,
|
|
clearSelectedVertexHandle,
|
|
removeGisEditInteractions,
|
|
} = useGisEditTransform({
|
|
gisMaps,
|
|
gisEditPreviewSourceRef,
|
|
gisEdit,
|
|
gisEditPreviewLayerRef,
|
|
setGisEditPreviewCursor,
|
|
mapReady,
|
|
ensureGisEditPreviewLayer,
|
|
shouldUsePipeSnapSource,
|
|
ensureGisEditPipeSnapSource,
|
|
gisEditMoveGripMetaRef,
|
|
});
|
|
const { removeGisEditDrawInteraction } = useGisEditDraw({
|
|
gisMaps,
|
|
gisEdit,
|
|
mapReady,
|
|
removeGisEditInteractions,
|
|
ensureGisEditPreviewLayer,
|
|
shouldUsePipeSnapSource,
|
|
ensureGisEditPipeSnapSource,
|
|
setGisEditPreviewCursor,
|
|
gisEditPreviewLayerRef,
|
|
openGisEditPipeDetail,
|
|
setFeatureData,
|
|
setGisEditDetailChangeId,
|
|
setIsOpen,
|
|
setIsSearchModalOpen,
|
|
});
|
|
|
|
const refreshGisEditTargetLayers = useCallback(
|
|
(layerTypeNames: string[], focusFid?: string | null) => {
|
|
const refreshTargetNames = new Set(
|
|
(layerTypeNames.length > 0 ? layerTypeNames : ["tb_ms_swage_ppl"])
|
|
.map((layerTypeName) => String(layerTypeName ?? "").trim())
|
|
.filter(Boolean),
|
|
);
|
|
const map = gisMaps.current;
|
|
map?.getAllLayers?.().forEach((layer: any) => {
|
|
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
|
|
if (!refreshTargetNames.has(layerTypeName)) return;
|
|
|
|
const source = layer.getSource?.();
|
|
source?.updateParams?.({ _t: Date.now() });
|
|
source?.refresh?.();
|
|
source?.changed?.();
|
|
layer.changed?.();
|
|
});
|
|
|
|
if (focusFid) {
|
|
const core = GisApp.Module.layer as any;
|
|
core?.showSearchResultsFromGeoJSON?.("tb_ms_swage_ppl", focusFid);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// GIS 편집 진입 시에는 화면 안 인라인 세션을 우선 표시하고, 윈도우 팝업은 사용자가 명시적으로 연다.
|
|
const handleToggleGisEdit = useCallback(() => {
|
|
if (gisEdit.isEditMode) {
|
|
closeGisEditSessionPopup();
|
|
} else {
|
|
setGisEditSessionView("inline");
|
|
}
|
|
gisEdit.toggleEditMode();
|
|
setIsOpen(false);
|
|
}, [closeGisEditSessionPopup, gisEdit]);
|
|
|
|
const handleOpenGisEditSessionPopup = useCallback(() => {
|
|
const popup = openGisEditSessionPopup();
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
return;
|
|
}
|
|
setGisEditSessionView("popup");
|
|
syncGisEditSessionPopup();
|
|
}, [openGisEditSessionPopup, syncGisEditSessionPopup]);
|
|
|
|
// 툴바 버튼은 현재 지도 중심 또는 다음 피처 선택을 기준으로 로컬 변경분을 만든다.
|
|
const handleSelectGisEditTool = useCallback(
|
|
(tool: "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE") => {
|
|
if (gisEdit.activeTool === tool) {
|
|
gisEdit.deactivateTool();
|
|
removeGisEditDrawInteraction();
|
|
removeGisEditInteractions();
|
|
return;
|
|
}
|
|
|
|
removeGisEditDrawInteraction();
|
|
removeGisEditInteractions();
|
|
gisEdit.activateTool(tool, gisMaps.current);
|
|
setIsOpen(false);
|
|
},
|
|
[gisEdit, removeGisEditDrawInteraction, removeGisEditInteractions],
|
|
);
|
|
|
|
const handleCloseGisEdit = useCallback(() => {
|
|
removeGisEditInteractions();
|
|
removeGisEditDrawInteraction();
|
|
clearGisEditPreviewLayer();
|
|
clearGisEditVertexLayer();
|
|
closeGisEditSessionPopup();
|
|
gisEdit.discardSession();
|
|
setGisEditDetailChangeId(null);
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
}, [
|
|
clearGisEditPreviewLayer,
|
|
clearGisEditVertexLayer,
|
|
closeGisEditSessionPopup,
|
|
gisEdit,
|
|
removeGisEditDrawInteraction,
|
|
removeGisEditInteractions,
|
|
]);
|
|
|
|
const handleSaveGisEdit = useCallback(async () => {
|
|
if (isGisEditSaving) return;
|
|
|
|
const payload = gisEdit.save(gisMaps.current);
|
|
if (!payload) return;
|
|
|
|
const invalidRequiredChange = findInvalidPipeRequiredChange(
|
|
payload.changes,
|
|
);
|
|
if (invalidRequiredChange) {
|
|
const message =
|
|
"관리기관코드, 시설관리번호, 기관관리번호, 용도는 필수 입력 항목입니다.";
|
|
alert(message);
|
|
gisEdit.announceStatus(message);
|
|
setGisEditDetailChangeId(invalidRequiredChange.changeId);
|
|
setIsOpen(true);
|
|
return;
|
|
}
|
|
|
|
setIsGisEditSaving(true);
|
|
gisEdit.announceStatus("관로 편집 변경분을 저장 중입니다.");
|
|
try {
|
|
const response = await postRawJson(
|
|
"/sewerage-service/api/v1/map/gisEditSessions/save",
|
|
payload,
|
|
);
|
|
const results = Array.isArray(response?.results) ? response.results : [];
|
|
const refreshLayerTypeNames = Array.isArray(
|
|
response?.refreshLayerTypeNames,
|
|
)
|
|
? response.refreshLayerTypeNames
|
|
: results.flatMap((result: any) => result?.refreshLayerTypeNames ?? []);
|
|
const focusFid =
|
|
results.find(
|
|
(result: any) => result?.fid && result?.editType !== "DELETE",
|
|
)?.fid ?? null;
|
|
|
|
removeGisEditInteractions();
|
|
removeGisEditDrawInteraction();
|
|
clearGisEditPreviewLayer();
|
|
clearGisEditVertexLayer();
|
|
refreshGisEditTargetLayers(refreshLayerTypeNames, focusFid);
|
|
gisEdit.clearChanges();
|
|
setGisEditDetailChangeId(null);
|
|
setIsOpen(false);
|
|
closeGisEditSessionPopup();
|
|
gisEdit.announceStatus(
|
|
response?.message ?? "하수관로 편집 저장이 완료되었습니다.",
|
|
);
|
|
} catch (error) {
|
|
console.error("GIS 편집 저장 실패:", error);
|
|
const apiError = error as Error & { body?: any };
|
|
gisEdit.announceStatus(
|
|
`하수관로 편집 저장 실패: ${apiError.body?.message ?? apiError.message}`,
|
|
);
|
|
} finally {
|
|
setIsGisEditSaving(false);
|
|
}
|
|
}, [
|
|
clearGisEditPreviewLayer,
|
|
clearGisEditVertexLayer,
|
|
closeGisEditSessionPopup,
|
|
gisEdit,
|
|
isGisEditSaving,
|
|
refreshGisEditTargetLayers,
|
|
removeGisEditDrawInteraction,
|
|
removeGisEditInteractions,
|
|
]);
|
|
|
|
const handleApplyGisEditPipeBasicInfo = useCallback(
|
|
(changeId: string, basicInfo: GisEditPipeBasicInfo) => {
|
|
gisEdit.updateChangeBasicInfo(changeId, basicInfo);
|
|
},
|
|
[gisEdit.updateChangeBasicInfo],
|
|
);
|
|
|
|
const canSelectFeatureDuringGisEdit = useCallback(
|
|
(feature: any) => {
|
|
if (!gisEdit.isEditMode) return true;
|
|
if (!gisEdit.activeTool) return true;
|
|
|
|
if (
|
|
gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" &&
|
|
getSelectedFeatureLayerTypeName(feature) !== "tb_ms_swage_ppl"
|
|
) {
|
|
gisEdit.announceStatus("하수관로를 선택해주세요.");
|
|
}
|
|
return false;
|
|
},
|
|
[gisEdit],
|
|
);
|
|
|
|
const GisEditAwareDetailView = useCallback(
|
|
(props: any) => {
|
|
const activeRightPanelTab =
|
|
rightPanelTabs.find((tab) => tab.id === activeRightPanelTabId) ?? null;
|
|
const panelFeatureData =
|
|
activeRightPanelTab?.featureData ?? props.featureData;
|
|
const currentChange = gisEditDetailChangeRef.current;
|
|
const currentFeatureData = gisEditDetailFeatureDataRef.current;
|
|
const isEditMode = gisEditIsEditModeRef.current;
|
|
const selectedLayerTypeName =
|
|
getSelectedFeatureLayerTypeName(panelFeatureData);
|
|
const panelSinkholeDetail =
|
|
activeRightPanelTab?.kind === "sinkhole"
|
|
? (activeRightPanelTab.sinkholeDetail ?? selectedSinkholeDetail)
|
|
: selectedSinkholeDetail;
|
|
const panelSinkholeNearbyPipes =
|
|
activeRightPanelTab?.kind === "sinkhole"
|
|
? (activeRightPanelTab.nearbyPipes ?? selectedSinkholeNearbyPipes)
|
|
: selectedSinkholeNearbyPipes;
|
|
const tabsView = (
|
|
<RightPanelBookmarkTabs
|
|
tabs={rightPanelTabs}
|
|
activeTabId={activeRightPanelTabId}
|
|
onActivate={activateRightPanelTab}
|
|
onBack={activateRightPanelTab}
|
|
/>
|
|
);
|
|
const closeActiveTab = () => {
|
|
if (activeRightPanelTabId) {
|
|
closeRightPanelTab(activeRightPanelTabId);
|
|
return;
|
|
}
|
|
props.onClose?.();
|
|
};
|
|
|
|
if (
|
|
isEditMode &&
|
|
currentChange &&
|
|
currentChange.layerId === "tb_ms_swage_ppl" &&
|
|
currentChange.editType !== "DELETE"
|
|
) {
|
|
return (
|
|
<>
|
|
{tabsView}
|
|
<GisEditPipeSessionPopup
|
|
change={currentChange}
|
|
featureData={currentFeatureData ?? panelFeatureData}
|
|
onClose={() => {
|
|
setGisEditDetailChangeId(null);
|
|
closeActiveTab();
|
|
}}
|
|
onSave={handleApplyGisEditPipeBasicInfo}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (
|
|
selectedLayerTypeName === SINKHOLE_LAYER_TYPE &&
|
|
panelSinkholeDetail
|
|
) {
|
|
return (
|
|
<>
|
|
{tabsView}
|
|
<SinkholeDetailPanel
|
|
detail={panelSinkholeDetail}
|
|
nearbyPipes={panelSinkholeNearbyPipes}
|
|
radiusMeters={sinkholeRadiusMeters}
|
|
onSelectPipe={focusSinkholeNearbyPipe}
|
|
onClose={closeActiveTab}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{tabsView}
|
|
<FeatureClickEventPopup
|
|
{...props}
|
|
featureData={panelFeatureData}
|
|
surveyMode={activeRightPanelTab?.category === "exmnSurvey"}
|
|
onClose={() => {
|
|
setGisEditDetailChangeId(null);
|
|
closeActiveTab();
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
},
|
|
[
|
|
activateRightPanelTab,
|
|
activeRightPanelTabId,
|
|
closeRightPanelTab,
|
|
focusSinkholeNearbyPipe,
|
|
handleApplyGisEditPipeBasicInfo,
|
|
rightPanelTabs,
|
|
selectedSinkholeDetail,
|
|
selectedSinkholeNearbyPipes,
|
|
sinkholeRadiusMeters,
|
|
],
|
|
);
|
|
useGisEditSelection({
|
|
mapReady,
|
|
gisMaps,
|
|
gisEdit,
|
|
gisEditVertexLayerRef,
|
|
clearSelectedVertexHandle,
|
|
gisEditSelectedVertexHandleKeyRef,
|
|
gisEditSelectedVertexMetaListRef,
|
|
applySelectedVertexHandleStyle,
|
|
setGisEditDetailChangeId,
|
|
setFeatureData,
|
|
setIsOpen,
|
|
setIsSearchModalOpen,
|
|
gisEditMoveGripMetaRef,
|
|
openGisEditPipeDetail,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const popup = sinkholePopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
sinkholePopupWindowRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const effectiveFilters = normalizeSinkholeFilters(
|
|
appliedSinkholeFilters,
|
|
sinkholeScope,
|
|
);
|
|
popup.postMessage(
|
|
{
|
|
type: SINKHOLE_FILTER_SYNC_MESSAGE,
|
|
filters: effectiveFilters,
|
|
},
|
|
window.location.origin,
|
|
);
|
|
}, [appliedSinkholeFilters, sinkholeScope, sinkholeSelectionVersion]);
|
|
useFloodHistoryClick({
|
|
mapReady,
|
|
gisMaps,
|
|
selectedFldngHstryDetail,
|
|
isFldngHstryChecked,
|
|
fldngHstryAreaLayerRef,
|
|
fldngHstryDepthLayerRef,
|
|
setFldngHstryDetailPopupPosition,
|
|
setIsSearchModalOpen,
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<div className="sn-map-control-layout relative h-[calc(100dvh-6rem)] overflow-hidden bg-container">
|
|
{layerLoadError ? (
|
|
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-background px-4 py-3 text-sm text-red-700 shadow">
|
|
레이어 설정 조회 실패: {layerLoadError}
|
|
</div>
|
|
) : null}
|
|
{agedPipeDownloadProgressText ? (
|
|
<div className="absolute left-4 top-20 z-[60] max-w-[520px] rounded border border-emerald-200 bg-background px-4 py-3 text-sm text-emerald-700 shadow">
|
|
{agedPipeDownloadProgressText}
|
|
</div>
|
|
) : null}
|
|
{selectedWaterRiskResultRow && waterRiskInfoCardStyle ? (
|
|
// 위험/정상 배지는 통수위험 상태를 즉시 구분하는 업무 의미색이므로 고정색을 유지한다.
|
|
<div
|
|
style={waterRiskInfoCardStyle}
|
|
className="absolute z-[61] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
|
|
>
|
|
<div className="flex items-center justify-between bg-secondary px-[9px] py-[7px]">
|
|
<div>
|
|
<div className="text-[10px] font-semibold text-muted-foreground">
|
|
중점관리지역
|
|
</div>
|
|
<div className="mt-[2px] text-[13px] font-bold text-foreground">
|
|
{selectedWaterRiskResultRow.areaName}
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={`inline-flex min-w-[48px] items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
|
|
selectedWaterRiskDisplayStatus === "DANGER"
|
|
? "bg-[#ff1c15] text-white"
|
|
: "bg-[#e8f6f4] text-[#0f766e]"
|
|
}`}
|
|
>
|
|
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
|
|
</span>
|
|
</div>
|
|
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
|
|
<div>GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면</div>
|
|
<div>
|
|
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
|
|
<span className="font-bold text-[#1677ff]">정상</span>
|
|
</div>
|
|
</div>
|
|
<table className="w-full table-fixed border-collapse border-t border-border">
|
|
<colgroup>
|
|
<col className="w-[68%]" />
|
|
<col className="w-[32%]" />
|
|
</colgroup>
|
|
<tbody>
|
|
{[
|
|
[
|
|
"현재 누적강우량(mm)",
|
|
selectedWaterRiskMapView?.currentAccumRainQty,
|
|
1,
|
|
],
|
|
[
|
|
"예측강우량(mm)",
|
|
selectedWaterRiskMapView?.forecastRainQty,
|
|
1,
|
|
],
|
|
[
|
|
"최대 침수유량(m³/s)",
|
|
selectedWaterRiskMapView?.maxFloodingFlow,
|
|
2,
|
|
],
|
|
].map(([label, value, digits], index) => (
|
|
<tr
|
|
key={String(label)}
|
|
className={index < 2 ? "border-b border-border" : ""}
|
|
>
|
|
<td className="border-r border-border bg-secondary px-[9px] py-[8px] text-[10px] font-semibold text-label">
|
|
{label}
|
|
</td>
|
|
<td className="px-[9px] py-[7px] text-right text-[12px] font-bold text-foreground">
|
|
{typeof value === "number"
|
|
? value.toFixed(Number(digits))
|
|
: "-"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : null}
|
|
{selectedWaterRiskMapView ? (
|
|
// Capacity와 Flooding 색상은 SWMM 결과의 업무 의미색이므로 테마와 무관하게 유지한다.
|
|
<div className="absolute bottom-4 left-[270px] z-[61] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
|
|
<div className="mb-1 font-bold">Capacity</div>
|
|
<div className="space-y-1 font-semibold">
|
|
{[
|
|
["#0000ff", "≤ 0.25"],
|
|
["#00d9ff", "≤ 0.50"],
|
|
["#00d95f", "≤ 0.75"],
|
|
["#ffe100", "< 1.00"],
|
|
["#ff1c15", "≥ 1.00"],
|
|
].map(([color, label]) => (
|
|
<div key={label} className="flex items-center gap-2">
|
|
<span
|
|
className="inline-block h-[10px] w-[10px] border border-foreground"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
<span>{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-2 border-t border-border pt-2">
|
|
<div className="font-bold">Flooding(m³/s)</div>
|
|
<div className="mt-1 flex gap-2">
|
|
<span className="text-[#1677ff]">● 0</span>
|
|
<span className="text-[#ff1c15]">● > 0</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{selectedFldngHstryDetail && fldngHstryDetailPopupPosition ? (
|
|
<FldngHstryDetailPanel
|
|
detail={selectedFldngHstryDetail}
|
|
position={fldngHstryDetailPopupPosition}
|
|
onClose={closeFldngHstryDetail}
|
|
/>
|
|
) : null}
|
|
<AnalysisPopupMenu
|
|
initialActiveKey={initialAnalysisPopupKey}
|
|
onOpenPopup={openAnalysisPopup}
|
|
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
|
|
onToggleGisEdit={handleToggleGisEdit}
|
|
isGisEditActive={gisEdit.isEditMode}
|
|
isInfoSearchActive={isSearchModalOpen}
|
|
/>
|
|
{isGisEditSessionInlineVisible ? (
|
|
<div
|
|
data-testid="gis-edit-session-inline"
|
|
className="absolute left-[258px] top-[16px] z-[61] w-[265px]"
|
|
>
|
|
<GisEditSessionPanel
|
|
{...gisEditSessionSnapshot}
|
|
className="max-h-[calc(50dvh-82px)] !border-0 !shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
|
|
variant="inline"
|
|
onOpenPopup={handleOpenGisEditSessionPopup}
|
|
onRemoveFocusedChange={gisEdit.removeFocusedChange}
|
|
onRemoveLastChange={gisEdit.removeLastChange}
|
|
onClearChanges={gisEdit.clearChanges}
|
|
onFocusChange={handleFocusGisEditSessionChange}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<GisEditToolbar
|
|
isEditMode={gisEdit.isEditMode}
|
|
activeTool={gisEdit.activeTool}
|
|
isSnapEnabled={gisEdit.isSnapEnabled}
|
|
canUseTools={gisEdit.canUseTools}
|
|
canSave={gisEdit.canSave}
|
|
isSaving={isGisEditSaving}
|
|
statusMessage={gisEdit.statusMessage}
|
|
allowedTools={gisEdit.selectedLayer?.allowedTools ?? []}
|
|
onClose={handleCloseGisEdit}
|
|
onToggleSnap={gisEdit.toggleSnapEnabled}
|
|
onSelectTool={handleSelectGisEditTool}
|
|
onSave={handleSaveGisEdit}
|
|
/>
|
|
<Gis
|
|
height="h-full"
|
|
treeData={treeData}
|
|
checkedLayerRequest={checkedLayerRequest}
|
|
isSearch={true}
|
|
gisMaps={gisMaps}
|
|
isMiniMap={false}
|
|
searchView={SearchModal}
|
|
detailView={GisEditAwareDetailView}
|
|
canSelectFeature={canSelectFeatureDuringGisEdit}
|
|
onFeatureSelect={onFeatureSelect}
|
|
onMapBlankClick={handleSinkholeMapBlankClick}
|
|
sinkholeLegendIconVisible={true}
|
|
featureData={featureData}
|
|
setFeatureData={setFeatureData}
|
|
isOpen={isOpen}
|
|
setIsOpen={setIsOpen}
|
|
isSearchModalOpen={isSearchModalOpen}
|
|
setIsSearchModalOpen={setIsSearchModalOpen}
|
|
onCheckedLayersChange={handleCheckedLayersChange}
|
|
onOpenPopup={handleOpenLayerPopup}
|
|
searchBarContainerClassName="!top-[16px] ml-[12px]"
|
|
alwaysVisibleMapTabs={true}
|
|
searchBar={
|
|
<MapSearchBar
|
|
gisMaps={gisMaps}
|
|
facilityTypes={facilityTypes}
|
|
integrated
|
|
sidoSearchZoom={
|
|
isAgedPipeChecked ? AGED_PIPE_GUIDE_MIN_ZOOM : undefined
|
|
}
|
|
facilityLayerFilter={swagePrcsFcltSearchFilter}
|
|
/>
|
|
}
|
|
waterDepth={undefined}
|
|
layerSettingView={ActiveLayerSettingView}
|
|
treeSectionClassName="px-[4px] py-2 text-foreground"
|
|
detachedTreeToggleButton={true}
|
|
showLayerPanelTitle={false}
|
|
sectionedTreePanel={true}
|
|
showBulkLayerToggle={true}
|
|
isPlaying={isPlaying}
|
|
setIsPlaying={setIsPlaying as any}
|
|
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
|
|
checkInterval={2500}
|
|
checkedLayerChangeDelay={0}
|
|
showZoomLevel={true}
|
|
toolBarIconSize={20}
|
|
showZoomScaleInfo={true}
|
|
showFloatingSearchButton={false}
|
|
treeTogglePosition="center"
|
|
zoomGuide={zoomGuide}
|
|
layerIconVisible={(node: any) =>
|
|
isAgedPipeLayerNode(node) ||
|
|
isSinkholeRootNode(node) ||
|
|
isSbmrgDngrMapRootNode(node) ||
|
|
isFloodDngrMapRootNode(node) ||
|
|
isFldngHstryRootNode(node)
|
|
}
|
|
layerLegendIconVisible={(node: any) => !isFldngHstryRootNode(node)}
|
|
layerIconRequiresChecked={false}
|
|
defaultZoom={7.5}
|
|
defaultLonLat={[129, 36.3]}
|
|
viewExtent={SOUTH_KOREA_VIEW_EXTENT}
|
|
/>
|
|
{isAgedPipeListMinimized && (
|
|
<div className="fixed left-[480px] top-[68px] z-[90] flex min-w-[250px] items-center gap-3 rounded-sm border border-red-200 bg-background px-3 py-2 shadow-[0_8px_22px_rgba(15,23,42,0.22)] backdrop-blur">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-red-50">
|
|
<span className="block h-4 w-4 rounded-sm border-2 border-red-500 bg-red-500/20" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[1.25rem] font-semibold leading-tight text-red-700">
|
|
노후관로 현황
|
|
</div>
|
|
<div className="text-[1rem] leading-tight text-muted-foreground">
|
|
{appliedAgedPipeFilters.elapsedYears}년 이상 ·{" "}
|
|
{agedPipeTotalCount.toLocaleString()}건
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
size="xs"
|
|
className="h-7 px-3"
|
|
onClick={openAgedPipeList}
|
|
>
|
|
열기
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
size="xs"
|
|
className="h-7 px-3"
|
|
onClick={() => setIsAgedPipeListMinimized(false)}
|
|
>
|
|
닫기
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<MapControlStyles />
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default MapPageClient;
|