Files
frontend/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx
T

3402 lines
116 KiB
TypeScript

/**
* ➡️ 경로 : 하수관망도 메인 페이지
*
* 📄 Map Page
* 지도 페이지 컴포넌트
*
* - OpenLayers 기반 GIS 지도 렌더링 및 상호작용
* - 레이어 트리 데이터 관리 및 동적 레이어 표시/숨김
* - 피처 검색 및 선택 기능 (SearchModal, ProcSearchBar)
* - 피처 클릭 이벤트 처리 및 상세 정보 팝업 표시
* - 수심(WaterDepth) 레이어 애니메이션 재생 기능
* - Zustand를 통한 검색 모달 상태 관리
*
* 🔗 사용 API
* - 레이어 설정 조회: GET /api/v1/map/lyrStngs/{lyrGrp} (sewerage-service)
*/
"use client";
import { fitMapSelection, mapSearchNavigationOptions, beginMapNavigation } from "@/features/map-search/lib/map-navigation";
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, X } 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,
isWaterRiskDanger,
} from "../components/water-risk/water-risk-types";
import GisToolButtons from "../components/gis-tools/gis-tool-buttons";
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,
dismissWaterRiskInfoCard,
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;
fitMapSelection(map, 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}`,
mapSearchNavigationOptions(gisMaps.current),
);
}
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,
resetSearch = false,
) => {
const navigation = resetSearch ? beginMapNavigation(gisMaps.current) : 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;
if (navigation && !navigation.isCurrent()) return;
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
) {
const x = Number(normalizedDetail.x);
const y = Number(normalizedDetail.y);
if (navigation && !navigation.commit([x, y, x, y])) return;
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 navigation = beginMapNavigation(gisMaps.current);
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 && navigation.commit(firstFeature.getGeometry()?.getExtent())) {
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, mapSearchNavigationOptions(gisMaps.current));
} 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, mapSearchNavigationOptions(gisMaps.current));
} 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,
true,
);
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-[44] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
>
<div className="bg-secondary px-[9px] py-[7px]">
<div className="flex items-center justify-between">
<div className="text-[10px] font-semibold text-muted-foreground">
중점관리지역
</div>
<button
type="button"
className="flex h-[20px] w-[20px] items-center justify-center rounded-[3px] text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
onClick={dismissWaterRiskInfoCard}
aria-label="통수위험 정보 카드 닫기"
title="닫기"
>
<X className="h-[14px] w-[14px]" aria-hidden="true" />
</button>
</div>
<div className="mt-[2px] flex items-center justify-between gap-2">
<div className="min-w-0 text-[13px] font-bold text-foreground">
{selectedWaterRiskResultRow.areaName}
</div>
<span
className={`inline-flex min-w-[48px] shrink-0 items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
// 위험(매핑관로없음)도 위험과 같은 빨간 배지로 표시하고 문구로 구분한다.
isWaterRiskDanger(selectedWaterRiskDisplayStatus)
? "bg-[#ff1c15] text-white"
: "bg-[#e8f6f4] text-[#0f766e]"
}`}
>
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
</span>
</div>
</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-[44] 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]">● &gt; 0</span>
</div>
</div>
</div>
) : null}
{selectedFldngHstryDetail && fldngHstryDetailPopupPosition ? (
<FldngHstryDetailPanel
detail={selectedFldngHstryDetail}
position={fldngHstryDetailPopupPosition}
onClose={closeFldngHstryDetail}
/>
) : null}
<AnalysisPopupMenu
initialActiveKey={initialAnalysisPopupKey}
onOpenPopup={openAnalysisPopup}
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
isInfoSearchActive={isSearchModalOpen}
/>
<GisToolButtons
isGisEditActive={gisEdit.isEditMode}
onToggleGisEdit={handleToggleGisEdit}
onOpenGisManagement={() => openAnalysisPopup("gisManagementPopup")}
/>
{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}
viewShowFullExtent={true}
/>
{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;