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

7804 lines
262 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ➡️ 경로 : 하수관망도 메인 페이지
*
* 📄 Map Page
* 지도 페이지 컴포넌트
*
* - OpenLayers 기반 GIS 지도 렌더링 및 상호작용
* - 레이어 트리 데이터 관리 및 동적 레이어 표시/숨김
* - 피처 검색 및 선택 기능 (SearchModal, ProcSearchBar)
* - 피처 클릭 이벤트 처리 및 상세 정보 팝업 표시
* - 수심(WaterDepth) 레이어 애니메이션 재생 기능
* - Zustand를 통한 검색 모달 상태 관리
*
* 🔗 사용 API
* - 레이어 설정 조회: GET /api/v1/map/lyrStngs/{lyrGrp} (sewerage-service)
*/
"use client";
import dynamic from "next/dynamic";
import { FLOW_DIRECTION_DEFAULT_COLOR, type FlowDirectionUsageStyle, withAlpha, createFlowDirectionStyle } from "@/features/mapLayers/lib/flow-direction-styles";
import { type VertexHandleMeta, type VertexHandleCandidate } from "@/features/gisEdit/model/vertex-handles";
import { createGisEditPreviewStyle, GIS_EDIT_VERTEX_DRAGGING_STYLE, getGisEditVertexHandleStyle } from "@/features/gisEdit/lib/preview-styles";
import { createVertexHandleFeature, buildVertexHandleFeatures, findHoveredMidpointCandidate } from "@/features/gisEdit/lib/vertex-handles";
import { applyVertexHandleMove, deleteSelectedVertexHandles, applyGeometryVertexSnaps, applyGeometryMoveSnap, getClosestGeometryVertexCoordinate, getClosestGeometryVertexMeta, getGeometryVertexCoordinateByMeta } from "@/features/gisEdit/lib/geometry-mutations";
import { type RightPanelTabCategory, type RightPanelTab, findRightPanelTabOverflowCandidate, removeRightPanelTabGroup, RightPanelBookmarkTabs } from "./components/rightPanel/RightPanelBookmarkTabs";
import {
FLOW_DIRECTION_LAYER_TYPE,
FLOW_DIRECTION_LAYER_NAME,
FLOW_DIRECTION_DEFAULT_MIN_ZOOM,
getFlowDirectionModeByPathname,
SWAGE_PRCS_FCLT_LAYER_TYPE,
type SwagePrcsFcltLayerMode,
getLayerTypeName,
getSwagePrcsFcltLayerMode,
isFlowDirectionLayerInfo,
normalizeFlowDirectionLayerInfos,
normalizeRainObservationLayerInteraction,
getFiniteNumber,
getFlowDirectionMinZoom,
getFlowDirectionZIndex,
expandFlowDirectionExtent,
formatFlowDirectionBbox,
getFlowDirectionFeatureCacheKey,
subscribeFlowDirectionMoveEnd,
} from "@/features/mapLayers";
import { ListTree } from "lucide-react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
type UIEvent,
} from "react";
import { usePathname, useSearchParams } from "next/navigation";
import Collection from "ol/Collection.js";
import Feature from "ol/Feature.js";
import GeoJSON from "ol/format/GeoJSON.js";
import Overlay from "ol/Overlay.js";
import CircleGeom from "ol/geom/Circle.js";
import Draw from "ol/interaction/Draw.js";
import Snap from "ol/interaction/Snap.js";
import Translate from "ol/interaction/Translate.js";
import Fill from "ol/style/Fill.js";
import Icon from "ol/style/Icon.js";
import Point from "ol/geom/Point.js";
import Polygon from "ol/geom/Polygon.js";
import VectorLayer from "ol/layer/Vector.js";
import { unByKey } from "ol/Observable.js";
import VectorSource from "ol/source/Vector.js";
import CircleStyle from "ol/style/Circle.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import Text from "ol/style/Text.js";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import {
isWindowPopupKey,
type WindowPopupKey,
} from "@/features/windowPopup/model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import Gis from "@common/business_components/ui/map/Gis";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
import { Button } from "@common/components";
import { getData, getRawJson, postRawJson } from "../../components/lib/api";
import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
import GisEditSessionPanel from "./components/gisEdit/GisEditSessionPanel";
import GisEditToolbar from "./components/gisEdit/GisEditToolbar";
import GisEditPipeSessionPopup from "./components/gisEdit/GisEditPipeSessionPopup";
import { useGisEdit } from "./components/gisEdit/useGisEdit";
import type {
GisEditPipeBasicInfo,
GisEditSessionSnapshot,
} from "./components/gisEdit/gisEditTypes";
import {
PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
type PipeAnalysisDetailRow,
} from "./components/pipeAnalysis/pipeAnalysisUtils";
import {
AGED_PIPE_DEFAULT_FILTERS,
AGED_PIPE_GUIDE_MIN_ZOOM,
AGED_PIPE_MIN_ZOOM,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_PAGE_SIZE,
type AgedPipeFilters,
type AgedPipeRow,
buildAgedPipeCqlFilters,
isAgedPipeLayerNode,
normalizeAgedPipeFilters,
toDisplayPipeRow,
validateElapsedYears,
} from "./components/agedPipe/agedPipeUtils";
import { downloadAgedPipeExcel } from "./components/agedPipe/agedPipeExport";
import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap";
import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap";
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel";
import FldngHstryLayerSettingPanel from "./components/fldngHstry/FldngHstryLayerSettingPanel";
import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel";
import LayerSettingCard from "./components/layerSetting/LayerSettingCard";
import { getExmnMapLayer } from "./components/exmn/exmnMapApi";
import {
EMPTY_EXMN_MAP_FILTERS,
EXMN_RESULT_MNHL_LAYER_ID,
EXMN_RESULT_PPL_LAYER_ID,
appendExmnResultControlNodes,
buildExmnMapEntryContext,
isExmnResultMnhlNode,
isExmnResultPplNode,
type ExmnMapFilters,
type ExmnMapJobType,
} from "./components/exmn/exmnMapTypes";
import {
buildSinkholeDefaultYearRange,
buildSinkholeFiltersFromYearRange,
buildSinkholeListQueryString,
buildSinkholeScope,
isSinkholeLayerNode,
isSinkholeRootNode,
normalizeSinkholeFilters,
normalizeSinkholeYearRange,
SINKHOLE_CONTROL_NODE_ID,
SINKHOLE_DEFAULT_RADIUS,
SINKHOLE_FILTER_SYNC_MESSAGE,
SINKHOLE_LAYER_ID,
SINKHOLE_LAYER_NAME,
SINKHOLE_LAYER_TYPE,
SINKHOLE_POPUP_KEY,
SINKHOLE_ROW_SELECT_MESSAGE,
SINKHOLE_SELECTED_LAYER_ID,
SINKHOLE_SYNC_COMPLETED_MESSAGE,
type SinkholeDetail,
type SinkholeFilters,
type SinkholeListItem,
type SinkholeNearbyPipe,
type SinkholeSyncSummary,
type SinkholeYearRange,
} from "./components/sinkhole/sinkholeTypes";
import {
FLDNG_HSTRY_AREA_LAYER_ID,
FLDNG_HSTRY_DEPTH_LAYER_NAME,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_DEPTH_LAYER_ID,
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_LAYER_NAME,
FLDNG_HSTRY_POPUP_KEY,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
type FldngHstryYearRange,
type FldngHstryItem,
} from "./components/fldngHstry/fldngHstryTypes";
import {
buildFldngHstryDefaultYearRange,
buildFldngHstryYearCql,
isFldngHstryDepthNode,
isFldngHstryWtlvNode,
isFldngHstryRootNode,
} from "./components/fldngHstry/fldngHstryUtils";
import {
SBMRG_DNGR_MAP_POPUP_KEY,
isSbmrgDngrMapRootNode,
} from "./components/sbmrgDngrMap/sbmrgDngrMapTypes";
import {
FLOOD_DNGR_MAP_LAYER_TYPE,
FLOOD_DNGR_MAP_POPUP_KEY,
isFloodDngrMapRootNode,
} from "./components/floodDngrMap/floodDngrMapTypes";
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
import SearchModal from "./searchModal/searchModal";
import {
WATER_DEPTH_LAYER_TYPES,
extractWaterDepthLegendItems,
} from "./waterDepth/waterDepth";
import {
buildWaterRiskStatusLabel,
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
type WaterRiskMapView,
type WaterRiskResultRow,
type WaterRiskSessionSnapshot,
} from "./components/waterRisk/waterRiskTypes";
const AnalysisPopupMenu = dynamic(
() => import("./components/analysisMenu/AnalysisPopupMenu"),
{ ssr: false },
);
const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE";
const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
// 노후관로 지도 강조선과 시설물 트리의 선형 범례가 동일하게 사용하는 빨간색이다.
const AGED_PIPE_HIGHLIGHT_COLOR = "rgba(235, 40, 40, 0.82)";
const FLDNG_HSTRY_WFS_LAYER_ID = "SN_FLDNG_HSTRY_WFS";
// 심선은 동일 레이어 유형에 6개 깊이 등급 설정이 있어 prefix 뒤에 lyrSn을 붙여 각각 생성한다.
const FLDNG_DEPTH_WFS_LAYER_ID_PREFIX = "SN_FLDNG_DEPTH_WFS";
const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
const SINKHOLE_DEFAULT_POINT_LIMIT = 2000;
const EXMN_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
// 지반침하 지도 포인트와 같은 파란 계열의 기존 레이어 트리 심볼 자산을 재사용한다.
const SINKHOLE_TREE_ICON_NAME = "SN_SINKHOLE_SPOT_BLUE.png";
const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
600000, 1300000, 1500000, 2500000,
];
const EXMN_GRADE_COLORS: Record<number, string> = {
1: "#22c55e",
2: "#84cc16",
3: "#facc15",
4: "#f97316",
5: "#dc2626",
};
const EXMN_RESULT_MANHOLE_ICON_SRC =
"/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png?v=3";
const FLOOD_PREDICTION_GRID_LAYER_ID = "SN_FLDNG_PREDICTION_GRID_LABEL";
/** 침수예측 오버레이용 12자리 분석시각을 화면 표시 형식으로 변환한다. */
const formatFloodPredictionDateTime = (value: unknown) => {
const text = String(value ?? "").trim();
return /^\d{12}$/.test(text)
? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)} ${text.slice(8, 10)}:${text.slice(10, 12)}`
: text || "-";
};
const getExmnGradeColor = (feature: any) => {
const grade = Number(feature?.get?.("structuralGrade") ?? 0);
return EXMN_GRADE_COLORS[grade] ?? "#2563eb";
};
const createExmnPipeStyle = (feature: any) =>
new Style({
stroke: new Stroke({
color: getExmnGradeColor(feature),
width: 4,
}),
});
// 레이어 트리 범례와 지도 피처가 동일한 기존 맨홀 형태의 보라색 심볼을 사용한다.
const createExmnManholeStyle = () =>
new Style({
image: new Icon({
src: EXMN_RESULT_MANHOLE_ICON_SRC,
anchor: [0.5, 0.5],
anchorXUnits: "fraction",
anchorYUnits: "fraction",
}),
});
/** 선택한 도시침수지도 중앙에 침수예측 분석값을 표시하는 오버레이 스타일이다. */
// 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다.
const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const FLOW_DIRECTION_SRS = "EPSG:5179";
const GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO = 0.15;
const GIS_EDIT_PREVIEW_LAYER_ID = "SN_GIS_EDIT_PREVIEW";
const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES";
const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA";
const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE";
const WATER_RISK_MANHOLE_LAYER_ID = "SN_WATER_RISK_MANHOLE";
const SHORT_TERM_FORECAST_GRID_LAYER_NAME = "초단기예보격자망";
const FLDNG_HSTRY_SELECTION_PADDING: [number, number, number, number] = [
100, 380, 120, 120,
];
const RIGHT_PANEL_TAB_LIMIT = 5;
// Only pipes opened from sinkhole details share one slot within the parent-return flow.
const RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID =
"feature:tb_ms_swage_ppl:sinkhole-singleton";
const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [
120, 120, 120, 120,
];
const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [
50, 50, 50, 50,
];
// BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다.
const getFeatureCollection = (payload: any) => {
if (payload?.type === "FeatureCollection") return payload;
if (payload?.data?.type === "FeatureCollection") return payload.data;
return { type: "FeatureCollection", features: [] };
};
// 지반침하 목록 API가 정상일 때는 x/y를 바로 Point feature로 바꿔 geometry 전용 API 장애를 우회할 수 있다.
const buildSinkholeFeatureCollectionFromItems = (
items: SinkholeListItem[],
) => ({
type: "FeatureCollection",
features: items
.map((item) => {
const x = Number(item?.x);
const y = Number(item?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null;
}
return {
type: "Feature",
id: `${SINKHOLE_LAYER_TYPE}.${String(item?.acdntNo ?? "").trim()}`,
geometry: {
type: "Point",
coordinates: [x, y],
},
properties: {
...item,
lyrTypeNm: SINKHOLE_LAYER_TYPE,
x,
y,
},
};
})
.filter(Boolean),
});
const SINKHOLE_POINT_PALETTE = {
orange: {
fill: "rgba(214, 102, 31, 0.88)",
fillSelected: "rgba(255, 138, 0, 0.92)",
stroke: "rgba(255, 255, 255, 0.95)",
strokeSelected: "rgba(120, 53, 15, 0.98)",
radiusStroke: "rgba(214, 102, 31, 0.75)",
radiusFill: "rgba(251, 146, 60, 0.12)",
},
blue: {
fill: "rgba(37, 99, 235, 0.82)",
fillSelected: "rgba(37, 99, 235, 0.9)",
stroke: "rgba(255, 255, 255, 0.95)",
strokeSelected: "rgba(30, 64, 175, 0.98)",
radiusStroke: "rgba(37, 99, 235, 0.7)",
radiusFill: "rgba(96, 165, 250, 0.12)",
},
} as const;
type SinkholePointVariant = keyof typeof SINKHOLE_POINT_PALETTE;
const resolveSinkholePointVariant = (
_ocrnYmd?: string | null,
): SinkholePointVariant => "blue";
const expandGisEditPipeSnapExtent = (extent: number[]) => {
const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;
const width = Math.max(0, maxX - minX);
const height = Math.max(0, maxY - minY);
const bufferX = width * GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO;
const bufferY = height * GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO;
return [minX - bufferX, minY - bufferY, maxX + bufferX, maxY + bufferY];
};
const geometryObjectToKey = (geometry: Record<string, unknown> | null) =>
geometry ? JSON.stringify(geometry) : "";
const buildWaterRiskStatusFillColor = (riskStatus: "DANGER" | "NORMAL") =>
riskStatus === "DANGER"
? "rgba(198, 40, 40, 0.24)"
: "rgba(21, 101, 192, 0.2)";
const buildWaterRiskStatusStrokeColor = (riskStatus: "DANGER" | "NORMAL") =>
riskStatus === "DANGER"
? "rgba(198, 40, 40, 0.92)"
: "rgba(21, 101, 192, 0.92)";
/** capacity를 첨부 범례의 다섯 고정 구간색으로 변환한다. */
const buildWaterRiskCapacityColor = (capacityRatio: number) => {
if (capacityRatio <= 0.25) return "#0000ff";
if (capacityRatio <= 0.5) return "#00d9ff";
if (capacityRatio <= 0.75) return "#00d95f";
if (capacityRatio < 1) return "#ffe100";
return "#ff1c15";
};
const createWaterRiskAreaStyle = (feature: any) => {
const areaName = String(feature.get("areaName") ?? "");
const riskStatus = String(feature.get("riskStatus") ?? "NORMAL") as
| "DANGER"
| "NORMAL";
return new Style({
fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }),
stroke: new Stroke({
color: buildWaterRiskStatusStrokeColor(riskStatus),
width: 3,
}),
text: new Text({
text: `${areaName}\n${buildWaterRiskStatusLabel(riskStatus)}`,
font: "700 13px sans-serif",
fill: new Fill({ color: "#ffffff" }),
backgroundFill: new Fill({
color: buildWaterRiskStatusStrokeColor(riskStatus),
}),
padding: [5, 8, 5, 8],
overflow: true,
}),
zIndex: 9996,
});
};
const createWaterRiskPipeStyle = (feature: any) => {
const capacityRatio = Number(feature.get("capacityRatio") ?? 0);
const capacityColor = buildWaterRiskCapacityColor(capacityRatio);
return [
new Style({
stroke: new Stroke({
color: "rgba(255,255,255,0.98)",
width: 9,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9997,
}),
new Style({
stroke: new Stroke({
color: capacityColor,
width: 4,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9998,
}),
];
};
/** 맨홀 Flooding이 0이면 파랑, 양수이면 빨강으로 표시한다. */
const createWaterRiskManholeStyle = (feature: any) => {
const floodingFlow = Number(feature.get("floodingFlow") ?? 0);
const isFlooding = floodingFlow > 0;
return new Style({
image: new CircleStyle({
radius: isFlooding ? 7 : 4,
fill: new Fill({ color: isFlooding ? "#ff1c15" : "#1677ff" }),
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
}),
zIndex: 9999,
});
};
const getSelectedFeatureLayerTypeName = (data: any) => {
const previewLayerId = String(data?.get?.("layerId") ?? "").trim();
if (String(data?.get?.("changeId") ?? "").trim() && previewLayerId) {
return previewLayerId;
}
if (typeof data?.getId === "function") {
return String(data.getId() ?? "").split(".")[0];
}
if (typeof data?.id_ === "string") {
return String(data.id_).split(".")[0];
}
return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim();
};
const getRightPanelFeatureIdentifier = (data: any) => {
const rawId =
data?.get?.("changeId") ??
data?.get?.("fid") ??
data?.values_?.fid ??
data?.fid ??
data?.getId?.() ??
data?.id_ ??
data?.fcltMngNo ??
data?.values_?.fclt_mng_no ??
data?.swtrmtFcltCd ??
data?.values_?.swtrmt_fclt_cd ??
"";
return String(rawId).trim();
};
const getRightPanelFeatureTitle = (data: any) => {
const layerTypeName = getSelectedFeatureLayerTypeName(data);
switch (layerTypeName) {
case "tb_ms_swage_ppl":
return "하수관로";
case "tb_ms_swage_mhl":
return "하수맨홀";
case "tb_ms_swage_pmstn":
return "펌프장";
case "tb_ms_stofcbr":
return "우수토실";
case "tb_il_swage_prcs_fclt":
return "처리시설";
case "tb_ms_swage_prcs_zone":
return "처리구역";
case "tb_ms_fldng_wtlv_map":
return FLDNG_HSTRY_LAYER_NAME;
case "tb_ms_fldng_dpth_map":
return "침수흔적심선";
case "om_dt_untrt_swg_rain":
return "중점우수";
default:
return (
String(data?.lyrNm ?? data?.values_?.lyrNm ?? "상세").trim() || "상세"
);
}
};
// OpenLayers Feature와 plain object 양쪽에서 탭 표시값 후보를 같은 순서로 읽는다.
const getRightPanelFeatureProperty = (data: any, keys: string[]) => {
for (const key of keys) {
const value = data?.get?.(key) ?? data?.values_?.[key] ?? data?.[key];
const normalizedValue = String(value ?? "").trim();
if (normalizedValue) return normalizedValue;
}
return "";
};
const getRightPanelTabCategory = (data: any): RightPanelTabCategory => {
const surveyOverlayType = getRightPanelFeatureProperty(data, [
"surveyOverlayType",
]);
if (surveyOverlayType === "EXMN_RESULT") return "exmnSurvey";
const layerTypeName = getSelectedFeatureLayerTypeName(data);
if (layerTypeName === "tb_ms_swage_ppl") return "pipe";
if (layerTypeName === "tb_il_swage_prcs_fclt") return "treatmentFacility";
if (layerTypeName === "tb_ms_swage_prcs_zone") return "treatmentZone";
return "defaultFeature";
};
const getRightPanelFeatureDisplayLabel = (
data: any,
category: RightPanelTabCategory,
) => {
if (category === "exmnSurvey") {
const managementNo =
getRightPanelFeatureProperty(data, [
"gisManagementNo",
"fclt_mng_no",
"fcltMngNo",
]) || "-";
const jobType = getRightPanelFeatureProperty(data, ["jobType"]);
const surveyLabel =
jobType === "PPL"
? "관로조사"
: jobType === "MNHL"
? "맨홀조사"
: "조사관리";
return `${surveyLabel}(${managementNo})`;
}
if (category === "pipe") {
const managementNo =
getRightPanelFeatureProperty(data, [
"fclt_mng_no",
"fcltMngNo",
"pipeId",
"fid",
]) ||
getRightPanelFeatureIdentifier(data) ||
"-";
return `관로(${managementNo})`;
}
if (category === "treatmentFacility") {
const facilityName =
getRightPanelFeatureProperty(data, ["swtrmt_fclt_nm", "swtrmtFcltNm"]) ||
getRightPanelFeatureProperty(data, ["swtrmt_fclt_cd", "swtrmtFcltCd"]) ||
"-";
return `처리시설(${facilityName})`;
}
if (category === "treatmentZone") {
const zoneName =
getRightPanelFeatureProperty(data, ["swtrmt_zone_nm", "swtrmtZoneNm"]) ||
getRightPanelFeatureProperty(data, [
"swtrmt_zone_sn",
"swtrmtZoneSn",
"swtrmt_fclt_cd",
"swtrmtFcltCd",
]) ||
"-";
return `처리구역(${zoneName})`;
}
return getRightPanelFeatureTitle(data);
};
const createFldngHstryAreaStyle = (label: string) =>
new Style({
fill: new Fill({
color: "rgba(30, 136, 229, 0.16)",
}),
stroke: new Stroke({
color: "rgba(30, 136, 229, 0.95)",
width: 3,
}),
text: new Text({
text: label,
font: "700 12px sans-serif",
fill: new Fill({ color: "#ffffff" }),
backgroundFill: new Fill({ color: "rgba(15, 23, 42, 0.88)" }),
padding: [4, 7, 4, 7],
overflow: true,
}),
zIndex: 9994,
});
const createFldngHstryDepthStyle = (label: string, color: string) =>
new Style({
fill: new Fill({
color,
}),
stroke: new Stroke({
color: "rgba(71, 85, 105, 0.72)",
width: 1.4,
}),
text: new Text({
text: label,
font: "700 11px sans-serif",
fill: new Fill({ color: "#0f172a" }),
overflow: true,
}),
zIndex: 9993,
});
const normalizeGisEditFid = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
const withoutNamespace = raw.includes(":")
? (raw.split(":").pop() ?? raw)
: raw;
return withoutNamespace.startsWith("tb_ms_swage_ppl.")
? withoutNamespace.slice("tb_ms_swage_ppl.".length)
: withoutNamespace;
};
const getGisEditFeatureFid = (data: any) =>
normalizeGisEditFid(
data?.get?.("fid") ??
data?.values_?.fid ??
data?.fid ??
data?.getId?.() ??
data?.id_ ??
"",
);
const isGisEditPreviewFeatureData = (data: any) =>
Boolean(String(data?.get?.("changeId") ?? data?.changeId ?? "").trim());
const highlightGisEditSelectedFeature = (featureData: any) => {
if (
!featureData ||
typeof featureData.getGeometry !== "function" ||
typeof featureData.setStyle !== "function"
) {
return;
}
const select = GisApp.Module.select as any;
select?.selectFeature?.(featureData, true);
};
const isVisibleGisEditSessionChange = (
change: GisEditSessionSnapshot["changes"][number],
) => !change.attributeOnly || Boolean(change.basicInfoSavedAt);
const getLatestNumericEditSn = (histories: any[]) =>
histories.reduce<number | null>((latest, history) => {
const editSn = String(history?.editSn ?? "").trim();
if (!/^\d+$/.test(editSn)) return latest;
const value = Number(editSn);
return latest === null || value > latest ? value : latest;
}, null);
const getGisEditAttributeText = (
attributes: Record<string, unknown> | null | undefined,
keys: string[],
) => {
for (const key of keys) {
const value = String(attributes?.[key] ?? "").trim();
if (value) return value;
}
return "";
};
const buildGisEditHiddenPipeFilter = (fids: string[]) => {
const normalizedFids = Array.from(
new Set(
fids
.map((fid) => normalizeGisEditFid(fid))
.filter((fid) => /^\d+$/.test(fid)),
),
);
if (normalizedFids.length === 0) return null;
return `fid NOT IN (${normalizedFids.join(",")})`;
};
const findInvalidPipeRequiredChange = (
changes: Array<{
changeId: string;
editType: string;
clientTempId?: string;
attributes?: Record<string, unknown> | null;
}>,
) =>
changes.find((change) => {
const attributes = change.attributes ?? {};
const hasAttributePayload = Object.keys(attributes).length > 0;
if (change.editType !== "ADD" && !hasAttributePayload) return false;
const mngInstCd = getGisEditAttributeText(attributes, ["mngInstCd"]);
const fcltMngNo = getGisEditAttributeText(attributes, ["fcltMngNo"]);
const instMngNo = getGisEditAttributeText(attributes, ["instMngNo"]);
const usgCd = getGisEditAttributeText(attributes, ["usgCd"]);
return (
!mngInstCd ||
!fcltMngNo ||
/^NEW-/i.test(fcltMngNo) ||
!instMngNo ||
/^NEW-/i.test(instMngNo) ||
!usgCd
);
});
const getSinkholeSelectionValue = (data: any, key: string) =>
data?.[key] ??
data?.values_?.[key] ??
data?.get?.(key) ??
data?.properties?.[key];
const getSinkholeAccidentId = (data: any) =>
String(getSinkholeSelectionValue(data, "acdntNo") ?? "").trim();
const getSinkholeFallbackDetailFromSelection = (
data: any,
): Partial<SinkholeDetail> | null => {
const acdntNo = getSinkholeAccidentId(data);
if (!acdntNo) return null;
const point = getSinkholePointFromSelection(data);
return {
acdntNo,
ctpvNm: String(getSinkholeSelectionValue(data, "ctpvNm") ?? ""),
sggNm: String(getSinkholeSelectionValue(data, "sggNm") ?? ""),
emdNm: String(getSinkholeSelectionValue(data, "emdNm") ?? ""),
daddr: String(getSinkholeSelectionValue(data, "daddr") ?? ""),
fullAddress: String(
getSinkholeSelectionValue(data, "fullAddress") ??
getSinkholeSelectionValue(data, "daddr") ??
"",
),
ocrnYmd: String(getSinkholeSelectionValue(data, "ocrnYmd") ?? ""),
restoreStatus: String(
getSinkholeSelectionValue(data, "restoreStatus") ?? "",
),
restoreCompletedYmd: String(
getSinkholeSelectionValue(data, "restoreCompletedYmd") ?? "",
),
coordStatus: String(
getSinkholeSelectionValue(data, "coordStatus") ??
getSinkholeSelectionValue(data, "geocodeStatus") ??
"",
),
geocodeStatus: String(
getSinkholeSelectionValue(data, "geocodeStatus") ??
getSinkholeSelectionValue(data, "coordStatus") ??
"",
),
geocodeSource: String(
getSinkholeSelectionValue(data, "geocodeSource") ?? "",
),
geocodeErrorMessage: String(
getSinkholeSelectionValue(data, "geocodeErrorMessage") ?? "",
),
latitude: getSinkholeSelectionValue(data, "latitude") ?? null,
longitude: getSinkholeSelectionValue(data, "longitude") ?? null,
x: point?.x ?? getSinkholeSelectionValue(data, "x") ?? null,
y: point?.y ?? getSinkholeSelectionValue(data, "y") ?? null,
sizeWidth: getSinkholeSelectionValue(data, "sizeWidth") ?? null,
sizeLength: getSinkholeSelectionValue(data, "sizeLength") ?? null,
sizeDepth: getSinkholeSelectionValue(data, "sizeDepth") ?? null,
detailCause: getSinkholeSelectionValue(data, "detailCause") ?? null,
deathCount: getSinkholeSelectionValue(data, "deathCount") ?? null,
injuryCount: getSinkholeSelectionValue(data, "injuryCount") ?? null,
vehicleDamageCount:
getSinkholeSelectionValue(data, "vehicleDamageCount") ?? null,
};
};
const getSinkholePointFromSelection = (
data: any,
): { x: number; y: number } | null => {
const directX = Number(getSinkholeSelectionValue(data, "x"));
const directY = Number(getSinkholeSelectionValue(data, "y"));
if (Number.isFinite(directX) && Number.isFinite(directY)) {
return { x: directX, y: directY };
}
const geometry = data?.getGeometry?.();
if (geometry?.getType?.() === "Point") {
const coordinates = geometry.getCoordinates?.();
const x = Number(coordinates?.[0]);
const y = Number(coordinates?.[1]);
if (Number.isFinite(x) && Number.isFinite(y)) {
return { x, y };
}
}
return null;
};
const createSinkholePointStyle = (
variant: SinkholePointVariant = "blue",
selected = false,
) =>
new Style({
image: new CircleStyle({
radius: selected ? 9 : 7,
fill: new Fill({
color: selected
? SINKHOLE_POINT_PALETTE[variant].fillSelected
: SINKHOLE_POINT_PALETTE[variant].fill,
}),
stroke: new Stroke({
color: selected
? SINKHOLE_POINT_PALETTE[variant].strokeSelected
: SINKHOLE_POINT_PALETTE[variant].stroke,
width: selected ? 2.6 : 2,
}),
}),
zIndex: selected ? 9997 : 9994,
});
const createSinkholeRadiusStyle = (variant: SinkholePointVariant = "blue") =>
new Style({
stroke: new Stroke({
color: SINKHOLE_POINT_PALETTE[variant].radiusStroke,
width: 2.2,
lineDash: [8, 6],
}),
fill: new Fill({
color: SINKHOLE_POINT_PALETTE[variant].radiusFill,
}),
zIndex: 9993,
});
// 반경 원 상단에 실제 적용 거리값을 표시하는 선택 전용 지도 라벨 스타일이다.
const createSinkholeRadiusLabelStyle = (
radiusMeters: number,
variant: SinkholePointVariant = "blue",
) =>
new Style({
text: new Text({
text: `반경 ${radiusMeters.toLocaleString("ko-KR")}m`,
font: "600 13px Pretendard, sans-serif",
textAlign: "center",
offsetY: -14,
padding: [4, 7, 4, 7],
fill: new Fill({ color: SINKHOLE_POINT_PALETTE[variant].strokeSelected }),
stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 3 }),
backgroundFill: new Fill({ color: "rgba(255, 255, 255, 0.9)" }),
backgroundStroke: new Stroke({
color: SINKHOLE_POINT_PALETTE[variant].radiusStroke,
width: 1.5,
}),
}),
zIndex: 9998,
});
const createSinkholeFocusedPipeStyle = () => [
new Style({
stroke: new Stroke({
color: "rgba(88, 28, 135, 0.22)",
width: 18,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9997,
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 255, 255, 0.96)",
width: 12,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9998,
}),
new Style({
stroke: new Stroke({
color: "rgba(37, 99, 235, 0.98)",
width: 8,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9999,
}),
];
const isTypingElement = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return (
tagName === "input" ||
tagName === "textarea" ||
tagName === "select" ||
target.isContentEditable
);
};
// 레이어 설정의 직접 코드와 filterSql 안의 usg_cd 조건을 모두 관종 코드 후보로 해석한다.
const extractUsageCodesFromLayerInfo = (layerInfo: any) => {
const usageCodes = new Set<string>();
const directCode = layerInfo?.usgCd ?? layerInfo?.usg_cd;
if (directCode) usageCodes.add(String(directCode).trim());
const filterSql = String(layerInfo?.flterSql ?? layerInfo?.filterSql ?? "");
const equalsMatches = filterSql.matchAll(/usg_cd\s*=\s*'([^']+)'/gi);
for (const match of equalsMatches) {
const code = match[1]?.trim();
if (code) usageCodes.add(code);
}
const inMatches = filterSql.matchAll(/usg_cd\s+in\s*\(([^)]+)\)/gi);
for (const match of inMatches) {
const codeList = match[1];
if (!codeList) continue;
codeList
.split(",")
.map((item) => item.replace(/'/g, "").trim())
.filter(Boolean)
.forEach((code) => usageCodes.add(code));
}
return Array.from(usageCodes);
};
// 현재 줌에서 실제 표출 가능한 관로 레이어인지 확인해 숨겨진 관종의 유하방향 요청을 막는다.
const isLayerVisibleAtZoom = (layerInfo: any, zoom: number) => {
const minZoom = getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom);
return minZoom === null || zoom >= minZoom;
};
// 현재 체크되어 화면에 보이는 관로 레이어의 관종 코드와 선 색상을 유하방향 조회/스타일 기준으로 변환한다.
const extractCheckedPipeUsageStyles = (
checkedNodesData: any[],
zoom: number,
) => {
const usageStyleMap: Record<string, FlowDirectionUsageStyle> = {};
checkedNodesData.forEach((node) => {
const data = node?.data ?? node;
if (!data || isFlowDirectionLayerInfo(data)) return;
if (getLayerTypeName(data) !== FLOW_DIRECTION_PIPE_LAYER_TYPE) return;
if (!isLayerVisibleAtZoom(data, zoom)) return;
const usageCodes = extractUsageCodesFromLayerInfo(data);
if (usageCodes.length === 0) return;
const layerColor = String(
data.lnColor ?? data.lineColor ?? FLOW_DIRECTION_DEFAULT_COLOR,
).trim();
usageCodes.forEach((code) => {
usageStyleMap[code] = {
color: layerColor,
lineColor: withAlpha(layerColor, 0.28),
};
});
});
return usageStyleMap;
};
const appendAgedPipeControlNode = (data: any[]) => {
if (!Array.isArray(data)) return data;
const isAgedPipeControlNode = (item: any) =>
String(item.lyrSn ?? item.id) === AGED_PIPE_CONTROL_NODE_ID ||
item.lyrNm === "노후관로";
if (data.some(isAgedPipeControlNode)) {
return data.map((item) =>
isAgedPipeControlNode(item)
? {
...item,
gobjTy: "line",
iconNm: null,
lnColor: AGED_PIPE_HIGHLIGHT_COLOR,
}
: item,
);
}
return [
...data,
{
lyrSn: AGED_PIPE_CONTROL_NODE_ID,
id: AGED_PIPE_CONTROL_NODE_ID,
lyrNm: "노후관로",
upperLyrSn: null,
upId: null,
sortOrdr: "4",
drawOrdr: null,
flterSql: null,
gobjTy: "line",
lyrTyNm: "tb_old_pipe",
useYn: "Y",
minViewSize: null,
iconNm: null,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
fontIntnColor: null,
fontExtnColor: null,
fontExtnWdth: null,
lnWdth: null,
lnColor: AGED_PIPE_HIGHLIGHT_COLOR,
polgnColor: null,
initEprssYn: "Y",
clickPsblYn: "N",
exprsYn: "Y",
cmncNm: "all",
srvcNm: "wfs",
actlLyrYn: "N",
clckZoom: null,
children: null,
},
];
};
const normalizeTreeRowSpacing = (data: any[]) => {
if (!Array.isArray(data)) return data;
return data.map((item) => {
if (item?.lyrNm !== "지반침하정보") {
return item;
}
return {
...item,
iconNm: item.iconNm || SINKHOLE_TREE_ICON_NAME,
gobjTy: "",
};
});
};
const appendSinkholeControlNode = (data: any[]) => {
if (!Array.isArray(data)) return data;
const existingRoot = data.find((item) => {
const nodeId = String(item?.lyrSn ?? item?.id ?? "");
const layerName = String(item?.lyrNm ?? item?.name ?? "").trim();
const layerTypeName = String(
item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "",
).trim();
return (
nodeId === SINKHOLE_CONTROL_NODE_ID ||
layerName === SINKHOLE_LAYER_NAME ||
layerTypeName === SINKHOLE_LAYER_TYPE
);
});
const existingRootId =
existingRoot?.lyrSn ?? existingRoot?.id ?? SINKHOLE_CONTROL_NODE_ID;
const filtered = data.filter((item) => {
const nodeId = String(item?.lyrSn ?? item?.id ?? "");
const upperId = String(item?.upperLyrSn ?? item?.upId ?? "");
const layerName = String(item?.lyrNm ?? item?.name ?? "").trim();
const layerTypeName = String(
item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "",
).trim();
if (nodeId === String(existingRootId)) return false;
if (upperId === String(existingRootId)) return false;
if (!existingRoot && nodeId === SINKHOLE_CONTROL_NODE_ID) return false;
if (!existingRoot && upperId === SINKHOLE_CONTROL_NODE_ID) return false;
if (!existingRoot && layerName === SINKHOLE_LAYER_NAME) return false;
if (!existingRoot && layerTypeName === SINKHOLE_LAYER_TYPE) return false;
return true;
});
const rootNode = existingRoot
? {
...existingRoot,
lyrSn: existingRootId,
id: existingRootId,
gobjTy: "",
iconNm: existingRoot.iconNm || SINKHOLE_TREE_ICON_NAME,
clickPsblYn: "N",
actlLyrYn: "N",
children: null,
}
: {
lyrSn: SINKHOLE_CONTROL_NODE_ID,
id: SINKHOLE_CONTROL_NODE_ID,
lyrNm: SINKHOLE_LAYER_NAME,
upperLyrSn: null,
upId: null,
sortOrdr: "3",
drawOrdr: null,
flterSql: null,
gobjTy: "",
lyrTyNm: SINKHOLE_LAYER_TYPE,
useYn: "Y",
minViewSize: null,
iconNm: SINKHOLE_TREE_ICON_NAME,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
fontIntnColor: null,
fontExtnColor: null,
fontExtnWdth: null,
lnWdth: null,
lnColor: null,
polgnColor: null,
initEprssYn: "Y",
clickPsblYn: "N",
exprsYn: "Y",
cmncNm: "all",
srvcNm: "api",
actlLyrYn: "N",
clckZoom: null,
children: null,
};
return [...filtered, rootNode];
};
const Map = () => {
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 [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 [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 fldngHstryYearFilterSignatureRef = useRef("");
const [isExmnPplChecked, setIsExmnPplChecked] = useState(false);
const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false);
const [exmnPplFilters, setExmnPplFilters] = useState<ExmnMapFilters>(() => ({
...EMPTY_EXMN_MAP_FILTERS,
bizCycl: exmnMapEntryContext.bizCycl,
}));
const [exmnMnhlFilters, setExmnMnhlFilters] = useState<ExmnMapFilters>(
() => ({
...EMPTY_EXMN_MAP_FILTERS,
bizCycl: exmnMapEntryContext.bizCycl,
}),
);
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();
// 조사시설 관로번호는 전국에서 중복될 수 있어 시군구코드 앞 5자리와 함께 GIS 원천을 조회한다.
const exmnMapRegionCodes = useMemo(() => {
const ctpvOption = ctpv.find(
(option) =>
String(option.text ?? "").trim() === exmnMapEntryContext.ctpvNm,
);
const ctpvCd = String(ctpvOption?.val ?? "0").trim() || "0";
const sggOption = sgg.find((option) => {
const code = String(option.val ?? "").trim();
return (
String(option.text ?? "").trim() === exmnMapEntryContext.sggNm &&
(ctpvCd === "0" || code.startsWith(ctpvCd.slice(0, 2)))
);
});
return {
ctpvCd,
sggCd: String(sggOption?.val ?? "0").trim() || "0",
};
}, [ctpv, exmnMapEntryContext.ctpvNm, exmnMapEntryContext.sggNm, sgg]);
// 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);
// 침수발생이력 overlay layer는 지도 드래그 중에도 유지돼야 하므로 React state 대신 ref로 보관한다.
const fldngHstryAreaLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const fldngHstryDepthLayerRef = useRef<VectorLayer<VectorSource> | 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);
// vertex Delete는 현재 선택된 주황색 실꼭지점 묶음을 ref로 기억해 키보드 이벤트에서 즉시 사용한다.
const gisEditSelectedVertexHandleKeyRef = useRef("");
const gisEditSelectedVertexMetaListRef = useRef<VertexHandleMeta[] | null>(
null,
);
const gisEditMoveGripMetaRef = useRef<{
changeId: string;
meta: VertexHandleMeta | null;
} | null>(null);
const waterRiskPopupWindowRef = useRef<Window | null>(null);
const waterRiskPopupCloseWatcherRef = useRef<number | null>(null);
const sinkholeLayerRef = useRef<VectorLayer<any> | null>(null);
const sinkholeSourceRef = useRef<VectorSource | null>(null);
const sinkholeSelectedLayerRef = useRef<VectorLayer<any> | null>(null);
const sinkholeSelectedSourceRef = useRef<VectorSource | null>(null);
const sinkholeFocusedPipeLayerRef = useRef<VectorLayer<any> | null>(null);
const sinkholeFocusedPipeSourceRef = useRef<VectorSource | null>(null);
const exmnPplLayerRef = useRef<VectorLayer<any> | null>(null);
const exmnPplSourceRef = useRef<VectorSource | null>(null);
const exmnMnhlLayerRef = useRef<VectorLayer<any> | null>(null);
const exmnMnhlSourceRef = useRef<VectorSource | null>(null);
const exmnPplRequestSeqRef = useRef(0);
const exmnMnhlRequestSeqRef = useRef(0);
const exmnInitialRegionAppliedRef = useRef(false);
// 조사관리에서 지도보기로 최초 진입했을 때만 조회된 대표 시설 한 건으로 지도를 맞춘다.
const exmnInitialFeatureFitAppliedRef = useRef(false);
// 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다.
const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false);
// 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다.
const [flowDirectionLayerInfo, setFlowDirectionLayerInfo] = useState<
any | null
>(null);
// 관종 레이어 체크 변경도 유하방향 재조회 조건이므로 별도 버전 state로 effect를 다시 실행한다.
const [flowDirectionSelectionVersion, setFlowDirectionSelectionVersion] =
useState(0);
// 줌레벨 컨트롤에 현재 체크된 특수 레이어의 최소 표출 기준을 안내한다.
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]);
// moveend 이벤트 핸들러는 최신 state closure를 보장하지 않으므로 현재 체크 여부를 ref로 함께 보관한다.
const flowDirectionCheckedRef = useRef(false);
// 지도 이동 후 재조회할 때 최신 유하방향 레이어 설정을 참조하기 위한 ref이다.
const flowDirectionLayerInfoRef = useRef<any | null>(null);
// 유하방향 API의 관종 필터를 만들기 위해 현재 체크된 트리 노드 목록을 ref로 보관한다.
const checkedNodesDataRef = useRef<any[]>([]);
// 유하방향 전용 vector layer/source를 유지해 지도 이동 시 기존 피처를 지우지 않고 새 BBOX 응답만 병합한다.
const flowDirectionLayerRef = useRef<any | null>(null);
const flowDirectionSourceRef = useRef<VectorSource | null>(null);
// 같은 관로가 겹치는 BBOX에서 반복 조회될 수 있으므로 고유키와 삽입 순서를 캐시로 관리한다.
const flowDirectionFeatureKeySetRef = useRef<Set<string>>(new Set());
const flowDirectionFeatureKeyQueueRef = useRef<string[]>([]);
// 관종 체크 변경 시 다른 관종 유하방향을 유지하기 위해 현재 source에 남겨둘 관종 코드를 별도로 추적한다.
const flowDirectionVisibleUsageCodesRef = useRef<Set<string>>(new Set());
// 관종 체크 변경으로 새로 켜진 usgCd만 다음 조회 대상으로 넘겨 기존 관종 재요청을 막는다.
const flowDirectionSelectionRequestUsageCodesRef = useRef<string[] | null>(
null,
);
// 체크 해제나 재조회 중 늦게 도착한 이전 API 응답이 레이어를 다시 그리지 못하도록 요청 순번을 관리한다.
const flowDirectionRequestSeqRef = useRef(0);
// 지도 이동이 연속으로 발생하면 이전 유하방향 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다.
const flowDirectionAbortControllerRef = useRef<AbortController | null>(null);
// 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다.
const flowDirectionFailureAlertedRef = useRef(false);
const gisEditPreviewLayerRef = useRef<any | null>(null);
const gisEditPreviewSourceRef = useRef<VectorSource | null>(null);
// 신규 관로마다 한 번만 우측 상세 패널을 피한 가시 영역으로 이동하기 위한 change 식별자다.
const gisEditAutoFocusedAddChangeIdRef = useRef("");
const gisEditPipeSnapSourceRef = useRef<VectorSource | null>(null);
const gisEditPipeSnapAbortControllerRef = useRef<AbortController | null>(
null,
);
const gisEditPipeSnapRequestSeqRef = useRef(0);
const gisEditOriginalLayerFilterRef = useRef<
globalThis.Map<any, string | null>
>(new globalThis.Map());
const gisEditOriginalFeatureStyleRef = useRef<globalThis.Map<any, any>>(
new globalThis.Map(),
);
const gisEditVertexLayerRef = useRef<VectorLayer<any> | null>(null);
const gisEditVertexSourceRef = useRef<VectorSource | null>(null);
const waterRiskAreaLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskAreaSourceRef = useRef<VectorSource | null>(null);
const waterRiskPipeLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskPipeSourceRef = useRef<VectorSource | null>(null);
const waterRiskManholeLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskManholeSourceRef = useRef<VectorSource | null>(null);
// 침수예측현황 선택행에 따라 초단기예보 격자 중앙 라벨을 교체하는 전용 overlay를 보관한다.
const floodPredictionGridLayerRef = useRef<Overlay | null>(null);
const gisEditDrawRef = useRef<any | null>(null);
const gisEditDrawSnapRef = useRef<any | null>(null);
const gisEditTranslateRef = useRef<any | null>(null);
const gisEditModifyRef = useRef<any | null>(null);
// 스냅 interaction은 MOVE/VERTEX_EDIT 진입마다 다시 붙이므로 별도 ref로 정리한다.
const gisEditSnapRef = useRef<any | null>(null);
const [waterRiskSession, setWaterRiskSession] =
useState<WaterRiskSessionSnapshot>(EMPTY_WATER_RISK_SESSION_SNAPSHOT);
// 팝업에서 실제 API로 조회한 결과 행과 GeoJSON을 지도·정보카드의 단일 원본으로 보관한다.
const [selectedWaterRiskApiResult, setSelectedWaterRiskApiResult] =
useState<WaterRiskResultRow | null>(null);
const [selectedWaterRiskMapView, setSelectedWaterRiskMapView] =
useState<WaterRiskMapView | null>(null);
const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<
[number, number] | null
>(null);
const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState<Record<
string,
number
> | null>(null);
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 waterRiskSessionSnapshot = useMemo<WaterRiskSessionSnapshot>(
() => waterRiskSession,
[waterRiskSession],
);
const syncGisEditSessionPopup = useCallback(() => {
const popup = gisEditSessionPopupWindowRef.current;
if (!popup || popup.closed) {
gisEditSessionPopupWindowRef.current = null;
setGisEditSessionView("inline");
return;
}
popup.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC,
payload: gisEditSessionSnapshot,
},
window.location.origin,
);
}, [gisEditSessionSnapshot]);
const closeGisEditSessionPopup = useCallback(() => {
if (gisEditSessionPopupCloseWatcherRef.current) {
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
gisEditSessionPopupCloseWatcherRef.current = null;
}
const popup = gisEditSessionPopupWindowRef.current;
if (popup && !popup.closed) {
popup.close();
}
gisEditSessionPopupWindowRef.current = null;
setGisEditSessionView("inline");
}, []);
useEffect(
() => () => {
if (gisEditSessionPopupCloseWatcherRef.current) {
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
gisEditSessionPopupCloseWatcherRef.current = null;
}
},
[],
);
const openGisEditPipeDetail = useCallback((changeId: string | null) => {
if (!changeId) {
setGisEditDetailChangeId(null);
return;
}
setGisEditDetailChangeId(changeId);
}, []);
const focusGisEditChangeOnMap = useCallback(
(changeId: string) => {
const map = gisMaps.current;
const change = gisEdit.changes.find((item) => item.changeId === changeId);
const geometryObject =
change?.editType === "DELETE"
? change?.beforeGeometry
: change?.afterGeometry;
if (!map || !geometryObject) return;
try {
const geometry = new GeoJSON().readGeometry(geometryObject, {
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
});
const extent = geometry?.getExtent?.();
if (
!extent ||
extent.length < 4 ||
extent.some((value: number) => !Number.isFinite(value))
) {
return;
}
const view = map.getView?.();
const currentZoom = Number(view?.getZoom?.());
const maxZoom =
Number.isFinite(currentZoom) && currentZoom > 18 ? currentZoom : 18;
view?.fit?.(extent, {
duration: 350,
padding: [80, 420, 80, 80],
maxZoom,
});
} catch (error) {
console.warn("GIS edit change focus failed:", error);
}
},
[gisEdit.changes],
);
// 인라인 세션과 윈도우 팝업이 동일한 지도 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,
],
);
const syncWaterRiskPopup = useCallback(() => {
const popup = waterRiskPopupWindowRef.current;
if (!popup || popup.closed) {
waterRiskPopupWindowRef.current = null;
return;
}
popup.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC,
payload: waterRiskSessionSnapshot,
},
window.location.origin,
);
}, [waterRiskSessionSnapshot]);
const selectedWaterRiskResultRow = useMemo(
() => selectedWaterRiskApiResult,
[selectedWaterRiskApiResult],
);
// 지도카드는 실제 GIS geometry까지 결합된 mapView 위험상태를 목록 상태보다 우선한다.
const selectedWaterRiskDisplayStatus = useMemo<
WaterRiskResultRow["riskStatus"]
>(
() =>
selectedWaterRiskMapView?.riskStatus ??
selectedWaterRiskResultRow?.riskStatus ??
"NORMAL",
[
selectedWaterRiskMapView?.riskStatus,
selectedWaterRiskResultRow?.riskStatus,
],
);
/** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */
const syncWaterRiskInfoCardPosition = useCallback(() => {
const map = gisMaps.current;
if (!map || !waterRiskInfoAnchor) {
setWaterRiskInfoCardStyle(null);
return;
}
const [mapWidth = 0, mapHeight = 0] = map.getSize?.() ?? [];
const [pixelX = 0, pixelY = 0] =
map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? [];
const cardWidth = 244;
const cardHeight = 180;
const left = pixelX + 22;
const top = pixelY - cardHeight / 2;
if (
pixelX < 0 ||
pixelY < 0 ||
pixelX > mapWidth ||
pixelY > mapHeight ||
left > mapWidth ||
top > mapHeight ||
left + cardWidth < 0 ||
top + cardHeight < 0
) {
setWaterRiskInfoCardStyle(null);
return;
}
setWaterRiskInfoCardStyle({ left, top });
}, [waterRiskInfoAnchor]);
const setGisEditPreviewCursor = useCallback((cursor: string) => {
const targetElement = gisMaps.current?.getTargetElement?.();
if (targetElement?.style) {
targetElement.style.cursor = cursor;
}
}, []);
/** 기존 침수예측 중앙 오버레이를 제거한다. */
const removeFloodPredictionGridLayer = useCallback(() => {
const core = GisApp.Module.core as any;
core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID);
const map = gisMaps.current;
if (map && floodPredictionGridLayerRef.current) {
map.removeOverlay(floodPredictionGridLayerRef.current);
}
floodPredictionGridLayerRef.current = null;
}, []);
/** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */
const loadFloodPredictionGridLayer = useCallback(
async (sggCd: string, analysisDateTime: string, extent: number[]) => {
const map = gisMaps.current;
if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime))
return;
removeFloodPredictionGridLayer();
try {
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`,
);
const payload = response?.data ?? response;
const row = payload && !Array.isArray(payload) ? payload : null;
if (
!row ||
extent.length !== 4 ||
extent.some((value) => !Number.isFinite(value))
)
return;
const center = [
(extent[0]! + extent[2]!) / 2,
(extent[1]! + extent[3]!) / 2,
];
const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(
1,
);
const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(
1,
);
const container = document.createElement("div");
container.style.cssText =
"position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; min-width:230px;";
const close = document.createElement("button");
close.type = "button";
close.textContent = "×";
close.title = "침수예측 오버레이 닫기";
close.style.cssText =
"position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;";
close.addEventListener("click", () => removeFloodPredictionGridLayer());
const table = document.createElement("table");
table.style.cssText = "border-collapse:collapse; width:100%;";
const title = table.createTHead().insertRow();
const titleCell = title.insertCell();
titleCell.colSpan = 2;
titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`;
titleCell.style.cssText =
"font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;";
const body = table.createTBody();
[
[
"분석시각",
formatFloodPredictionDateTime(
row.analysisDateTime ?? analysisDateTime,
),
],
[
"강우시작시각",
formatFloodPredictionDateTime(row.rainBeginningDateTime),
],
["관측 누적강수량(mm)", observed],
["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)],
["예측 누적강수량(mm)", predicted],
["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`],
["침수위험도", row.floodRiskGrade ?? "-"],
].forEach(([label, value]) => {
const tr = body.insertRow();
const key = tr.insertCell();
key.textContent = label;
key.style.cssText =
"font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
const val = tr.insertCell();
val.textContent = value;
val.style.cssText =
"font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
});
container.append(close, table);
const overlay = new Overlay({
element: container,
positioning: "center-center",
stopEvent: true,
});
overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID);
overlay.setPosition(center);
map.addOverlay(overlay);
floodPredictionGridLayerRef.current = overlay;
} catch (error) {
console.error("침수예측지도 오버레이 표시 실패:", error);
}
},
[removeFloodPredictionGridLayer],
);
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
if (
!mapReady ||
exmnInitialRegionAppliedRef.current ||
(!exmnMapEntryContext.ctpvNm && !exmnMapEntryContext.sggNm)
) {
return;
}
const { ctpvCd, sggCd } = exmnMapRegionCodes;
// 시군구가 전달된 진입에서는 시도만 먼저 적용해 버리지 않고 두 코드가 모두 준비될 때까지 기다린다.
if (
(exmnMapEntryContext.ctpvNm && ctpvCd === "0") ||
(exmnMapEntryContext.sggNm && sggCd === "0")
) {
return;
}
exmnInitialRegionAppliedRef.current = true;
window.dispatchEvent(
new CustomEvent(EXMN_PROC_SEARCH_EVENT, {
detail: {
ctpvCd,
sggCd,
},
}),
);
}, [
exmnMapEntryContext.ctpvNm,
exmnMapEntryContext.sggNm,
exmnMapRegionCodes,
mapReady,
]);
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,
]);
useEffect(() => {
const timer = window.setInterval(() => {
if (gisMaps.current) {
setMapReady(true);
window.clearInterval(timer);
}
}, 300);
const handlePopupMessage = async (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
const core = GisApp.Module.layer as any;
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE) {
const layerNm = data.payload?.layerNm;
const fid = data.payload?.fid;
if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return;
await core.showSearchResultsFromGeoJSON(layerNm, fid);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE) {
const layerNm = data.payload?.layerNm;
const fidsRaw = data.payload?.fids;
if (!layerNm || !fidsRaw || !core?.showMultipleSearchResultsFromGeoJSON)
return;
let fids: string[] = [];
try {
fids = JSON.parse(fidsRaw);
} catch (error) {
console.error("다중 시설 메시지 파싱 실패:", error);
return;
}
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FIT_EXTENT) {
const extent = Array.isArray(data.payload?.extent)
? data.payload.extent.map(Number)
: [];
const map = gisMaps.current;
if (
extent.length !== 4 ||
extent.some((value: number) => !Number.isFinite(value)) ||
!map?.getView
) {
return;
}
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
return;
}
if (
data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP
) {
const extent = Array.isArray(data.payload?.extent)
? data.payload.extent.map(Number)
: [];
const sggCd = String(data.payload?.sggCd ?? "").trim();
const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim();
const analysisDateTime = String(
data.payload?.analysisDateTime ?? "",
).trim();
const map = gisMaps.current;
const select = GisApp.Module.select as any;
if (
extent.length !== 4 ||
extent.some((value: number) => !Number.isFinite(value)) ||
!/^\d{5}$/.test(sggCd) ||
!/^(030|050|080|100)$/.test(frequency) ||
!map?.getView ||
!select?.selectFocusedBoundaryFeature
) {
return;
}
await select.resetSelectedFeature?.();
const selectedFeatureIds = new Set<string>();
let floodLayerVisible = false;
const selectLoadedGroupFeatures = (source: any) => {
const features = source?.getFeatures?.() ?? [];
features.forEach((feature: any) => {
const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim();
const featureFrequency = String(
feature.get?.("sbmrg_frq_expln") ?? "",
).trim();
if (featureSggCd !== sggCd || featureFrequency !== frequency)
return;
const featureId = String(
feature.getId?.() ?? feature.get?.("fid") ?? "",
);
if (featureId && selectedFeatureIds.has(featureId)) return;
if (featureId) selectedFeatureIds.add(featureId);
select.selectFocusedBoundaryFeature(feature, true);
});
};
map
.getLayers()
.getArray()
.forEach((layer: any) => {
if (
String(layer.get?.("lyrTyNm") ?? "").trim() !==
FLOOD_DNGR_MAP_LAYER_TYPE
)
return;
if (layer.getVisible?.() !== false) floodLayerVisible = true;
const source = layer.getSource?.();
if (!source?.getFeatures) return;
selectLoadedGroupFeatures(source);
source.once?.("featuresloadend", () =>
selectLoadedGroupFeatures(source),
);
});
if (!floodLayerVisible) {
(event.source as Window | null)?.postMessage?.(
{ type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED },
event.origin,
);
return;
}
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
void loadFloodPredictionGridLayer(sggCd, analysisDateTime, extent);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED) {
const rawLayerTypeNames = data.payload?.refreshLayerTypeNames;
let layerTypeNames = ["tb_ms_swage_ppl"];
if (rawLayerTypeNames) {
try {
const parsed = JSON.parse(rawLayerTypeNames);
if (Array.isArray(parsed) && parsed.length > 0) {
layerTypeNames = parsed
.map((item) => String(item ?? "").trim())
.filter(Boolean);
}
} catch (error) {
console.error("GIS 업로드 레이어 refresh 메시지 파싱 실패:", error);
}
}
const refreshTargetNames = new Set(layerTypeNames);
gisMaps.current?.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?.();
});
}
};
window.addEventListener("message", handlePopupMessage);
return () => {
window.clearInterval(timer);
window.removeEventListener("message", handlePopupMessage);
};
}, [loadFloodPredictionGridLayer]);
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",
);
setTreeData(
appendExmnResultControlNodes(
appendAgedPipeControlNode(
appendSinkholeControlNode(
normalizeTreeRowSpacing(
normalizeRainObservationLayerInteraction(
normalizeFlowDirectionLayerInfos(data),
),
),
),
),
exmnMapEntryContext.jobType,
),
);
setLayerLoadError(null);
} catch (error) {
const message =
error instanceof Error
? error.message
: "레이어 설정 조회 중 알 수 없는 오류가 발생했습니다.";
console.error("레이어 설정 조회 실패:", error);
setTreeData([]);
setLayerLoadError(message);
}
};
const clearWaterRiskPopupCloseWatcher = useCallback(() => {
if (waterRiskPopupCloseWatcherRef.current !== null) {
window.clearInterval(waterRiskPopupCloseWatcherRef.current);
waterRiskPopupCloseWatcherRef.current = null;
}
}, []);
const ensureWaterRiskAreaLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (waterRiskAreaLayerRef.current && waterRiskAreaSourceRef.current) {
const layers = map.getLayers?.().getArray?.() ?? [];
if (!layers.includes(waterRiskAreaLayerRef.current)) {
map.addLayer(waterRiskAreaLayerRef.current);
}
waterRiskAreaLayerRef.current.setZIndex?.(9996);
return waterRiskAreaSourceRef.current;
}
// 통수위험 overlay 초기화는 자신의 stale layer만 정리해 GIS 편집 preview를 보존한다.
const layers = map.getLayers?.().getArray?.() ?? [];
layers.forEach((existingLayer: any) => {
if (existingLayer?.get?.("id") === WATER_RISK_AREA_LAYER_ID) {
map.removeLayer(existingLayer);
}
});
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9996,
});
layer.set("id", WATER_RISK_AREA_LAYER_ID);
layer.set("lyrNm", "통수위험 중점관리지역");
layer.set("lyrTyNm", WATER_RISK_AREA_LAYER_ID);
map.addLayer(layer);
waterRiskAreaLayerRef.current = layer;
waterRiskAreaSourceRef.current = source;
return source;
}, []);
const ensureWaterRiskPipeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (waterRiskPipeLayerRef.current && waterRiskPipeSourceRef.current) {
return waterRiskPipeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9998,
});
layer.set("id", WATER_RISK_PIPE_LAYER_ID);
layer.set("lyrNm", "통수위험 유출관로");
layer.set("lyrTyNm", WATER_RISK_PIPE_LAYER_ID);
map.addLayer(layer);
waterRiskPipeLayerRef.current = layer;
waterRiskPipeSourceRef.current = source;
return source;
}, []);
const ensureWaterRiskManholeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (waterRiskManholeLayerRef.current && waterRiskManholeSourceRef.current) {
return waterRiskManholeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({ source, zIndex: 9999 });
layer.set("id", WATER_RISK_MANHOLE_LAYER_ID);
layer.set("lyrNm", "통수위험 맨홀 Flooding");
layer.set("lyrTyNm", WATER_RISK_MANHOLE_LAYER_ID);
map.addLayer(layer);
waterRiskManholeLayerRef.current = layer;
waterRiskManholeSourceRef.current = source;
return source;
}, []);
const clearWaterRiskLayers = useCallback(() => {
waterRiskAreaSourceRef.current?.clear();
waterRiskPipeSourceRef.current?.clear();
waterRiskManholeSourceRef.current?.clear();
}, []);
/** 통수위험 팝업 종료 시 지도 강조·범례·정보카드 선택상태를 모두 초기화한다. */
const clearWaterRiskSelection = useCallback(() => {
clearWaterRiskLayers();
setWaterRiskSession(EMPTY_WATER_RISK_SESSION_SNAPSHOT);
setSelectedWaterRiskApiResult(null);
setSelectedWaterRiskMapView(null);
setWaterRiskInfoAnchor(null);
setWaterRiskInfoCardStyle(null);
}, [clearWaterRiskLayers]);
const renderWaterRiskSelection = useCallback(
(areaId: string | null, mapView: WaterRiskMapView | null) => {
const map = gisMaps.current;
if (!map) return;
const areaSource = ensureWaterRiskAreaLayer();
if (!areaSource) return;
areaSource.clear();
const pipeSource = ensureWaterRiskPipeLayer();
pipeSource?.clear();
const manholeSource = ensureWaterRiskManholeLayer();
manholeSource?.clear();
if (!areaId || !mapView || mapView.areaId !== areaId) return;
const geoJson = new GeoJSON();
const projection = mapView.projection || "EPSG:5179";
let focusGeometry: any | null = null;
if (mapView.areaGeometry) {
const geometry = geoJson.readGeometry(mapView.areaGeometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const areaFeature = new Feature({
geometry,
areaId: mapView.areaId,
areaName: mapView.areaName,
riskStatus: mapView.riskStatus,
});
areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature));
areaSource.addFeature(areaFeature);
focusGeometry = geometry;
}
mapView.pipes.forEach((pipe) => {
if (!pipeSource || !pipe.geometry) return;
const geometry = geoJson.readGeometry(pipe.geometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const pipeFeature = new Feature({
geometry,
pipeId: pipe.pipeId,
displayLabel: pipe.displayLabel || pipe.pipeId,
capacityRatio: pipe.capacityRatio ?? 0,
});
pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature));
pipeSource.addFeature(pipeFeature);
if (!focusGeometry) focusGeometry = geometry;
});
mapView.manholes.forEach((manhole) => {
if (!manholeSource || !manhole.geometry) return;
const geometry = geoJson.readGeometry(manhole.geometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const manholeFeature = new Feature({
geometry,
manholeId: manhole.manholeId,
displayLabel: manhole.displayLabel || manhole.manholeId,
floodingFlow: manhole.floodingFlow ?? 0,
});
manholeFeature.setStyle(createWaterRiskManholeStyle(manholeFeature));
manholeSource.addFeature(manholeFeature);
if (!focusGeometry) focusGeometry = geometry;
});
if (!focusGeometry) return;
const extent = focusGeometry.getExtent();
const [, minY = 0, maxX = 0, maxY = 0] = extent;
setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]);
map.getView().fit(extent, {
duration: 300,
padding: [120, 120, 120, 120],
maxZoom: 15,
constrainResolution: true,
nearest: true,
});
},
[
ensureWaterRiskAreaLayer,
ensureWaterRiskManholeLayer,
ensureWaterRiskPipeLayer,
],
);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
if (!waterRiskSession.selectedAreaId) {
clearWaterRiskLayers();
setWaterRiskInfoAnchor(null);
setWaterRiskInfoCardStyle(null);
return;
}
renderWaterRiskSelection(
waterRiskSession.selectedAreaId,
selectedWaterRiskMapView,
);
}, [
clearWaterRiskLayers,
mapReady,
renderWaterRiskSelection,
selectedWaterRiskMapView,
waterRiskSession.selectedAreaId,
]);
useEffect(() => {
if (!selectedWaterRiskMapView) setWaterRiskInfoAnchor(null);
}, [selectedWaterRiskMapView]);
useEffect(() => {
const popup = waterRiskPopupWindowRef.current;
if (!popup || popup.closed) return;
syncWaterRiskPopup();
}, [syncWaterRiskPopup, waterRiskSession]);
useEffect(() => {
if (!mapReady || !gisMaps.current || !waterRiskInfoAnchor) return;
const map = gisMaps.current;
const syncDuringMapRender = () => syncWaterRiskInfoCardPosition();
syncWaterRiskInfoCardPosition();
map.on?.("postrender", syncDuringMapRender);
map.on?.("moveend", syncDuringMapRender);
return () => {
map.un?.("postrender", syncDuringMapRender);
map.un?.("moveend", syncDuringMapRender);
};
}, [mapReady, syncWaterRiskInfoCardPosition, waterRiskInfoAnchor]);
useEffect(() => {
const handleWaterRiskPopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY) {
syncWaterRiskPopup();
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) {
const areaId = String(data.payload?.areaId ?? "").trim();
if (!areaId) return;
const resultRow = data.payload?.resultRow as
| WaterRiskResultRow
| undefined;
const mapView = data.payload?.mapView as WaterRiskMapView | undefined;
if (!resultRow || !mapView || mapView.areaId !== areaId) return;
setSelectedWaterRiskApiResult(resultRow);
setSelectedWaterRiskMapView(mapView);
setWaterRiskSession((current) => ({
...current,
selectedAreaId: areaId,
}));
setCheckedLayerRequest((current) => ({
requestKey: (current?.requestKey ?? 0) + 1,
layerNames: [SHORT_TERM_FORECAST_GRID_LAYER_NAME],
}));
setIsOpen(false);
setFeatureData(null);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLOSED) {
clearWaterRiskPopupCloseWatcher();
waterRiskPopupWindowRef.current = null;
clearWaterRiskSelection();
return;
}
};
window.addEventListener("message", handleWaterRiskPopupMessage);
return () => {
window.removeEventListener("message", handleWaterRiskPopupMessage);
};
}, [
clearWaterRiskPopupCloseWatcher,
clearWaterRiskSelection,
syncWaterRiskPopup,
]);
useEffect(() => {
return () => {
clearWaterRiskPopupCloseWatcher();
const popup = waterRiskPopupWindowRef.current;
if (popup && !popup.closed) {
popup.close();
}
waterRiskPopupWindowRef.current = null;
};
}, [clearWaterRiskPopupCloseWatcher]);
// 유하방향은 일반 Gis core 레이어 등록 흐름을 타지 않으므로 전용 layer/source/cache를 직접 정리한다.
const removeFlowDirectionLayer = useCallback(() => {
const core = GisApp.Module.core as any;
core?.deleteLayer?.(FLOW_DIRECTION_LAYER_TYPE);
flowDirectionLayerRef.current = null;
flowDirectionSourceRef.current = null;
flowDirectionFeatureKeySetRef.current.clear();
flowDirectionFeatureKeyQueueRef.current = [];
flowDirectionVisibleUsageCodesRef.current.clear();
}, []);
// 유하방향 cache/source만 비워 관종 변경처럼 조건이 달라진 경우 이전 조건의 피처가 남지 않게 한다.
const clearFlowDirectionFeatureCache = useCallback(() => {
flowDirectionSourceRef.current?.clear();
flowDirectionFeatureKeySetRef.current.clear();
flowDirectionFeatureKeyQueueRef.current = [];
flowDirectionVisibleUsageCodesRef.current.clear();
}, []);
// 체크 해제된 관종의 유하방향 feature/cache만 제거하고, 새로 체크된 관종 코드만 다음 요청 대상으로 돌려준다.
const retainFlowDirectionUsageCodes = useCallback((usageCodes: string[]) => {
const nextUsageCodes = new Set(usageCodes);
const addedUsageCodes = usageCodes.filter(
(usageCode) => !flowDirectionVisibleUsageCodesRef.current.has(usageCode),
);
const source = flowDirectionSourceRef.current;
if (source) {
source.getFeatures().forEach((feature: any) => {
const usageCode = String(
feature.get("usgCd") ?? feature.get("usg_cd") ?? "",
).trim();
if (nextUsageCodes.has(usageCode)) return;
const featureKey = feature.get("flowDirectionCacheKey");
if (featureKey) {
flowDirectionFeatureKeySetRef.current.delete(String(featureKey));
}
source.removeFeature(feature);
});
flowDirectionFeatureKeyQueueRef.current =
flowDirectionFeatureKeyQueueRef.current.filter((featureKey) =>
flowDirectionFeatureKeySetRef.current.has(featureKey),
);
}
flowDirectionVisibleUsageCodesRef.current = new Set(
Array.from(flowDirectionVisibleUsageCodesRef.current).filter(
(usageCode) => nextUsageCodes.has(usageCode),
),
);
return addedUsageCodes;
}, []);
useEffect(() => {
if (!flowDirectionCheckedRef.current) return;
clearFlowDirectionFeatureCache();
flowDirectionSelectionRequestUsageCodesRef.current = null;
setFlowDirectionSelectionVersion((version) => version + 1);
}, [clearFlowDirectionFeatureCache, flowDirectionMode]);
// 유하방향 레이어는 하나만 유지하고 source에 피처를 누적한다.
const ensureFlowDirectionLayer = useCallback((layerInfo: any) => {
const map = gisMaps.current;
if (!map) return null;
if (flowDirectionLayerRef.current && flowDirectionSourceRef.current) {
flowDirectionLayerRef.current.setMinZoom?.(
getFlowDirectionMinZoom(layerInfo),
);
flowDirectionLayerRef.current.setZIndex?.(
getFlowDirectionZIndex(layerInfo),
);
return flowDirectionSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
minZoom: getFlowDirectionMinZoom(layerInfo),
zIndex: getFlowDirectionZIndex(layerInfo),
updateWhileAnimating: false,
updateWhileInteracting: false,
});
layer.set("id", FLOW_DIRECTION_LAYER_TYPE);
layer.set("lyrNm", FLOW_DIRECTION_LAYER_NAME);
layer.set("lyrTyNm", FLOW_DIRECTION_LAYER_TYPE);
layer.set("typeName", FLOW_DIRECTION_LAYER_TYPE);
layer.set("clickPsblYn", "N");
layer.set("chcYn", "N");
const core = GisApp.Module.core as any;
core?.deleteLayer?.(FLOW_DIRECTION_LAYER_TYPE);
map.addLayer(layer);
flowDirectionLayerRef.current = layer;
flowDirectionSourceRef.current = source;
return source;
}, []);
// BE에서 받은 보정 GeoJSON을 OpenLayers feature로 변환해 기존 유하방향 source에 중복 없이 병합한다.
const renderFlowDirectionLayer = useCallback(
(
featureCollection: any,
layerInfo: any,
usageStyleMap: Record<string, FlowDirectionUsageStyle>,
) => {
const source = ensureFlowDirectionLayer(layerInfo);
if (!source) return;
const features = new GeoJSON().readFeatures(featureCollection, {
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
});
const nextFeatures = features.filter((feature) => {
const featureKey = getFlowDirectionFeatureCacheKey(feature);
if (flowDirectionFeatureKeySetRef.current.has(featureKey)) return false;
feature.set("flowDirectionCacheKey", featureKey);
feature.setId(featureKey);
feature.setStyle(createFlowDirectionStyle(feature, usageStyleMap));
flowDirectionFeatureKeySetRef.current.add(featureKey);
flowDirectionFeatureKeyQueueRef.current.push(featureKey);
return true;
});
if (nextFeatures.length > 0) {
source.addFeatures(nextFeatures);
}
},
[ensureFlowDirectionLayer],
);
// 현재 지도 BBOX와 체크된 관종별 필터로 유하방향 GeoJSON을 각각 조회하고, 최신 요청만 지도에 반영한다.
const loadFlowDirectionLayer = useCallback(
async (layerInfo: any, requestOnlyUsageCodes?: string[]) => {
const map = gisMaps.current;
if (!map) return;
const requestSeq = flowDirectionRequestSeqRef.current + 1;
flowDirectionRequestSeqRef.current = requestSeq;
flowDirectionAbortControllerRef.current?.abort();
const abortController = new AbortController();
flowDirectionAbortControllerRef.current = abortController;
const view = map.getView();
const zoom = view.getZoom() ?? 0;
const minZoom = getFlowDirectionMinZoom(layerInfo);
if (zoom < minZoom) {
removeFlowDirectionLayer();
return;
}
const size = map.getSize();
if (!size) return;
const usageStyleMap = extractCheckedPipeUsageStyles(
checkedNodesDataRef.current,
zoom,
);
const usageCodes = Object.keys(usageStyleMap);
if (usageCodes.length === 0) {
removeFlowDirectionLayer();
return;
}
retainFlowDirectionUsageCodes(usageCodes);
const usageCodeSet = new Set(usageCodes);
const requestUsageCodes = requestOnlyUsageCodes
? requestOnlyUsageCodes.filter((usageCode) =>
usageCodeSet.has(usageCode),
)
: usageCodes;
const isPartialUsageRequest = Array.isArray(requestOnlyUsageCodes);
if (requestUsageCodes.length === 0) {
if (flowDirectionAbortControllerRef.current === abortController) {
flowDirectionAbortControllerRef.current = null;
}
return;
}
const screenExtent = view.calculateExtent(size);
const requestExtent = expandFlowDirectionExtent(screenExtent);
const screenBbox = formatFlowDirectionBbox(screenExtent);
const requestBbox = formatFlowDirectionBbox(requestExtent);
const baseParams = new URLSearchParams({
srs: FLOW_DIRECTION_SRS,
directionMode: flowDirectionMode,
searchStrategy: "BBOX",
bbox: requestBbox,
});
try {
const settledResponses = await Promise.allSettled(
requestUsageCodes.map(async (usageCode) => {
const params = new URLSearchParams(baseParams);
params.set("usgCd", usageCode);
const payload = await getRawJson(
`/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`,
{ signal: abortController.signal },
);
return {
usageCode,
featureCollection: getFeatureCollection(payload),
};
}),
);
if (requestSeq !== flowDirectionRequestSeqRef.current) return;
if (abortController.signal.aborted) return;
const responses = settledResponses
.filter(
(
result,
): result is PromiseFulfilledResult<{
usageCode: string;
featureCollection: any;
}> => result.status === "fulfilled",
)
.map((result) => result.value);
const failedResponses = settledResponses.filter(
(result) => result.status === "rejected",
);
if (responses.length === 0) {
if (!isPartialUsageRequest) {
removeFlowDirectionLayer();
}
throw (
failedResponses[0]?.reason ??
new Error("유하방향 관종별 조회 결과가 없습니다.")
);
}
if (failedResponses.length > 0) {
console.error(
"유하방향 일부 관종 조회 실패:",
failedResponses.map((result) => result.reason),
);
if (!flowDirectionFailureAlertedRef.current) {
flowDirectionFailureAlertedRef.current = true;
}
}
responses.forEach(({ usageCode, featureCollection }) => {
renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap);
flowDirectionVisibleUsageCodesRef.current.add(usageCode);
});
const returnedFeaturesByUsage = responses.reduce<
Record<string, number>
>((acc, { usageCode, featureCollection }) => {
acc[usageCode] = featureCollection.features?.length ?? 0;
return acc;
}, {});
const networkCorrectedFeatures = responses.reduce(
(count, { featureCollection }) => {
const correctedCount =
featureCollection.features?.filter(
(feature: any) =>
String(
feature?.properties?.networkCorrectionYn ??
feature?.properties?.network_correction_yn ??
"",
).toUpperCase() === "Y",
).length ?? 0;
return count + correctedCount;
},
0,
);
(window as any).__SN_FLOW_DIRECTION_DEBUG__ = {
screenBbox,
requestBbox,
requestSeq,
zoom,
directionMode: flowDirectionMode,
searchStrategy: "BBOX",
usageCodes,
requestUsageCodes,
requestCount: responses.length,
returnedFeaturesByUsage,
returnedFeatures: Object.values(returnedFeaturesByUsage).reduce(
(sum, count) => sum + count,
0,
),
networkCorrectedFeatures,
cachedFeatures:
flowDirectionSourceRef.current?.getFeatures?.().length ?? 0,
};
if (failedResponses.length === 0) {
flowDirectionFailureAlertedRef.current = false;
}
} catch (error) {
if (requestSeq !== flowDirectionRequestSeqRef.current) return;
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AbortError"
)
return;
console.error("유하방향 레이어 조회 실패:", error);
if (!isPartialUsageRequest) {
removeFlowDirectionLayer();
}
if (!flowDirectionFailureAlertedRef.current) {
flowDirectionFailureAlertedRef.current = true;
}
} finally {
if (flowDirectionAbortControllerRef.current === abortController) {
flowDirectionAbortControllerRef.current = null;
}
}
},
[
flowDirectionMode,
removeFlowDirectionLayer,
renderFlowDirectionLayer,
retainFlowDirectionUsageCodes,
],
);
// 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 queryParams = new URLSearchParams({
lyrTypeNm: AGED_PIPE_LAYER_TYPE,
page: String(page - 1),
size: String(size),
sort: "string",
});
const cqlFilters = buildAgedPipeCqlFilters(normalized);
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),
),
total: Number(response?.count ?? 0),
};
},
[ctpv, sgg],
);
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 removeExmnResultLayer = useCallback((jobType: ExmnMapJobType) => {
const layerId =
jobType === "PPL" ? EXMN_RESULT_PPL_LAYER_ID : EXMN_RESULT_MNHL_LAYER_ID;
const layerRef = jobType === "PPL" ? exmnPplLayerRef : exmnMnhlLayerRef;
const sourceRef = jobType === "PPL" ? exmnPplSourceRef : exmnMnhlSourceRef;
const requestSeqRef =
jobType === "PPL" ? exmnPplRequestSeqRef : exmnMnhlRequestSeqRef;
requestSeqRef.current += 1;
if (layerRef.current) {
(GisApp.Module.select as any)?.removeLayer?.(layerRef.current);
}
(GisApp.Module.core as any)?.deleteLayer?.(layerId);
layerRef.current = null;
sourceRef.current = null;
}, []);
const ensureExmnResultLayer = useCallback((jobType: ExmnMapJobType) => {
const map = gisMaps.current;
if (!map) return null;
const layerRef = jobType === "PPL" ? exmnPplLayerRef : exmnMnhlLayerRef;
const sourceRef = jobType === "PPL" ? exmnPplSourceRef : exmnMnhlSourceRef;
if (layerRef.current && sourceRef.current) return sourceRef.current;
const layerId =
jobType === "PPL" ? EXMN_RESULT_PPL_LAYER_ID : EXMN_RESULT_MNHL_LAYER_ID;
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: jobType === "PPL" ? 9988 : 9989,
style: jobType === "PPL" ? createExmnPipeStyle : createExmnManholeStyle,
updateWhileAnimating: false,
updateWhileInteracting: false,
});
layer.set("id", layerId);
layer.set("lyrNm", jobType === "PPL" ? "관로조사" : "맨홀조사");
layer.set(
"lyrTyNm",
jobType === "PPL" ? "tb_ms_swage_ppl" : "tb_ms_swage_mhl",
);
layer.set("clickPsblYn", "Y");
layer.set("chcYn", "Y");
(GisApp.Module.core as any)?.deleteLayer?.(layerId);
map.addLayer(layer);
(GisApp.Module.select as any)?.addLayer?.(layer);
layerRef.current = layer;
sourceRef.current = source;
return source;
}, []);
const loadExmnResultLayer = useCallback(
async (jobType: ExmnMapJobType, filters: ExmnMapFilters) => {
const isChecked =
jobType === "PPL" ? isExmnPplChecked : isExmnMnhlChecked;
if (!mapReady || !gisMaps.current || !isChecked) return;
const source = ensureExmnResultLayer(jobType);
if (!source) return;
const sggCodePrefix = exmnMapRegionCodes.sggCd
.replace(/[^0-9]/g, "")
.slice(0, 5);
// 공통 행정구역 코드가 준비되기 전에는 전국 중복 관로를 조회하지 않고 다음 상태 갱신을 기다린다.
if (sggCodePrefix.length !== 5) {
source.clear();
return;
}
const requestSeqRef =
jobType === "PPL" ? exmnPplRequestSeqRef : exmnMnhlRequestSeqRef;
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
try {
const response = await getExmnMapLayer(jobType, filters, {
sggCd: sggCodePrefix,
ctpvNm: exmnMapEntryContext.ctpvNm,
sggNm: exmnMapEntryContext.sggNm,
});
if (requestSeqRef.current !== requestSeq) return;
const featureCollection =
response?.data?.type === "FeatureCollection"
? response.data
: response;
const features = new GeoJSON().readFeatures(featureCollection, {
dataProjection: "EPSG:5179",
featureProjection: GisApp.TO_PROJ,
});
source.clear();
source.addFeatures(features);
// 표본 데이터가 적은 현재 단계에서는 최초 조사유형의 첫 시설을 기준으로 한 번만 확대한다.
if (
!exmnInitialFeatureFitAppliedRef.current &&
exmnMapEntryContext.jobType === jobType &&
features.length > 0
) {
const geometry = features[0]?.getGeometry?.();
const extent = geometry?.getExtent?.();
if (
extent &&
extent.length === 4 &&
extent.every((coordinate: number) => Number.isFinite(coordinate))
) {
gisMaps.current?.getView?.().fit?.(extent, {
duration: 300,
padding: [80, 420, 80, 80],
maxZoom: 18,
constrainResolution: true,
nearest: true,
});
exmnInitialFeatureFitAppliedRef.current = true;
}
}
} catch (error) {
if (requestSeqRef.current !== requestSeq) return;
source.clear();
console.error(
`${jobType === "PPL" ? "관로조사" : "맨홀조사"} 레이어 조회 실패:`,
error,
);
}
},
[
ensureExmnResultLayer,
exmnMapEntryContext.ctpvNm,
exmnMapEntryContext.jobType,
exmnMapEntryContext.sggNm,
exmnMapRegionCodes.ctpvCd,
exmnMapRegionCodes.sggCd,
isExmnMnhlChecked,
isExmnPplChecked,
mapReady,
],
);
const removeSinkholeLayer = useCallback(() => {
const core = GisApp.Module.core as any;
core?.deleteLayer?.(SINKHOLE_LAYER_ID);
sinkholeLayerRef.current = null;
sinkholeSourceRef.current = null;
}, []);
const ensureSinkholeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (sinkholeLayerRef.current && sinkholeSourceRef.current) {
return sinkholeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9992,
updateWhileAnimating: false,
updateWhileInteracting: false,
});
layer.set("id", SINKHOLE_LAYER_ID);
layer.set("lyrNm", SINKHOLE_LAYER_NAME);
layer.set("lyrTyNm", SINKHOLE_LAYER_TYPE);
layer.set("clickPsblYn", "Y");
layer.set("chcYn", "N");
const core = GisApp.Module.core as any;
core?.deleteLayer?.(SINKHOLE_LAYER_ID);
map.addLayer(layer);
sinkholeLayerRef.current = layer;
sinkholeSourceRef.current = source;
return source;
}, []);
const clearSinkholeSelectionLayer = useCallback(() => {
const core = GisApp.Module.core as any;
core?.deleteLayer?.(SINKHOLE_SELECTED_LAYER_ID);
sinkholeSelectedLayerRef.current = null;
sinkholeSelectedSourceRef.current = null;
}, []);
const clearSinkholeFocusedPipeLayer = useCallback(() => {
const core = GisApp.Module.core as any;
core?.deleteLayer?.("SN_SINKHOLE_FOCUSED_PIPE_LAYER");
sinkholeFocusedPipeLayerRef.current = null;
sinkholeFocusedPipeSourceRef.current = null;
}, []);
const ensureSinkholeSelectionLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (sinkholeSelectedLayerRef.current && sinkholeSelectedSourceRef.current) {
return sinkholeSelectedSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9996,
updateWhileAnimating: false,
updateWhileInteracting: false,
});
layer.set("id", SINKHOLE_SELECTED_LAYER_ID);
layer.set("lyrNm", `${SINKHOLE_LAYER_NAME}-선택`);
layer.set("lyrTyNm", SINKHOLE_LAYER_TYPE);
layer.set("clickPsblYn", "N");
const core = GisApp.Module.core as any;
core?.deleteLayer?.(SINKHOLE_SELECTED_LAYER_ID);
map.addLayer(layer);
sinkholeSelectedLayerRef.current = layer;
sinkholeSelectedSourceRef.current = source;
return source;
}, []);
const ensureSinkholeFocusedPipeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (
sinkholeFocusedPipeLayerRef.current &&
sinkholeFocusedPipeSourceRef.current
) {
return sinkholeFocusedPipeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9999,
updateWhileAnimating: false,
updateWhileInteracting: false,
});
layer.set("id", "SN_SINKHOLE_FOCUSED_PIPE_LAYER");
layer.set("lyrNm", "지반침하-관로강조");
layer.set("lyrTyNm", "tb_ms_swage_ppl");
layer.set("clickPsblYn", "N");
const core = GisApp.Module.core as any;
core?.deleteLayer?.("SN_SINKHOLE_FOCUSED_PIPE_LAYER");
map.addLayer(layer);
sinkholeFocusedPipeLayerRef.current = layer;
sinkholeFocusedPipeSourceRef.current = source;
return source;
}, []);
const renderSelectedSinkhole = useCallback(
(detail: SinkholeDetail | null, radiusMeters: number) => {
if (!detail || detail.x == null || detail.y == null) {
clearSinkholeSelectionLayer();
return;
}
const source = ensureSinkholeSelectionLayer();
if (!source) return;
source.clear();
const pointFeature = new Feature({
geometry: new Point([detail.x, detail.y]),
});
const pointVariant = resolveSinkholePointVariant(detail.ocrnYmd);
pointFeature.setStyle(createSinkholePointStyle(pointVariant, true));
const radiusFeature = new Feature({
geometry: new CircleGeom([detail.x, detail.y], radiusMeters),
});
radiusFeature.setStyle(createSinkholeRadiusStyle(pointVariant));
// 원의 북쪽 경계에 라벨을 두고 같은 source에서 관리해 선택 해제 시 함께 정리한다.
const radiusLabelFeature = new Feature({
geometry: new Point([detail.x, detail.y + radiusMeters]),
});
radiusLabelFeature.set("sinkholeRadiusLabel", true);
radiusLabelFeature.setStyle(
createSinkholeRadiusLabelStyle(radiusMeters, pointVariant),
);
source.addFeatures([radiusFeature, radiusLabelFeature, pointFeature]);
},
[clearSinkholeSelectionLayer, ensureSinkholeSelectionLayer],
);
const focusSinkholeOnMap = useCallback(
(detail: SinkholeDetail, radiusMeters: number) => {
if (!gisMaps.current || detail.x == null || detail.y == null) return;
const radiusGeometry = new CircleGeom([detail.x, detail.y], radiusMeters);
gisMaps.current.getView?.().fit?.(radiusGeometry.getExtent(), {
duration: 350,
padding: [100, 380, 100, 120],
maxZoom: 17,
constrainResolution: true,
nearest: true,
});
},
[],
);
// 우측 상세 탭 전환 시 패널의 활성 대상과 지도 선택/강조 상태를 맞춘다.
const syncRightPanelFeatureSelectionOnMap = useCallback(
(data: any) => {
const select = GisApp.Module.select as any;
select?.resetSelectedFeature?.();
const layerTypeName = getSelectedFeatureLayerTypeName(data);
if (layerTypeName === SINKHOLE_LAYER_TYPE) {
clearSinkholeFocusedPipeLayer();
return;
}
// 연계 심선/위선 조회는 공용 search-layer를 교체한다. 이때 탭에 보관된
// 기존 침수흔적 feature는 더 이상 지도 레이어에 속하지 않으므로, 단순히
// Select 컬렉션에 다시 넣어도 강조가 렌더링되지 않는다. 탭으로 돌아올
// 때는 FID로 search-layer를 복원한 뒤 로드된 feature를 선택하게 한다.
if (
layerTypeName === FLDNG_HSTRY_LAYER_TYPE ||
layerTypeName === "tb_ms_fldng_dpth_map"
) {
clearSinkholeFocusedPipeLayer();
const featureIdentifier = getRightPanelFeatureIdentifier(data).replace(
new RegExp(`^${layerTypeName}\\.`),
"",
);
if (featureIdentifier) {
const core = GisApp.Module.layer as any;
void core?.showSearchResultsFromGeoJSON?.(
layerTypeName,
`${layerTypeName}.${featureIdentifier}`,
);
}
return;
}
if (!data || typeof data.getGeometry !== "function") {
clearSinkholeFocusedPipeLayer();
return;
}
if (layerTypeName === "tb_ms_swage_ppl") {
const source = ensureSinkholeFocusedPipeLayer();
if (!source) return;
source.clear();
const focusedFeature =
typeof data.clone === "function" ? data.clone() : data;
if (
typeof focusedFeature.setId === "function" &&
typeof data.getId === "function"
) {
focusedFeature.setId(data.getId());
}
focusedFeature.setStyle(createSinkholeFocusedPipeStyle());
source.addFeature(focusedFeature);
select?.selectFeature?.(focusedFeature, true);
return;
}
clearSinkholeFocusedPipeLayer();
select?.selectFeature?.(data, true);
},
[clearSinkholeFocusedPipeLayer, ensureSinkholeFocusedPipeLayer],
);
const ensureFldngHstryAreaSource = useCallback(() => {
if (!mapReady || !gisMaps.current) return null;
if (!fldngHstryAreaLayerRef.current) {
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9993,
});
layer.set("id", FLDNG_HSTRY_AREA_LAYER_ID);
layer.set("lyrNm", FLDNG_HSTRY_LAYER_NAME);
layer.set("lyrTyNm", FLDNG_HSTRY_PREVIEW_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
fldngHstryAreaLayerRef.current = layer;
}
return fldngHstryAreaLayerRef.current.getSource();
}, [mapReady]);
const ensureFldngHstryDepthSource = useCallback(() => {
if (!mapReady || !gisMaps.current) return null;
if (!fldngHstryDepthLayerRef.current) {
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9992,
});
layer.set("id", FLDNG_HSTRY_DEPTH_LAYER_ID);
layer.set("lyrNm", FLDNG_HSTRY_LAYER_NAME);
layer.set("lyrTyNm", FLDNG_HSTRY_PREVIEW_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
fldngHstryDepthLayerRef.current = layer;
}
return fldngHstryDepthLayerRef.current.getSource();
}, [mapReady]);
const clearFldngHstryLayers = useCallback(() => {
fldngHstryAreaLayerRef.current?.getSource?.()?.clear?.();
fldngHstryDepthLayerRef.current?.getSource?.()?.clear?.();
}, []);
const renderSelectedFldngHstry = useCallback(
(detail: FldngHstryItem | null) => {
const areaSource = ensureFldngHstryAreaSource();
const depthSource = ensureFldngHstryDepthSource();
if (!areaSource || !depthSource) return;
areaSource.clear();
depthSource.clear();
if (!detail) return;
const areaFeature = new Feature({
geometry: new Polygon([detail.areaRing]),
});
areaFeature.setStyle(
createFldngHstryAreaStyle(
`${detail.floodYear}년 ${detail.districtName}`,
),
);
areaSource.addFeature(areaFeature);
detail.depthZones.forEach((zone) => {
const zoneFeature = new Feature({
geometry: new Polygon([zone.ring]),
});
zoneFeature.setStyle(
createFldngHstryDepthStyle(zone.depthLabel, zone.color),
);
depthSource.addFeature(zoneFeature);
});
},
[ensureFldngHstryAreaSource, ensureFldngHstryDepthSource],
);
const focusFldngHstryOnMap = useCallback((detail: FldngHstryItem) => {
if (!gisMaps.current) return;
const geometry = new Polygon([detail.areaRing]);
gisMaps.current.getView?.().fit?.(geometry.getExtent(), {
duration: 350,
padding: FLDNG_HSTRY_SELECTION_PADDING,
maxZoom: 15,
constrainResolution: true,
nearest: true,
});
}, []);
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 buildSinkholeQueryString = useCallback(
(filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT) =>
buildSinkholeListQueryString(filters, ctpv, sgg, page, size),
[ctpv, sgg],
);
const loadSinkholeLayer = useCallback(
async (filters = appliedSinkholeFilters) => {
if (!mapReady || !gisMaps.current || !isSinkholeChecked) return;
const source = ensureSinkholeLayer();
if (!source) return;
const effectiveFilters = normalizeSinkholeFilters(filters, sinkholeScope);
try {
const params = new URLSearchParams(
buildSinkholeQueryString(
effectiveFilters,
0,
SINKHOLE_DEFAULT_POINT_LIMIT,
),
);
const rawResponse = await getRawJson(
`/sewerage-service/api/v1/sinkholes?${params.toString()}`,
);
const items = Array.isArray(rawResponse?.data?.items)
? (rawResponse.data.items as SinkholeListItem[])
: Array.isArray(rawResponse?.items)
? (rawResponse.items as SinkholeListItem[])
: [];
const featureCollection =
buildSinkholeFeatureCollectionFromItems(items);
if (!featureCollection) {
console.warn(
"지반침하 목록 응답을 feature collection으로 변환하지 못했습니다.",
rawResponse,
);
source.clear();
return;
}
const features = new GeoJSON().readFeatures(featureCollection, {
dataProjection: "EPSG:5179",
featureProjection: GisApp.TO_PROJ,
});
source.clear();
features.forEach((feature: any) => {
const variant = resolveSinkholePointVariant(
String(feature?.get?.("ocrnYmd") ?? ""),
);
feature.setStyle(createSinkholePointStyle(variant, false));
});
source.addFeatures(features);
} catch (error) {
console.error("지반침하 레이어 조회 실패:", error);
source.clear();
}
},
[
appliedSinkholeFilters,
buildSinkholeQueryString,
ensureSinkholeLayer,
isSinkholeChecked,
mapReady,
sinkholeScope,
],
);
const closeSinkholeDetail = useCallback(() => {
setSelectedSinkholeDetail(null);
setSelectedSinkholeNearbyPipes([]);
clearSinkholeSelectionLayer();
clearSinkholeFocusedPipeLayer();
if (getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE) {
setFeatureData(null);
setIsOpen(false);
}
}, [clearSinkholeFocusedPipeLayer, clearSinkholeSelectionLayer, featureData]);
const loadSinkholeDetail = useCallback(
async (
acdntNo: string,
focusMap = true,
fallbackPoint?: { x: number; y: number } | null,
fallbackDetail?: Partial<SinkholeDetail> | null,
) => {
if (!acdntNo) return;
try {
const hasFallbackCoordinate =
(fallbackDetail?.x != null && fallbackDetail?.y != null) ||
(fallbackPoint != null &&
Number.isFinite(fallbackPoint.x) &&
Number.isFinite(fallbackPoint.y));
const fallbackCoordStatus = String(
fallbackDetail?.coordStatus ?? fallbackDetail?.geocodeStatus ?? "",
)
.trim()
.toUpperCase();
if (
fallbackDetail &&
!hasFallbackCoordinate &&
fallbackCoordStatus &&
fallbackCoordStatus !== "SUCCESS"
) {
const noCoordDetail: SinkholeDetail = {
acdntNo,
ctpvNm: String(fallbackDetail.ctpvNm ?? ""),
sggNm: String(fallbackDetail.sggNm ?? ""),
emdNm: String(fallbackDetail.emdNm ?? ""),
daddr: String(fallbackDetail.daddr ?? ""),
fullAddress: String(
fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? "",
),
ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""),
restoreStatus: String(fallbackDetail.restoreStatus ?? ""),
restoreCompletedYmd: String(
fallbackDetail.restoreCompletedYmd ?? "",
),
coordStatus: String(
fallbackDetail.coordStatus ?? fallbackDetail.geocodeStatus ?? "",
),
geocodeStatus: String(
fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? "",
),
geocodeSource: String(fallbackDetail.geocodeSource ?? ""),
geocodeErrorMessage: String(
fallbackDetail.geocodeErrorMessage ?? "",
),
latitude: fallbackDetail.latitude ?? null,
longitude: fallbackDetail.longitude ?? null,
x: fallbackDetail.x ?? null,
y: fallbackDetail.y ?? null,
sizeWidth: fallbackDetail.sizeWidth ?? null,
sizeLength: fallbackDetail.sizeLength ?? null,
sizeDepth: fallbackDetail.sizeDepth ?? null,
detailCause: fallbackDetail.detailCause ?? null,
deathCount: fallbackDetail.deathCount ?? null,
injuryCount: fallbackDetail.injuryCount ?? null,
vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null,
};
setSelectedSinkholeDetail(noCoordDetail);
setSelectedSinkholeNearbyPipes([]);
renderSelectedSinkhole(null, sinkholeRadiusMeters);
setFeatureData({
lyrTypeNm: SINKHOLE_LAYER_TYPE,
acdntNo: noCoordDetail.acdntNo,
});
setIsOpen(true);
setIsSearchModalOpen(false);
return;
}
let detail: SinkholeDetail;
try {
const detailResponse = await getRawJson(
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}`,
);
detail = (detailResponse?.data ?? detailResponse) as SinkholeDetail;
} catch (detailError) {
if (!fallbackDetail) {
throw detailError;
}
console.warn(
"지반침하 상세 API 실패, 목록 데이터로 대체합니다.",
detailError,
);
detail = {
acdntNo,
ctpvNm: String(fallbackDetail.ctpvNm ?? ""),
sggNm: String(fallbackDetail.sggNm ?? ""),
emdNm: String(fallbackDetail.emdNm ?? ""),
daddr: String(fallbackDetail.daddr ?? ""),
fullAddress: String(
fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? "",
),
ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""),
restoreStatus: String(fallbackDetail.restoreStatus ?? ""),
restoreCompletedYmd: String(
fallbackDetail.restoreCompletedYmd ?? "",
),
coordStatus: String(fallbackDetail.coordStatus ?? ""),
geocodeStatus: String(
fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? "",
),
geocodeSource: String(fallbackDetail.geocodeSource ?? ""),
geocodeErrorMessage: String(
fallbackDetail.geocodeErrorMessage ?? "",
),
latitude: fallbackDetail.latitude ?? null,
longitude: fallbackDetail.longitude ?? null,
x: fallbackDetail.x ?? null,
y: fallbackDetail.y ?? null,
sizeWidth: fallbackDetail.sizeWidth ?? null,
sizeLength: fallbackDetail.sizeLength ?? null,
sizeDepth: fallbackDetail.sizeDepth ?? null,
detailCause: fallbackDetail.detailCause ?? null,
deathCount: fallbackDetail.deathCount ?? null,
injuryCount: fallbackDetail.injuryCount ?? null,
vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null,
};
}
const resolvedPoint =
detail.x != null && detail.y != null
? { x: detail.x, y: detail.y }
: fallbackPoint &&
Number.isFinite(fallbackPoint.x) &&
Number.isFinite(fallbackPoint.y)
? fallbackPoint
: null;
let nearbyPipes: SinkholeNearbyPipe[] = [];
try {
const nearbyRawResponse = resolvedPoint
? await getRawJson(
`/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${resolvedPoint.x}&y=${resolvedPoint.y}&radiusMeters=${sinkholeRadiusMeters}`,
)
: await getRawJson(
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}/nearbyPipes?radiusMeters=${sinkholeRadiusMeters}`,
);
const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse;
nearbyPipes = Array.isArray(nearbyResponse?.items)
? (nearbyResponse.items as SinkholeNearbyPipe[])
: [];
} catch (nearbyError) {
console.warn(
"지반침하 반경 내 관로 조회 실패, 상세는 계속 표시합니다.",
nearbyError,
);
}
const normalizedDetail = resolvedPoint
? {
...detail,
x: resolvedPoint.x,
y: resolvedPoint.y,
coordStatus: "SUCCESS",
}
: detail;
setSelectedSinkholeDetail(normalizedDetail);
setSelectedSinkholeNearbyPipes(nearbyPipes);
renderSelectedSinkhole(normalizedDetail, sinkholeRadiusMeters);
setFeatureData({
lyrTypeNm: SINKHOLE_LAYER_TYPE,
acdntNo: normalizedDetail.acdntNo,
});
setIsOpen(true);
setIsSearchModalOpen(false);
if (
focusMap &&
normalizedDetail.x != null &&
normalizedDetail.y != null
) {
focusSinkholeOnMap(normalizedDetail, sinkholeRadiusMeters);
}
} catch (error) {
console.error("지반침하 상세 조회 중 오류:", error);
alert("지반침하 상세 정보를 불러오지 못했습니다.");
}
},
[
focusSinkholeOnMap,
renderSelectedSinkhole,
setIsSearchModalOpen,
sinkholeRadiusMeters,
],
);
const applySinkholeRadius = useCallback(
async (radiusMeters: number) => {
setSinkholeRadiusMeters(radiusMeters);
if (!selectedSinkholeDetail?.acdntNo) {
return;
}
const selectedPoint =
selectedSinkholeDetail.x != null && selectedSinkholeDetail.y != null
? {
x: Number(selectedSinkholeDetail.x),
y: Number(selectedSinkholeDetail.y),
}
: null;
try {
const nearbyRawResponse =
selectedPoint &&
Number.isFinite(selectedPoint.x) &&
Number.isFinite(selectedPoint.y)
? await getRawJson(
`/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${selectedPoint.x}&y=${selectedPoint.y}&radiusMeters=${radiusMeters}`,
)
: await getRawJson(
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(selectedSinkholeDetail.acdntNo)}/nearbyPipes?radiusMeters=${radiusMeters}`,
);
const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse;
setSelectedSinkholeNearbyPipes(
Array.isArray(nearbyResponse?.items)
? (nearbyResponse.items as SinkholeNearbyPipe[])
: [],
);
renderSelectedSinkhole(selectedSinkholeDetail, radiusMeters);
focusSinkholeOnMap(selectedSinkholeDetail, radiusMeters);
} catch (error) {
console.error("지반침하 반경 적용 중 오류:", error);
alert("반경 내 하수관로 정보를 다시 조회하지 못했습니다.");
throw error;
}
},
[focusSinkholeOnMap, renderSelectedSinkhole, selectedSinkholeDetail],
);
const focusSinkholeNearbyPipe = useCallback(
async (pipe: SinkholeNearbyPipe) => {
const fid = String(pipe?.fid ?? "").trim();
if (!fid) {
alert("표시할 관로 식별값이 없습니다.");
return;
}
const activeSinkholeTab = rightPanelTabs.find(
(tab) =>
tab.id === activeRightPanelTabId && tab.category === "sinkhole",
);
pipeParentTabIdRef.current = activeSinkholeTab?.id ?? null;
try {
const layerTypeName = String(pipe?.lyrTypeNm ?? "tb_ms_swage_ppl");
const layerModule = GisApp.Module.layer as {
storageNm?: string;
} | null;
const storageNm = String(layerModule?.storageNm ?? "").trim();
if (!storageNm)
throw new Error("GeoServer 저장소 이름을 확인할 수 없습니다.");
const params = new URLSearchParams({
SERVICE: "WFS",
VERSION: "1.1.0",
REQUEST: "GetFeature",
TYPENAME: `${storageNm}${layerTypeName}`,
SRSNAME: GisApp.TO_PROJ,
OUTPUTFORMAT: "application/json",
FEATUREID: fid,
});
const response = await getRawJson(
`${GisApp.GEOSERVER_URL}?${params.toString()}`,
);
const featureCollection = getFeatureCollection(response);
const features = new GeoJSON().readFeatures(featureCollection, {
dataProjection: GisApp.TO_PROJ,
featureProjection: GisApp.TO_PROJ,
});
const firstFeature = features[0];
if (firstFeature) {
syncRightPanelFeatureSelectionOnMap(firstFeature);
setFeatureData(firstFeature);
setIsOpen(true);
setIsSearchModalOpen(false);
}
} catch (error) {
console.error("지반침하 반경 내 관로 지도 표시 실패:", error);
alert("선택한 관로를 지도에서 표시하지 못했습니다.");
}
},
[
activeRightPanelTabId,
rightPanelTabs,
setIsSearchModalOpen,
syncRightPanelFeatureSelectionOnMap,
],
);
const buildRightPanelTab = useCallback(
(data: any): RightPanelTab | null => {
if (!data) return null;
const layerTypeName = getSelectedFeatureLayerTypeName(data);
const now = Date.now();
if (layerTypeName === SINKHOLE_LAYER_TYPE) {
const detail = selectedSinkholeDetail;
const acdntNo = String(
detail?.acdntNo ?? getSinkholeAccidentId(data) ?? "",
).trim();
if (!acdntNo) return null;
return {
id: `sinkhole:${acdntNo}`,
kind: "sinkhole",
category: "sinkhole",
displayLabel: `지반침하(${acdntNo})`,
parentTabId: null,
featureData: {
lyrTypeNm: SINKHOLE_LAYER_TYPE,
acdntNo,
},
sinkholeDetail: detail,
nearbyPipes: selectedSinkholeNearbyPipes,
lastActiveAt: now,
};
}
if (gisEditDetailChangeId) {
return {
id: `gisEdit:${gisEditDetailChangeId}`,
kind: "gisEdit",
category: "gisEdit",
displayLabel: "관로편집",
parentTabId: null,
featureData: data,
gisEditChangeId: gisEditDetailChangeId,
lastActiveAt: now,
};
}
const category = getRightPanelTabCategory(data);
const surveyJobTypeValue = getRightPanelFeatureProperty(data, [
"jobType",
]);
const surveyJobType =
surveyJobTypeValue === "PPL" || surveyJobTypeValue === "MNHL"
? surveyJobTypeValue
: undefined;
const featureId =
getRightPanelFeatureIdentifier(data) ||
`${layerTypeName || "feature"}:${now}`;
// Direct map selections accumulate per pipe; only a pipe with a sinkhole parent replaces the linked slot.
const pipeParentTabId =
category === "pipe" ? pipeParentTabIdRef.current : null;
return {
id:
category === "exmnSurvey"
? `exmnSurvey:${getRightPanelFeatureProperty(data, ["exmnFacilityId"])}:${getRightPanelFeatureProperty(data, ["manholePosition"])}`
: category === "pipe" && pipeParentTabId
? RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID
: `feature:${layerTypeName || "unknown"}:${featureId}`,
kind: "feature",
category,
displayLabel: getRightPanelFeatureDisplayLabel(data, category),
surveyJobType,
parentTabId: pipeParentTabId,
featureData: data,
lastActiveAt: now,
};
},
[
gisEditDetailChangeId,
selectedSinkholeDetail,
selectedSinkholeNearbyPipes,
],
);
const upsertRightPanelTab = useCallback((tab: RightPanelTab) => {
setRightPanelTabs((currentTabs) => {
const nextTab = { ...tab, lastActiveAt: Date.now() };
const existingIndex = currentTabs.findIndex((item) => item.id === tab.id);
const mergedTabs =
existingIndex < 0
? [...currentTabs, nextTab]
: currentTabs.map((item, index) =>
index === existingIndex ? { ...item, ...nextTab } : item,
);
if (mergedTabs.length <= RIGHT_PANEL_TAB_LIMIT) return mergedTabs;
const overflowCandidate = findRightPanelTabOverflowCandidate(
mergedTabs,
nextTab.id,
);
if (!overflowCandidate) return mergedTabs.slice(-RIGHT_PANEL_TAB_LIMIT);
return removeRightPanelTabGroup(mergedTabs, overflowCandidate.id);
});
setActiveRightPanelTabId(tab.id);
}, []);
const activateRightPanelTab = useCallback(
(tabId: string) => {
const targetTab = rightPanelTabs.find((tab) => tab.id === tabId);
if (!targetTab) return;
setRightPanelTabs((currentTabs) =>
currentTabs.map((tab) =>
tab.id === tabId ? { ...tab, lastActiveAt: Date.now() } : tab,
),
);
setActiveRightPanelTabId(tabId);
if (targetTab.category === "pipe") {
pipeParentTabIdRef.current = targetTab.parentTabId ?? null;
}
if (targetTab.kind === "sinkhole") {
const detail = targetTab.sinkholeDetail ?? null;
setSelectedSinkholeDetail(detail);
setSelectedSinkholeNearbyPipes(targetTab.nearbyPipes ?? []);
setGisEditDetailChangeId(null);
syncRightPanelFeatureSelectionOnMap(targetTab.featureData);
if (detail) {
renderSelectedSinkhole(detail, sinkholeRadiusMeters);
}
} else if (targetTab.kind === "gisEdit") {
setGisEditDetailChangeId(targetTab.gisEditChangeId ?? null);
} else {
setGisEditDetailChangeId(null);
syncRightPanelFeatureSelectionOnMap(targetTab.featureData);
}
setFeatureData(targetTab.featureData);
setIsOpen(true);
setIsSearchModalOpen(false);
},
[
renderSelectedSinkhole,
rightPanelTabs,
setIsSearchModalOpen,
sinkholeRadiusMeters,
syncRightPanelFeatureSelectionOnMap,
],
);
const closeRightPanelTab = useCallback(
(tabId: string | null) => {
if (!tabId) return;
const closingTab = rightPanelTabs.find((tab) => tab.id === tabId);
const remainingTabs = removeRightPanelTabGroup(rightPanelTabs, tabId);
const closingLayerTypeName = getSelectedFeatureLayerTypeName(
closingTab?.featureData,
);
// 침수흔적 연계 조회로 만든 공용 search-layer는 선택 해제만으로
// 제거되지 않는다. 마지막 탭을 닫는 경우와 다른 일반 feature 탭으로
// 전환되는 경우 모두 잔상으로 남지 않도록 여기서 명시적으로 제거한다.
if (
closingLayerTypeName === FLDNG_HSTRY_LAYER_TYPE ||
closingLayerTypeName === "tb_ms_fldng_dpth_map"
) {
const layerModule = GisApp.Module.layer as any;
if (layerModule?.searchLayer) {
layerModule.clearSearchLayer?.();
}
}
if (closingTab?.kind === "sinkhole") {
setSelectedSinkholeDetail(null);
setSelectedSinkholeNearbyPipes([]);
clearSinkholeSelectionLayer();
}
if (closingTab?.kind === "feature") {
const select = GisApp.Module.select as any;
select?.resetSelectedFeature?.();
clearSinkholeFocusedPipeLayer();
}
if (closingTab?.kind === "gisEdit") {
setGisEditDetailChangeId(null);
}
if (
closingTab?.category === "pipe" ||
pipeParentTabIdRef.current === tabId
) {
pipeParentTabIdRef.current = null;
}
setRightPanelTabs(remainingTabs);
if (remainingTabs.length === 0) {
setActiveRightPanelTabId(null);
pipeParentTabIdRef.current = null;
setFeatureData(null);
setIsOpen(false);
return;
}
const parentFallbackTab =
closingTab?.category === "pipe" && closingTab.parentTabId
? remainingTabs.find((tab) => tab.id === closingTab.parentTabId)
: null;
const fallbackTab =
parentFallbackTab ??
[...remainingTabs].sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0]!;
activateRightPanelTab(fallbackTab.id);
},
[
activateRightPanelTab,
clearSinkholeFocusedPipeLayer,
clearSinkholeSelectionLayer,
rightPanelTabs,
],
);
// 선택 피처가 없는 지도 본체 클릭을 기존 지반침하 상세 닫기 흐름과 연결한다.
const handleSinkholeMapBlankClick = useCallback(() => {
if (gisEdit.isEditMode || !selectedSinkholeDetail?.acdntNo) return;
const sinkholeTabId = `sinkhole:${selectedSinkholeDetail.acdntNo}`;
const hasSinkholeTab = rightPanelTabs.some(
(tab) => tab.id === sinkholeTabId,
);
if (hasSinkholeTab && activeRightPanelTabId === sinkholeTabId) {
closeRightPanelTab(sinkholeTabId);
} else {
// 비활성 지반침하 탭은 현재 활성 탭을 바꾸지 않고 종속 관로와 함께 제거한다.
setRightPanelTabs((currentTabs) =>
removeRightPanelTabGroup(currentTabs, sinkholeTabId),
);
setSelectedSinkholeDetail(null);
setSelectedSinkholeNearbyPipes([]);
clearSinkholeSelectionLayer();
if (pipeParentTabIdRef.current === sinkholeTabId) {
pipeParentTabIdRef.current = null;
}
if (
!hasSinkholeTab &&
getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE
) {
setFeatureData(null);
setIsOpen(false);
}
}
const select = GisApp.Module.select as any;
select?.resetSelectedFeature?.();
clearSinkholeFocusedPipeLayer();
}, [
activeRightPanelTabId,
clearSinkholeFocusedPipeLayer,
clearSinkholeSelectionLayer,
closeRightPanelTab,
featureData,
gisEdit.isEditMode,
rightPanelTabs,
selectedSinkholeDetail,
]);
useEffect(() => {
if (!isOpen || !featureData) return;
const nextTab = buildRightPanelTab(featureData);
if (nextTab) upsertRightPanelTab(nextTab);
}, [buildRightPanelTab, featureData, isOpen, upsertRightPanelTab]);
useEffect(() => {
if (isOpen) return;
pipeParentTabIdRef.current = null;
setRightPanelTabs([]);
setActiveRightPanelTabId(null);
}, [isOpen]);
const openSinkholePopupWindow = useCallback(() => {
const effectiveFilters = normalizeSinkholeFilters(
appliedSinkholeFilters,
sinkholeScope,
);
const popup = openWindowPopup(
SINKHOLE_POPUP_KEY as WindowPopupKey,
effectiveFilters as Record<string, string>,
);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
return null;
}
setIsSinkholePopupFilterActive(true);
setAppliedSinkholeFilters(effectiveFilters);
setSinkholeSelectionVersion((version) => version + 1);
if (sinkholePopupCloseWatcherRef.current) {
window.clearInterval(sinkholePopupCloseWatcherRef.current);
sinkholePopupCloseWatcherRef.current = null;
}
sinkholePopupWindowRef.current = popup;
sinkholePopupCloseWatcherRef.current = window.setInterval(() => {
if (!popup.closed) return;
if (sinkholePopupCloseWatcherRef.current) {
window.clearInterval(sinkholePopupCloseWatcherRef.current);
sinkholePopupCloseWatcherRef.current = null;
}
sinkholePopupWindowRef.current = null;
setIsSinkholePopupFilterActive(false);
setAppliedSinkholeFilters(
normalizeSinkholeFilters(
buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope),
sinkholeScope,
),
);
setSinkholeSelectionVersion((version) => version + 1);
}, 300);
return popup;
}, [appliedSinkholeFilters, sinkholeScope, sinkholeYearRange]);
useEffect(
() => () => {
if (sinkholePopupCloseWatcherRef.current) {
window.clearInterval(sinkholePopupCloseWatcherRef.current);
sinkholePopupCloseWatcherRef.current = null;
}
},
[],
);
const handleAgedPipeRowDoubleClick = useCallback(
async (row: AgedPipeRow) => {
try {
shouldFocusAgedPipeOnNextSelectRef.current = true;
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
} catch (error) {
shouldFocusAgedPipeOnNextSelectRef.current = false;
console.error("노후관로 위치 이동 중 오류:", error);
}
setFeatureData(row as any);
setIsOpen(true);
setIsSearchModalOpen(false);
},
[setIsSearchModalOpen],
);
const handlePipeAnalysisRowSelect = useCallback(
async (row: PipeAnalysisDetailRow) => {
// 관로현황 팝업에서 선택한 경우 메시지를 받은 최상위 지도창을 먼저 앞으로 가져온다.
window.focus();
try {
shouldFocusAgedPipeOnNextSelectRef.current = true;
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
} catch (error) {
shouldFocusAgedPipeOnNextSelectRef.current = false;
console.error("관로 위치 이동 중 오류:", error);
}
setFeatureData(row as any);
setIsOpen(true);
setIsSearchModalOpen(false);
// 지도 이동과 상세 패널 렌더링 이후에도 최상위 창 포커스를 한 번 더 요청한다.
window.focus();
},
[setIsSearchModalOpen],
);
const handleAgedPipeDownload = useCallback(async () => {
if (agedPipeTotalCount === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
try {
setIsAgedPipeListLoading(true);
setAgedPipeDownloadProgressText("Excel 파일을 준비하고 있습니다.");
await downloadAgedPipeExcel(
appliedAgedPipeFilters,
(job) => {
if (job.status === "QUEUED") {
setAgedPipeDownloadProgressText(
"Excel 다운로드 작업이 대기 중입니다.",
);
return;
}
if (job.status === "RUNNING") {
setAgedPipeDownloadProgressText(
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
},
agedPipeTotalCount,
);
setAgedPipeDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
setAgedPipeDownloadProgressText("");
alert(
error instanceof Error
? error.message
: "노후관로 Excel 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsAgedPipeListLoading(false);
}
}, [agedPipeTotalCount, appliedAgedPipeFilters]);
useEffect(() => {
const handleAgedPipePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === AGED_PIPE_ROW_SELECT_MESSAGE) {
if (!event.data?.row) return;
handleAgedPipeRowDoubleClick(event.data.row as AgedPipeRow);
return;
}
if (event.data?.type === AGED_PIPE_POPUP_MINIMIZE_MESSAGE) {
agedPipePopupWindowRef.current = null;
if (event.data?.filters) {
const normalized = normalizeAgedPipeFilters(
event.data.filters as AgedPipeFilters,
);
setDraftAgedPipeFilters(normalized);
setAppliedAgedPipeFilters(normalized);
}
setAgedPipeTotalCount(Number(event.data?.total ?? agedPipeTotalCount));
setIsAgedPipeListMinimized(true);
return;
}
if (event.data?.type === PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE) {
if (!event.data?.row) return;
handlePipeAnalysisRowSelect(event.data.row as PipeAnalysisDetailRow);
}
};
window.addEventListener("message", handleAgedPipePopupMessage);
return () =>
window.removeEventListener("message", handleAgedPipePopupMessage);
}, [
agedPipeTotalCount,
handleAgedPipeRowDoubleClick,
handlePipeAnalysisRowSelect,
]);
useEffect(() => {
const handleSinkholePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (
event.data?.type === SINKHOLE_FILTER_SYNC_MESSAGE &&
event.data?.filters
) {
const normalized = normalizeSinkholeFilters(
event.data.filters as SinkholeFilters,
sinkholeScope,
);
setIsSinkholePopupFilterActive(true);
setAppliedSinkholeFilters(normalized);
setSinkholeSelectionVersion((version) => version + 1);
return;
}
if (event.data?.type === SINKHOLE_ROW_SELECT_MESSAGE && event.data?.row) {
const row = event.data.row as SinkholeListItem;
const fallbackPoint =
row.x != null && row.y != null
? { x: Number(row.x), y: Number(row.y) }
: null;
void loadSinkholeDetail(
String(row.acdntNo ?? ""),
true,
fallbackPoint,
row,
);
return;
}
if (
event.data?.type === SINKHOLE_SYNC_COMPLETED_MESSAGE &&
event.data?.payload
) {
setLastSinkholeSyncSummary(event.data.payload as SinkholeSyncSummary);
setSinkholeSelectionVersion((version) => version + 1);
}
};
window.addEventListener("message", handleSinkholePopupMessage);
return () =>
window.removeEventListener("message", handleSinkholePopupMessage);
}, [loadSinkholeDetail, sinkholeScope]);
useEffect(() => {
const handleFldngHstryPopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (
event.data?.type !== FLDNG_HSTRY_ROW_SELECT_MESSAGE ||
!event.data?.row
)
return;
handleFldngHstryRowSelect(event.data.row as FldngHstryItem);
};
window.addEventListener("message", handleFldngHstryPopupMessage);
return () =>
window.removeEventListener("message", handleFldngHstryPopupMessage);
}, [handleFldngHstryRowSelect]);
// WaterDepth 표시 여부를 결정하는 함수
const handleWaterDepthVisibility = useCallback(
(checkedIds: string[], checkedNodesData: any[]) => {
const waterDepthLayers = checkedNodesData.filter((node) =>
WATER_DEPTH_LAYER_TYPES.includes(node.data?.lyrTyNm),
);
const hasWaterDepthLayer = waterDepthLayers.length > 0;
setIsWaterDepthLayerActive(hasWaterDepthLayer);
},
[],
);
// 체크된 레이어 변경 처리 함수
const handleCheckedLayersChange = useCallback(
(checkedIds: string[], checkedNodesData: any[]) => {
checkedNodesDataRef.current = checkedNodesData;
setCheckedLayerLegendVersion((version) => version + 1);
setSwagePrcsFcltLayerMode(getSwagePrcsFcltLayerMode(checkedNodesData));
// 유하방향은 일반 setLayerOnMap 대상이 아니므로 체크 노드 여부를 별도로 추적한다.
const flowDirectionNode = checkedNodesData.find((node) =>
isFlowDirectionLayerInfo(node?.data ?? node),
);
const nextFlowDirectionLayerInfo =
flowDirectionNode?.data ?? flowDirectionNode ?? null;
flowDirectionCheckedRef.current = Boolean(nextFlowDirectionLayerInfo);
flowDirectionLayerInfoRef.current = nextFlowDirectionLayerInfo;
setIsFlowDirectionChecked(Boolean(nextFlowDirectionLayerInfo));
setFlowDirectionLayerInfo(nextFlowDirectionLayerInfo);
if (nextFlowDirectionLayerInfo) {
const map = gisMaps.current;
const zoom = map?.getView?.()?.getZoom?.() ?? 0;
const nextUsageStyleMap = extractCheckedPipeUsageStyles(
checkedNodesData,
zoom,
);
const requestUsageCodes = retainFlowDirectionUsageCodes(
Object.keys(nextUsageStyleMap),
);
flowDirectionSelectionRequestUsageCodesRef.current = requestUsageCodes;
if (requestUsageCodes.length === 0) {
flowDirectionRequestSeqRef.current += 1;
flowDirectionAbortControllerRef.current?.abort();
flowDirectionAbortControllerRef.current = null;
}
setFlowDirectionSelectionVersion((version) => version + 1);
}
if (!nextFlowDirectionLayerInfo) {
// 해제 직후 pending API 응답이 늦게 도착해도 renderFlowDirectionLayer가 실행되지 않도록 순번을 무효화한다.
flowDirectionSelectionRequestUsageCodesRef.current = null;
flowDirectionRequestSeqRef.current += 1;
flowDirectionAbortControllerRef.current?.abort();
flowDirectionAbortControllerRef.current = null;
flowDirectionFailureAlertedRef.current = false;
removeFlowDirectionLayer();
}
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,
removeFlowDirectionLayer,
removeSinkholeLayer,
retainFlowDirectionUsageCodes,
],
);
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"
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"
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,
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 core = GisApp.Module.core as any;
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
if (!isAgedPipeChecked || !isAgedPipeSettingEnabled) return;
const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
(item: any) =>
item?.lyrTyNm === AGED_PIPE_LAYER_TYPE && item?.actlLyrYn === "Y",
) as any;
if (!baseLayerCode) return;
const layerCode = {
...baseLayerCode,
id: AGED_PIPE_HIGHLIGHT_LAYER_ID,
lyrSn: AGED_PIPE_HIGHLIGHT_LAYER_ID,
lyrNm: "노후관로",
lyrTyNm: AGED_PIPE_LAYER_TYPE,
gobjTy: "line",
actlLyrYn: "Y",
exprsYn: "Y",
clickPsblYn: "Y",
cmncNm: "bbox",
srvcNm: "wfs",
flterSql: buildAgedPipeCqlFilters(appliedAgedPipeFilters).join(" and "),
lnWdth: "4",
lnColor: AGED_PIPE_HIGHLIGHT_COLOR,
drawOrdr: "9999",
minViewSize: String(AGED_PIPE_MIN_ZOOM),
};
const layer = core?.addWfsLayer?.(layerCode);
layer?.set?.("id", AGED_PIPE_HIGHLIGHT_LAYER_ID);
layer?.set?.("lyrNm", "노후관로");
layer?.set?.("lyrTyNm", AGED_PIPE_LAYER_TYPE);
return () => {
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
};
}, [
appliedAgedPipeFilters,
isAgedPipeChecked,
isAgedPipeSettingEnabled,
mapReady,
]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const core = GisApp.Module.core as any;
const yearCql = buildFldngHstryYearCql(
fldngHstryYearRange,
isFldngHstryYearFilterEnabled,
);
const yearFilterSignature = `${fldngHstryYearRange.startYear}:${fldngHstryYearRange.endYear}:${isFldngHstryYearFilterEnabled}`;
if (
fldngHstryYearFilterSignatureRef.current &&
fldngHstryYearFilterSignatureRef.current !== yearFilterSignature
) {
clearFldngHstrySelection();
}
fldngHstryYearFilterSignatureRef.current = yearFilterSignature;
/** 기준 레이어 필터를 보존하면서 선택한 침수년도 조건을 결합한다. */
const resolveFldngHstryFilter = (baseLayerCode: any) => {
const baseFilter = String(baseLayerCode?.flterSql ?? "").trim();
return [baseFilter && `(${baseFilter})`, yearCql]
.filter(Boolean)
.join(" AND ");
};
type FldngHstryLayerSpec = {
id: string;
layerType: string;
layerName: string;
baseLayerCode: any;
filterSql: string;
};
const desiredLayers: FldngHstryLayerSpec[] = [];
const layerCodes = Object.values(GisApp.layerCode ?? {}) as any[];
if (isFldngHstryWtlvChecked) {
const baseLayerCode = layerCodes.find(
(item) =>
item?.lyrTyNm === FLDNG_HSTRY_LAYER_TYPE && item?.actlLyrYn === "Y",
);
if (baseLayerCode) {
desiredLayers.push({
id: FLDNG_HSTRY_WFS_LAYER_ID,
layerType: FLDNG_HSTRY_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLDNG_HSTRY_LAYER_NAME,
baseLayerCode,
filterSql: resolveFldngHstryFilter(baseLayerCode),
});
}
}
if (isFldngHstryDepthChecked) {
const selectedDepthLayerKeys = new Set(checkedFldngHstryDepthLayerKeys);
layerCodes
.filter((item) => {
const layerKey = String(item?.lyrSn ?? item?.id ?? "").trim();
return (
item?.lyrTyNm === FLDNG_HSTRY_DEPTH_LAYER_TYPE &&
item?.actlLyrYn === "Y" &&
selectedDepthLayerKeys.has(layerKey)
);
})
.forEach((baseLayerCode) => {
const layerKey = String(
baseLayerCode?.lyrSn ?? baseLayerCode?.id,
).trim();
desiredLayers.push({
id: `${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_${layerKey}`,
layerType: FLDNG_HSTRY_DEPTH_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLDNG_HSTRY_DEPTH_LAYER_NAME,
baseLayerCode,
filterSql: resolveFldngHstryFilter(baseLayerCode),
});
});
}
const desiredLayerIds = new Set(desiredLayers.map((item) => item.id));
const mapLayers = [...(gisMaps.current?.getLayers?.()?.getArray?.() ?? [])];
const isManagedFldngHstryLayerId = (id: string) =>
id === FLDNG_HSTRY_WFS_LAYER_ID ||
id.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`);
// 체크 해제된 등급만 삭제하고, 계속 체크된 등급은 지도에 그대로 유지한다.
mapLayers.forEach((mapLayer: any) => {
const layerId = String(mapLayer?.get?.("id") ?? "");
if (
isManagedFldngHstryLayerId(layerId) &&
!desiredLayerIds.has(layerId)
) {
gisMaps.current?.removeLayer?.(mapLayer);
}
});
desiredLayers.forEach((spec) => {
const filterSignature = `${spec.layerType}|${spec.filterSql}`;
// 공통 getLayerById는 ID를 Number로 비교하므로 문자열 ID인 필터 레이어를 찾지 못한다.
// 지도 컬렉션에서 문자열 ID를 직접 비교해 기존 레이어와 WFS source를 재사용한다.
const existingLayer = gisMaps.current
?.getLayers?.()
?.getArray?.()
?.find(
(mapLayer: any) => String(mapLayer?.get?.("id") ?? "") === spec.id,
);
if (
existingLayer?.get?.("fldngHstryFilterSignature") === filterSignature
) {
existingLayer?.setVisible?.(true);
return;
}
// 연도나 적용 여부가 달라진 레이어만 교체하고, 동일 필터 레이어는 재사용한다.
core?.deleteLayer?.(spec.id);
const layerCode = {
...spec.baseLayerCode,
id: spec.id,
lyrSn: spec.id,
lyrNm: spec.layerName,
lyrTyNm: spec.layerType,
actlLyrYn: "Y",
exprsYn: "Y",
clickPsblYn: "Y",
cmncNm: "bbox",
srvcNm: "wfs",
flterSql: spec.filterSql,
};
const layer = core?.addWfsLayer?.(layerCode);
layer?.set?.("id", spec.id);
layer?.set?.("lyrNm", spec.layerName);
layer?.set?.("lyrTyNm", spec.layerType);
layer?.set?.("fldngHstryFilterSignature", filterSignature);
});
// 전용 필터 레이어가 담당하는 유형의 원본 레이어만 숨겨 중복 표출을 막는다.
const activeFilteredTypes = new Set(
desiredLayers.map((item) => item.layerType),
);
gisMaps.current
?.getLayers?.()
?.getArray?.()
?.forEach((mapLayer: any) => {
const layerId = String(mapLayer?.get?.("id") ?? "");
const layerType = String(mapLayer?.get?.("lyrTyNm") ?? "");
if (
activeFilteredTypes.has(layerType) &&
!isManagedFldngHstryLayerId(layerId)
) {
mapLayer?.setVisible?.(false);
}
});
}, [
checkedFldngHstryDepthLayerKeys,
clearFldngHstrySelection,
fldngHstryYearRange,
isFldngHstryChecked,
isFldngHstryDepthChecked,
isFldngHstryWtlvChecked,
isFldngHstryYearFilterEnabled,
mapReady,
]);
// 페이지가 닫힐 때만 관리 대상 필터 레이어 전체를 정리한다.
useEffect(() => {
return () => {
const map = gisMaps.current;
const layers = [...(map?.getLayers?.()?.getArray?.() ?? [])];
layers.forEach((layer: any) => {
const layerId = String(layer?.get?.("id") ?? "");
if (
layerId === FLDNG_HSTRY_WFS_LAYER_ID ||
layerId.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`)
) {
map?.removeLayer?.(layer);
}
});
};
}, []);
const openAnalysisPopup = useCallback(
(popupKey: WindowPopupKey) => {
const popup = openWindowPopup(popupKey);
if (!popup) {
alert(
"브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.",
);
return null;
}
if (popupKey === "pipeAnalysisPopup") {
pipeAnalysisPopupWindowRef.current = popup;
}
if (popupKey === "flowRiskPopup") {
clearWaterRiskPopupCloseWatcher();
waterRiskPopupWindowRef.current = popup;
waterRiskPopupCloseWatcherRef.current = window.setInterval(() => {
if (!popup.closed) return;
clearWaterRiskPopupCloseWatcher();
waterRiskPopupWindowRef.current = null;
clearWaterRiskSelection();
}, 300);
}
return popup;
},
[clearWaterRiskPopupCloseWatcher, clearWaterRiskSelection],
);
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 ensureGisEditPreviewLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (gisEditPreviewLayerRef.current && gisEditPreviewSourceRef.current) {
const layers = map.getLayers?.().getArray?.() ?? [];
if (!layers.includes(gisEditPreviewLayerRef.current)) {
map.addLayer(gisEditPreviewLayerRef.current);
}
gisEditPreviewLayerRef.current.setVisible?.(true);
gisEditPreviewLayerRef.current.setZIndex?.(10001);
return gisEditPreviewSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
style: createGisEditPreviewStyle,
zIndex: 10001,
updateWhileAnimating: true,
updateWhileInteracting: true,
});
layer.set("id", GIS_EDIT_PREVIEW_LAYER_ID);
layer.set("lyrNm", "GIS 편집 미리보기");
layer.set("lyrTyNm", GIS_EDIT_PREVIEW_LAYER_ID);
// Preview selection is handled by map.forEachFeatureAtPixel; keep it out of the common Select styler.
layer.set("clickPsblYn", "N");
layer.setVisible?.(true);
map.addLayer(layer);
gisEditPreviewLayerRef.current = layer;
gisEditPreviewSourceRef.current = source;
return source;
}, []);
const clearGisEditPreviewLayer = useCallback(() => {
gisEditPreviewSourceRef.current?.clear();
setGisEditPreviewCursor("");
}, [setGisEditPreviewCursor]);
const ensureGisEditPipeSnapSource = useCallback(() => {
if (gisEditPipeSnapSourceRef.current) {
return gisEditPipeSnapSourceRef.current;
}
const source = new VectorSource();
gisEditPipeSnapSourceRef.current = source;
return source;
}, []);
const clearGisEditPipeSnapSource = useCallback(() => {
gisEditPipeSnapAbortControllerRef.current?.abort();
gisEditPipeSnapAbortControllerRef.current = null;
gisEditPipeSnapSourceRef.current?.clear();
}, []);
const buildGisEditPipeSnapPreviewFeatures = useCallback(
(excludedChangeId?: string | null) => {
const normalizedExcludedChangeId = String(excludedChangeId ?? "").trim();
const geoJson = new GeoJSON();
return gisEdit.changes
.filter((change) => change.layerId === "tb_ms_swage_ppl")
.filter(
(change) =>
!change.attributeOnly &&
change.editType !== "DELETE" &&
Boolean(change.afterGeometry),
)
.filter((change) => change.changeId !== normalizedExcludedChangeId)
.map((change) => {
const feature = geoJson.readFeature(
{
type: "Feature",
geometry: change.afterGeometry,
properties: {
fid:
change.fid ??
change.featureRef?.fid ??
change.objectId ??
change.changeId,
layerId: change.layerId,
changeId: change.changeId,
},
},
{
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
},
);
feature.setId(`gis-edit-snap:${change.changeId}`);
return feature;
});
},
[gisEdit.changes],
);
const loadGisEditPipeSnapSource = useCallback(
async (options?: {
excludedFid?: string | null;
excludedChangeId?: string | null;
}) => {
const map = gisMaps.current;
const source = ensureGisEditPipeSnapSource();
if (!map || !source) return;
const size = map.getSize?.();
const view = map.getView?.();
if (!size || !view?.calculateExtent) return;
const normalizedExcludedFid = normalizeGisEditFid(
options?.excludedFid ?? "",
);
const normalizedExcludedChangeId = String(
options?.excludedChangeId ?? "",
).trim();
const hiddenPipeFidSet = new Set(
gisEdit.changes
.filter((change) => change.layerId === "tb_ms_swage_ppl")
.filter(
(change) => !change.attributeOnly && change.editType !== "DELETE",
)
.map((change) =>
normalizeGisEditFid(
change.fid ?? change.featureRef?.fid ?? change.objectId,
),
)
.filter((fid) => /^\d+$/.test(fid)),
);
if (/^\d+$/.test(normalizedExcludedFid)) {
hiddenPipeFidSet.add(normalizedExcludedFid);
}
const previewSnapFeatures = buildGisEditPipeSnapPreviewFeatures(
normalizedExcludedChangeId,
);
source.clear();
if (previewSnapFeatures.length > 0) {
source.addFeatures(previewSnapFeatures);
}
const screenExtent = view.calculateExtent(size);
const requestExtent = expandGisEditPipeSnapExtent(screenExtent);
const [minX, minY, maxX, maxY] = requestExtent;
const bboxFilter = `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${GisApp.TO_PROJ}')`;
const cqlFilter = bboxFilter;
const requestSeq = gisEditPipeSnapRequestSeqRef.current + 1;
gisEditPipeSnapRequestSeqRef.current = requestSeq;
gisEditPipeSnapAbortControllerRef.current?.abort();
const abortController = new AbortController();
gisEditPipeSnapAbortControllerRef.current = abortController;
try {
const layerModule = GisApp.Module.layer as any;
const storageName = String(layerModule?.storageNm ?? "hasudo:");
const params = new URLSearchParams({
SERVICE: "WFS",
VERSION: "1.1.0",
REQUEST: "GetFeature",
TYPENAME: `${storageName}tb_ms_swage_ppl`,
SRSNAME: GisApp.TO_PROJ,
OUTPUTFORMAT: "application/json",
CQL_FILTER: cqlFilter,
});
const response = await getRawJson(
`${GisApp.GEOSERVER_URL}?${params.toString()}`,
{
signal: abortController.signal,
},
);
if (requestSeq !== gisEditPipeSnapRequestSeqRef.current) return;
if (abortController.signal.aborted) return;
const featureCollection = getFeatureCollection(response);
const features = new GeoJSON()
.readFeatures(featureCollection, {
dataProjection: GisApp.TO_PROJ,
featureProjection: GisApp.TO_PROJ,
})
.filter(
(feature: any) =>
!hiddenPipeFidSet.has(getGisEditFeatureFid(feature)),
);
source.clear();
source.addFeatures(features);
if (previewSnapFeatures.length > 0) {
source.addFeatures(previewSnapFeatures);
}
} catch (error) {
if ((error as Error)?.name === "AbortError") return;
console.error("GIS ?? ???? ?? source ?? ??:", error);
source.clear();
if (previewSnapFeatures.length > 0) {
source.addFeatures(previewSnapFeatures);
}
} finally {
if (gisEditPipeSnapAbortControllerRef.current === abortController) {
gisEditPipeSnapAbortControllerRef.current = null;
}
}
},
[
buildGisEditPipeSnapPreviewFeatures,
ensureGisEditPipeSnapSource,
gisEdit.changes,
],
);
const restoreGisEditOriginalPipeMask = useCallback(() => {
gisEditOriginalLayerFilterRef.current.forEach((originalFilter, layer) => {
const source = layer?.getSource?.();
const params = { ...(source?.getParams?.() ?? {}) };
if (originalFilter) {
params.CQL_FILTER = originalFilter;
} else {
delete params.CQL_FILTER;
}
params._t = Date.now();
source?.updateParams?.(params);
source?.refresh?.();
source?.changed?.();
layer?.changed?.();
});
gisEditOriginalLayerFilterRef.current.clear();
gisEditOriginalFeatureStyleRef.current.forEach((originalStyle, feature) => {
feature?.setStyle?.(originalStyle);
feature?.changed?.();
});
gisEditOriginalFeatureStyleRef.current.clear();
gisMaps.current?.renderSync?.();
}, []);
const applyGisEditOriginalPipeMask = useCallback(
(hiddenFids: string[]) => {
const hiddenFidSet = new Set(
hiddenFids.map((fid) => normalizeGisEditFid(fid)).filter(Boolean),
);
if (hiddenFidSet.size === 0) {
restoreGisEditOriginalPipeMask();
return;
}
const map = gisMaps.current;
if (!map) return;
const filter = buildGisEditHiddenPipeFilter(Array.from(hiddenFidSet));
map.getAllLayers?.().forEach((layer: any) => {
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
if (layerTypeName !== "tb_ms_swage_ppl") return;
const source = layer.getSource?.();
if (source?.updateParams && filter) {
const params = { ...(source.getParams?.() ?? {}) };
if (!gisEditOriginalLayerFilterRef.current.has(layer)) {
gisEditOriginalLayerFilterRef.current.set(
layer,
String(params.CQL_FILTER ?? "").trim() || null,
);
}
const originalFilter =
gisEditOriginalLayerFilterRef.current.get(layer);
params.CQL_FILTER = originalFilter
? `(${originalFilter}) AND (${filter})`
: filter;
params._t = Date.now();
source.updateParams(params);
source.refresh?.();
source.changed?.();
layer.changed?.();
}
source?.getFeatures?.().forEach((feature: any) => {
const fid = getGisEditFeatureFid(feature);
const shouldHide = hiddenFidSet.has(fid);
if (
shouldHide &&
!gisEditOriginalFeatureStyleRef.current.has(feature)
) {
gisEditOriginalFeatureStyleRef.current.set(
feature,
feature.getStyle?.(),
);
feature.setStyle?.(() => []);
feature.changed?.();
}
if (
!shouldHide &&
gisEditOriginalFeatureStyleRef.current.has(feature)
) {
feature.setStyle?.(
gisEditOriginalFeatureStyleRef.current.get(feature),
);
gisEditOriginalFeatureStyleRef.current.delete(feature);
feature.changed?.();
}
});
});
gisMaps.current?.renderSync?.();
},
[restoreGisEditOriginalPipeMask],
);
const ensureGisEditVertexLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (gisEditVertexLayerRef.current && gisEditVertexSourceRef.current) {
return gisEditVertexSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 10005,
updateWhileAnimating: true,
updateWhileInteracting: true,
});
layer.set("id", GIS_EDIT_VERTEX_LAYER_ID);
layer.set("lyrNm", "GIS 편집 꼭지점");
layer.set("lyrTyNm", GIS_EDIT_VERTEX_LAYER_ID);
layer.set("clickPsblYn", "N");
const core = GisApp.Module.core as any;
core?.deleteLayer?.(GIS_EDIT_VERTEX_LAYER_ID);
map.addLayer(layer);
gisEditVertexLayerRef.current = layer;
gisEditVertexSourceRef.current = source;
return source;
}, []);
const clearGisEditVertexLayer = useCallback(() => {
gisEditVertexSourceRef.current?.clear();
gisEditSelectedVertexHandleKeyRef.current = "";
gisEditSelectedVertexMetaListRef.current = null;
}, []);
const applySelectedVertexHandleStyle = useCallback(() => {
const selectedHandleKey = gisEditSelectedVertexHandleKeyRef.current;
const vertexSource = gisEditVertexSourceRef.current;
if (!vertexSource) return;
vertexSource.getFeatures().forEach((feature: any) => {
const handleKind = String(
feature?.get?.("handleKind") ?? "",
) as VertexHandleMeta["kind"];
const handleSelectionKey = String(
feature?.get?.("handleSelectionKey") ?? "",
);
const isSelected =
Boolean(selectedHandleKey) && handleSelectionKey === selectedHandleKey;
feature.setStyle(getGisEditVertexHandleStyle(handleKind, isSelected));
});
vertexSource.changed?.();
}, []);
const clearSelectedVertexHandle = useCallback(() => {
gisEditSelectedVertexHandleKeyRef.current = "";
gisEditSelectedVertexMetaListRef.current = null;
applySelectedVertexHandleStyle();
}, [applySelectedVertexHandleStyle]);
const shouldUsePipeSnapSource =
gisEdit.isEditMode &&
gisEdit.isSnapEnabled &&
gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl";
const collectGisEditSnapFeatures = useCallback(
(targetFeature: any) => {
const map = gisMaps.current;
const previewSource = gisEditPreviewSourceRef.current;
const selectedLayerId = gisEdit.selectedLayer?.layerId;
if (!map || !previewSource || !selectedLayerId) return [] as any[];
const hiddenFidSet = new Set(
gisEdit.changes
.filter((change) => change.editType === "DELETE")
.map((change) =>
normalizeGisEditFid(
change.fid ?? change.featureRef?.fid ?? change.objectId,
),
)
.filter(Boolean),
);
const seen = new Set<string>();
const snapFeatures: any[] = [];
const targetChangeId = String(
targetFeature?.get?.("changeId") ?? "",
).trim();
const registerFeature = (feature: any) => {
if (!feature || feature === targetFeature) return;
if (!feature.getGeometry?.()) return;
if (getSelectedFeatureLayerTypeName(feature) !== selectedLayerId)
return;
const changeId = String(feature.get?.("changeId") ?? "").trim();
if (changeId && changeId === targetChangeId) return;
if (String(feature.get?.("editType") ?? "").trim() === "DELETE") return;
const fid = getGisEditFeatureFid(feature);
if (fid && hiddenFidSet.has(fid)) return;
const objectKey =
changeId ||
fid ||
String(feature.get?.("objectId") ?? feature.getId?.() ?? "");
const featureKey = `${selectedLayerId}:${objectKey}`;
if (!objectKey || seen.has(featureKey)) return;
seen.add(featureKey);
snapFeatures.push(feature);
};
previewSource.getFeatures().forEach(registerFeature);
map.getAllLayers?.().forEach((layer: any) => {
if (
layer === gisEditPreviewLayerRef.current ||
layer === gisEditVertexLayerRef.current
)
return;
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
if (layerTypeName !== selectedLayerId) return;
layer.getSource?.()?.getFeatures?.().forEach(registerFeature);
});
return snapFeatures;
},
[gisEdit.changes, gisEdit.selectedLayer?.layerId],
);
const removeGisEditDrawInteraction = useCallback(() => {
const map = gisMaps.current;
if (!map) return;
if (gisEditDrawSnapRef.current) {
map.removeInteraction(gisEditDrawSnapRef.current);
gisEditDrawSnapRef.current = null;
}
if (gisEditDrawRef.current) {
map.removeInteraction(gisEditDrawRef.current);
gisEditDrawRef.current = null;
}
}, []);
const removeGisEditInteractions = useCallback(() => {
const map = gisMaps.current;
if (!map) return;
if (gisEditSnapRef.current) {
map.removeInteraction(gisEditSnapRef.current);
gisEditSnapRef.current = null;
}
if (gisEditTranslateRef.current) {
map.removeInteraction(gisEditTranslateRef.current);
gisEditTranslateRef.current = null;
}
if (gisEditModifyRef.current) {
const modifyInteractions = Array.isArray(gisEditModifyRef.current)
? gisEditModifyRef.current
: [gisEditModifyRef.current];
modifyInteractions.forEach((interaction: any) => {
if (interaction) {
map.removeInteraction(interaction);
}
});
gisEditModifyRef.current = null;
}
clearGisEditVertexLayer();
setGisEditPreviewCursor("");
}, [clearGisEditVertexLayer, setGisEditPreviewCursor]);
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,
],
);
useEffect(() => {
if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return;
const map = gisMaps.current;
const clickKey = map.on("singleclick", (event: any) => {
const vertexHandleFeature =
gisEdit.pendingInteraction?.editType === "VERTEX_EDIT"
? map.forEachFeatureAtPixel(
event.pixel,
(feature: any, layer: any) =>
layer === gisEditVertexLayerRef.current ? feature : null,
{ hitTolerance: 14 },
)
: null;
if (vertexHandleFeature) {
event.preventDefault?.();
const handleKind = String(
vertexHandleFeature.get?.("handleKind") ?? "",
);
if (handleKind !== "vertex") {
clearSelectedVertexHandle();
gisEdit.announceStatus(
"흰색 midpoint는 드래그해서 새 꼭지점을 추가할 수 있습니다. Delete 삭제 대상은 주황색 실꼭지점입니다.",
);
return;
}
gisEditSelectedVertexHandleKeyRef.current = String(
vertexHandleFeature.get?.("handleSelectionKey") ?? "",
);
gisEditSelectedVertexMetaListRef.current =
(vertexHandleFeature.get?.("handleMetaList") as
| VertexHandleMeta[]
| undefined) ?? null;
applySelectedVertexHandleStyle();
gisEdit.announceStatus(
"주황색 실꼭지점을 선택했습니다. Delete 키를 누르면 해당 꼭지점을 삭제합니다.",
);
return;
}
const previewFeature = map.forEachFeatureAtPixel(
event.pixel,
(feature: any) =>
String(feature?.get?.("changeId") ?? "").trim() ? feature : null,
{ hitTolerance: 12 },
);
// 편집 도구가 켜진 동안에는 공통 Select를 우회하므로 원본 WFS 피처도 여기서 직접 판정한다.
const editableSourceFeature = previewFeature
? null
: gisEdit.activeTool && gisEdit.activeTool !== "ADD"
? map.forEachFeatureAtPixel(
event.pixel,
(feature: any) => {
if (String(feature?.get?.("changeId") ?? "").trim())
return null;
return getSelectedFeatureLayerTypeName(feature) ===
gisEdit.selectedLayer?.layerId
? feature
: null;
},
{ hitTolerance: 12 },
)
: null;
const selectedFeature = previewFeature ?? editableSourceFeature;
if (!selectedFeature) return;
clearSelectedVertexHandle();
const editSelection = gisEdit.registerFeatureSelection(selectedFeature);
if (!editSelection.handled) return;
event.preventDefault?.();
if (editSelection.editType === "DELETE") {
const select = GisApp.Module.select as any;
select?.resetSelectedFeature?.();
setGisEditDetailChangeId(null);
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
if (
gisEdit.activeTool === "MOVE" ||
gisEdit.activeTool === "VERTEX_EDIT"
) {
if (
gisEdit.activeTool === "MOVE" &&
typeof editSelection.changeId === "string" &&
editSelection.changeId
) {
gisEditMoveGripMetaRef.current = {
changeId: String(editSelection.changeId),
meta: getClosestGeometryVertexMeta(
selectedFeature.getGeometry?.(),
map,
event.coordinate,
),
};
}
setGisEditDetailChangeId(null);
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
const selectedChangeId =
typeof editSelection.changeId === "string"
? editSelection.changeId
: "";
const selectedChange = gisEdit.changes.find(
(change) => change.changeId === selectedChangeId,
);
const canOpenPipeDetail =
selectedChangeId && editSelection.editType !== "DELETE";
if (canOpenPipeDetail) {
openGisEditPipeDetail(selectedChangeId);
setFeatureData(
selectedChange?.editType === "ADD"
? {
lyrTypeNm: "tb_ms_swage_ppl",
fcltMngNo: "",
mngInstCd: "",
fclt_mng_no: "",
inst_mng_no: "",
}
: null,
);
window.setTimeout(() => {
setGisEditDetailChangeId(selectedChangeId);
setIsOpen(true);
}, 0);
setIsSearchModalOpen(false);
}
});
return () => {
unByKey(clickKey);
};
}, [
applySelectedVertexHandleStyle,
clearSelectedVertexHandle,
gisEdit.activeTool,
gisEdit.changes,
gisEdit.isEditMode,
gisEdit.pendingInteraction,
gisEdit.announceStatus,
gisEdit.registerFeatureSelection,
gisEdit.selectedLayer,
mapReady,
openGisEditPipeDetail,
]);
const gisEditSelectedLayerId = gisEdit.selectedLayer?.layerId ?? "";
const gisEditSelectedLayerGeometryType =
gisEdit.selectedLayer?.geometryType ?? null;
const gisEditSelectedLayerPreviewColors =
gisEdit.selectedLayer?.previewColors ?? null;
useEffect(() => {
if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return;
const map = gisMaps.current;
if (
gisEdit.activeTool !== "ADD" ||
!gisEditSelectedLayerId ||
!gisEditSelectedLayerGeometryType
) {
removeGisEditDrawInteraction();
return;
}
removeGisEditInteractions();
removeGisEditDrawInteraction();
const drawSource = ensureGisEditPreviewLayer();
if (!drawSource) return;
const drawType =
gisEditSelectedLayerGeometryType === "Point"
? "Point"
: gisEditSelectedLayerGeometryType === "Polygon"
? "Polygon"
: "LineString";
const draw = new Draw({
source: drawSource,
type: drawType,
});
const pipeSnapSource = shouldUsePipeSnapSource
? ensureGisEditPipeSnapSource()
: null;
draw.on("drawstart", () => {
setGisEditPreviewCursor("crosshair");
});
draw.on("drawend", (event: any) => {
try {
const pipeSnapSource = shouldUsePipeSnapSource
? ensureGisEditPipeSnapSource()
: null;
if (pipeSnapSource?.getFeatures?.().length) {
applyGeometryVertexSnaps(
event.feature.getGeometry(),
pipeSnapSource,
map,
20,
);
}
const geometryObject = new GeoJSON().writeGeometryObject(
event.feature.getGeometry(),
{
featureProjection: GisApp.TO_PROJ,
dataProjection: FLOW_DIRECTION_SRS,
},
) as Record<string, unknown>;
const changeId = gisEdit.registerAddGeometry(geometryObject);
if (changeId) {
event.feature.setId(`gis-edit:${changeId}`);
event.feature.set("fid", changeId);
event.feature.set("editType", "ADD");
event.feature.set("objectId", changeId);
event.feature.set("layerId", gisEditSelectedLayerId);
event.feature.set("changeId", changeId);
event.feature.set(
"layerPointColor",
gisEditSelectedLayerPreviewColors?.pointColor ?? "",
);
event.feature.set(
"layerLineColor",
gisEditSelectedLayerPreviewColors?.lineColor ?? "",
);
event.feature.set(
"layerFillColor",
gisEditSelectedLayerPreviewColors?.fillColor ?? "",
);
event.feature.set("isInteractionTarget", false);
event.feature.set("isDragging", false);
event.feature.set("previewLabel", "신규 추가");
event.feature.setStyle(createGisEditPreviewStyle(event.feature));
gisMaps.current?.renderSync?.();
window.setTimeout(() => {
const source = ensureGisEditPreviewLayer();
if (!source) return;
if (!source.getFeatureById?.(`gis-edit:${changeId}`)) {
source.addFeature(event.feature);
}
event.feature.changed?.();
source.changed?.();
gisEditPreviewLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
}, 0);
} else {
window.setTimeout(() => {
drawSource.removeFeature(event.feature);
}, 0);
}
if (changeId && gisEditSelectedLayerId === "tb_ms_swage_ppl") {
openGisEditPipeDetail(changeId);
setFeatureData({
lyrTypeNm: "tb_ms_swage_ppl",
fcltMngNo: "",
mngInstCd: "",
fclt_mng_no: "",
inst_mng_no: "",
});
window.setTimeout(() => {
setGisEditDetailChangeId(changeId);
setIsOpen(true);
}, 0);
setIsSearchModalOpen(false);
window.setTimeout(() => {
gisEdit.deactivateTool();
removeGisEditDrawInteraction();
}, 0);
gisEdit.announceStatus(
"하수관로 신규 객체를 지도에 추가했습니다. 우측 상세정보를 입력한 뒤 저장하세요.",
);
}
} catch (error) {
console.error("GIS 편집 ADD geometry 변환 실패:", error);
gisEdit.announceStatus(
"신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요.",
);
} finally {
setGisEditPreviewCursor("crosshair");
}
});
map.addInteraction(draw);
gisEditDrawRef.current = draw;
if (pipeSnapSource) {
const drawSnap = new Snap({
source: pipeSnapSource,
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(drawSnap);
gisEditDrawSnapRef.current = drawSnap;
}
return () => {
removeGisEditDrawInteraction();
};
}, [
ensureGisEditPreviewLayer,
ensureGisEditPipeSnapSource,
gisEdit.activeTool,
gisEdit.announceStatus,
gisEdit.deactivateTool,
gisEdit.isEditMode,
gisEdit.isSnapEnabled,
gisEdit.registerAddGeometry,
gisEditSelectedLayerGeometryType,
gisEditSelectedLayerId,
gisEditSelectedLayerPreviewColors,
mapReady,
openGisEditPipeDetail,
removeGisEditDrawInteraction,
removeGisEditInteractions,
setGisEditPreviewCursor,
shouldUsePipeSnapSource,
]);
useEffect(() => {
if (!mapReady || !flowDirectionLayerInfo) return;
// 트리 체크 상태나 관종 레이어 선택이 바뀌면 유하방향 API vector layer를 추가/갱신/제거한다.
if (isFlowDirectionChecked) {
const requestUsageCodes =
flowDirectionSelectionRequestUsageCodesRef.current;
flowDirectionSelectionRequestUsageCodesRef.current = null;
if (requestUsageCodes && requestUsageCodes.length === 0) return;
loadFlowDirectionLayer(
flowDirectionLayerInfo,
requestUsageCodes ?? undefined,
);
} else {
removeFlowDirectionLayer();
}
}, [
flowDirectionLayerInfo,
flowDirectionSelectionVersion,
isFlowDirectionChecked,
loadFlowDirectionLayer,
mapReady,
removeFlowDirectionLayer,
]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
// 유하방향이 켜진 상태에서 지도 이동/확대축소가 끝나면 현재 BBOX 기준으로 다시 조회한다.
return subscribeFlowDirectionMoveEnd(
gisMaps.current,
() => ({
checked: flowDirectionCheckedRef.current,
layerInfo: flowDirectionLayerInfoRef.current,
}),
loadFlowDirectionLayer,
unByKey,
);
}, [loadFlowDirectionLayer, mapReady]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const source = ensureGisEditPreviewLayer();
if (!source) return;
if (gisEdit.changes.length === 0) {
clearGisEditPreviewLayer();
return;
}
const geoJson = new GeoJSON();
const layerPreviewColorMap = new globalThis.Map<string, any>(
gisEdit.editableLayers.map(
(layer) => [layer.layerId, layer.previewColors] as const,
),
);
const existingFeatureMap = new globalThis.Map<string, any>(
source
.getFeatures()
.map(
(feature: any) =>
[String(feature.get("changeId") ?? ""), feature] as const,
),
);
const nextChangeIdSet = new Set<string>();
gisEdit.changes.forEach((change) => {
if (change.attributeOnly) return;
const geometry =
change.editType === "DELETE"
? change.beforeGeometry
: change.afterGeometry;
if (!geometry) return;
const previewEditType =
gisEdit.pendingInteraction?.changeId === change.changeId
? gisEdit.pendingInteraction.editType
: change.editType;
const previewLabel =
gisEdit.focusedChangeId === change.changeId &&
previewEditType === "MOVE"
? "드래그 이동"
: gisEdit.focusedChangeId === change.changeId &&
previewEditType === "VERTEX_EDIT"
? "꼭지점 편집"
: previewEditType === "DELETE"
? "삭제 예정"
: previewEditType === "MOVE"
? "이동 예정"
: previewEditType === "VERTEX_EDIT"
? "편집 예정"
: "신규 추가";
let feature = existingFeatureMap.get(change.changeId);
const previewColors = layerPreviewColorMap.get(change.layerId);
const originalProperties = change.featureProperties ?? {};
const previewObjectId = change.objectId ?? change.objectLabel;
const previewFid =
originalProperties["fid"] ??
change.fid ??
change.featureRef?.fid ??
previewObjectId;
if (!feature) {
feature = geoJson.readFeature(
{
type: "Feature",
geometry,
properties: {
...originalProperties,
fid: previewFid,
editType: previewEditType,
objectId: previewObjectId,
layerId: change.layerId,
changeId: change.changeId,
},
},
{
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
},
);
feature.setId(`gis-edit:${change.changeId}`);
source.addFeature(feature);
} else {
feature.setGeometry(
geoJson.readGeometry(geometry, {
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
}),
);
}
Object.entries(originalProperties).forEach(([key, value]) => {
if (key === "geometry") return;
feature.set(key, value);
});
feature.set("fid", previewFid);
feature.set("editType", previewEditType);
feature.set("objectId", previewObjectId);
feature.set("layerId", change.layerId);
feature.set("changeId", change.changeId);
feature.set("layerPointColor", previewColors?.pointColor ?? "");
feature.set("layerLineColor", previewColors?.lineColor ?? "");
feature.set("layerFillColor", previewColors?.fillColor ?? "");
feature.set(
"isInteractionTarget",
gisEdit.focusedChangeId === change.changeId,
);
feature.set("isDragging", Boolean(feature.get("isDragging")));
feature.set("previewLabel", previewLabel);
feature.setStyle(createGisEditPreviewStyle(feature));
nextChangeIdSet.add(change.changeId);
});
existingFeatureMap.forEach((feature: any, changeId: string) => {
if (!nextChangeIdSet.has(changeId)) {
source.removeFeature(feature);
}
});
source.changed?.();
gisEditPreviewLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
}, [
clearGisEditPreviewLayer,
ensureGisEditPreviewLayer,
gisEdit.changes,
gisEdit.editableLayers,
gisEdit.focusedChangeId,
gisEdit.isEditMode,
gisEdit.pendingInteraction,
mapReady,
]);
useEffect(() => {
if (!mapReady || !gisEdit.isEditMode || gisEdit.changes.length === 0)
return;
ensureGisEditPreviewLayer();
gisEditPreviewSourceRef.current?.changed?.();
gisEditPreviewLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
}, [
ensureGisEditPreviewLayer,
gisEdit.changes.length,
gisEdit.isEditMode,
mapReady,
]);
// ADD 완료 직후 열린 상세 패널 아래에 신규 선형이 가려지지 않도록 최초 1회 지도 가시 영역에 맞춘다.
useEffect(() => {
if (!mapReady || !gisEdit.isEditMode || !gisEdit.focusedChangeId) return;
const focusedChange = gisEdit.changes.find(
(change) => change.changeId === gisEdit.focusedChangeId,
);
if (
focusedChange?.editType !== "ADD" ||
!focusedChange.afterGeometry ||
gisEditAutoFocusedAddChangeIdRef.current === focusedChange.changeId
) {
return;
}
gisEditAutoFocusedAddChangeIdRef.current = focusedChange.changeId;
const focusTimer = window.setTimeout(() => {
const map = gisMaps.current;
if (!map) return;
ensureGisEditPreviewLayer();
map.updateSize?.();
focusGisEditChangeOnMap(focusedChange.changeId);
gisEditPreviewSourceRef.current?.changed?.();
gisEditPreviewLayerRef.current?.changed?.();
map.renderSync?.();
}, 120);
return () => window.clearTimeout(focusTimer);
}, [
ensureGisEditPreviewLayer,
focusGisEditChangeOnMap,
gisEdit.changes,
gisEdit.focusedChangeId,
gisEdit.isEditMode,
mapReady,
]);
const gisEditMaskedPipeFidKey = useMemo(() => {
const fids = gisEdit.changes
.filter((change) => change.layerId === "tb_ms_swage_ppl")
.filter((change) => !change.attributeOnly)
.filter(
(change) => change.editType !== "ADD" && change.editType !== "DELETE",
)
.map((change) =>
normalizeGisEditFid(
change.fid ?? change.featureRef?.fid ?? change.objectId,
),
)
.filter((fid) => /^\d+$/.test(fid));
return Array.from(new Set(fids)).sort().join(",");
}, [gisEdit.changes]);
useEffect(() => {
if (!mapReady || !gisEdit.isEditMode) {
restoreGisEditOriginalPipeMask();
return;
}
applyGisEditOriginalPipeMask(
gisEditMaskedPipeFidKey ? gisEditMaskedPipeFidKey.split(",") : [],
);
}, [
applyGisEditOriginalPipeMask,
gisEdit.isEditMode,
gisEditMaskedPipeFidKey,
mapReady,
restoreGisEditOriginalPipeMask,
]);
useEffect(
() => () => {
restoreGisEditOriginalPipeMask();
},
[restoreGisEditOriginalPipeMask],
);
useEffect(
() => () => {
clearGisEditPipeSnapSource();
},
[clearGisEditPipeSnapSource],
);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
if (!shouldUsePipeSnapSource) {
clearGisEditPipeSnapSource();
return;
}
const excludedFid =
gisEdit.pendingInteraction?.editType === "VERTEX_EDIT" ||
gisEdit.pendingInteraction?.editType === "MOVE"
? normalizeGisEditFid(
gisEdit.changes.find(
(change) =>
change.changeId === gisEdit.pendingInteraction?.changeId,
)?.fid ?? "",
)
: "";
void loadGisEditPipeSnapSource({
excludedFid,
excludedChangeId: gisEdit.pendingInteraction?.changeId ?? null,
});
}, [
clearGisEditPipeSnapSource,
gisEdit.changes,
gisEdit.pendingInteraction,
loadGisEditPipeSnapSource,
mapReady,
shouldUsePipeSnapSource,
]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
if (!shouldUsePipeSnapSource) return;
const key = gisMaps.current.on("moveend", () => {
const excludedFid =
gisEdit.pendingInteraction?.editType === "VERTEX_EDIT" ||
gisEdit.pendingInteraction?.editType === "MOVE"
? normalizeGisEditFid(
gisEdit.changes.find(
(change) =>
change.changeId === gisEdit.pendingInteraction?.changeId,
)?.fid ?? "",
)
: "";
void loadGisEditPipeSnapSource({
excludedFid,
excludedChangeId: gisEdit.pendingInteraction?.changeId ?? null,
});
});
return () => {
unByKey(key);
};
}, [
gisEdit.changes,
gisEdit.pendingInteraction,
loadGisEditPipeSnapSource,
mapReady,
shouldUsePipeSnapSource,
]);
useEffect(() => {
if (
!mapReady ||
!gisMaps.current ||
!gisEdit.isEditMode ||
!gisEdit.pendingInteraction
)
return;
const source = ensureGisEditPreviewLayer();
const map = gisMaps.current;
if (!source || !map) return;
const targetFeature = source
.getFeatures()
.find(
(feature: any) =>
feature.get("changeId") === gisEdit.pendingInteraction?.changeId,
);
if (!targetFeature) {
gisEdit.clearPendingInteraction();
return;
}
removeGisEditInteractions();
const features = new Collection([targetFeature]);
let cleanupPointerMove: (() => void) | undefined;
const writeGeometryObject = () => {
const geometry = targetFeature.getGeometry();
if (!geometry) return null;
return new GeoJSON().writeGeometryObject(geometry, {
featureProjection: GisApp.TO_PROJ,
dataProjection: FLOW_DIRECTION_SRS,
}) as Record<string, unknown>;
};
const usePipeSnapSource =
shouldUsePipeSnapSource &&
(gisEdit.pendingInteraction.editType === "MOVE" ||
gisEdit.pendingInteraction.editType === "VERTEX_EDIT");
const pipeSnapSource = usePipeSnapSource
? ensureGisEditPipeSnapSource()
: null;
const pipeSnapSourceHasFeatures = Boolean(
pipeSnapSource?.getFeatures?.().length,
);
const snapFeatures =
(!usePipeSnapSource || !pipeSnapSourceHasFeatures) &&
gisEdit.isSnapEnabled
? collectGisEditSnapFeatures(targetFeature)
: [];
if (gisEdit.pendingInteraction.editType === "MOVE") {
const translate = new Translate({ features, hitTolerance: 10 });
let lastTranslateCoordinate: number[] | null = null;
let translateStartCoordinate: number[] | null = null;
let originalMoveGeometrySnapshot =
targetFeature.getGeometry?.()?.clone?.() ?? null;
let moveGripCoordinate: number[] | null = null;
const moveGripMeta =
gisEditMoveGripMetaRef.current?.changeId ===
gisEdit.pendingInteraction.changeId
? gisEditMoveGripMetaRef.current.meta
: null;
translate.on("translatestart", (event: any) => {
lastTranslateCoordinate = Array.isArray(event?.coordinate)
? event.coordinate
: null;
translateStartCoordinate = Array.isArray(event?.coordinate)
? event.coordinate
: null;
originalMoveGeometrySnapshot =
targetFeature.getGeometry?.()?.clone?.() ?? null;
moveGripCoordinate =
getGeometryVertexCoordinateByMeta(
originalMoveGeometrySnapshot,
moveGripMeta,
) ??
getClosestGeometryVertexCoordinate(
originalMoveGeometrySnapshot,
map,
translateStartCoordinate,
);
targetFeature.set("isDragging", true);
targetFeature.set("previewLabel", "이동 중");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("grabbing");
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
translate.on("translating", (event: any) => {
lastTranslateCoordinate = Array.isArray(event?.coordinate)
? event.coordinate
: lastTranslateCoordinate;
if (
originalMoveGeometrySnapshot?.clone &&
Array.isArray(lastTranslateCoordinate) &&
Array.isArray(translateStartCoordinate)
) {
const geometry = originalMoveGeometrySnapshot.clone();
const deltaX =
Number(lastTranslateCoordinate[0] ?? 0) -
Number(translateStartCoordinate[0] ?? 0);
const deltaY =
Number(lastTranslateCoordinate[1] ?? 0) -
Number(translateStartCoordinate[1] ?? 0);
geometry.translate(deltaX, deltaY);
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
const movedGripCoordinate = Array.isArray(moveGripCoordinate)
? [
Number(moveGripCoordinate[0] ?? 0) + deltaX,
Number(moveGripCoordinate[1] ?? 0) + deltaY,
]
: lastTranslateCoordinate;
applyGeometryMoveSnap(
geometry,
pipeSnapSource,
map,
movedGripCoordinate,
28,
);
}
targetFeature.setGeometry(geometry);
}
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
translate.on("translateend", (event: any) => {
const objectId = String(targetFeature.get("objectId") ?? "");
const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어";
lastTranslateCoordinate = Array.isArray(event?.coordinate)
? event.coordinate
: lastTranslateCoordinate;
targetFeature.set("isDragging", false);
targetFeature.set("previewLabel", "드래그 이동");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("grab");
if (
originalMoveGeometrySnapshot?.clone &&
Array.isArray(lastTranslateCoordinate) &&
Array.isArray(translateStartCoordinate)
) {
const geometry = originalMoveGeometrySnapshot.clone();
const deltaX =
Number(lastTranslateCoordinate[0] ?? 0) -
Number(translateStartCoordinate[0] ?? 0);
const deltaY =
Number(lastTranslateCoordinate[1] ?? 0) -
Number(translateStartCoordinate[1] ?? 0);
geometry.translate(deltaX, deltaY);
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
const movedGripCoordinate = Array.isArray(moveGripCoordinate)
? [
Number(moveGripCoordinate[0] ?? 0) + deltaX,
Number(moveGripCoordinate[1] ?? 0) + deltaY,
]
: lastTranslateCoordinate;
applyGeometryMoveSnap(
geometry,
pipeSnapSource,
map,
movedGripCoordinate,
28,
);
}
targetFeature.setGeometry(geometry);
}
gisEdit.updateChangeGeometry(
gisEdit.pendingInteraction!.changeId,
writeGeometryObject(),
`${layerName} ${objectId} 객체 위치를 지도에서 이동했습니다.`,
{ keepMoveToolActive: true },
);
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
map.addInteraction(translate);
gisEditTranslateRef.current = translate;
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
const snap = new Snap({
source: pipeSnapSource,
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(snap);
gisEditSnapRef.current = snap;
} else if (gisEdit.isSnapEnabled && snapFeatures.length > 0) {
const snap = new Snap({
features: new Collection(snapFeatures),
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(snap);
gisEditSnapRef.current = snap;
}
setGisEditPreviewCursor("grab");
map.renderSync?.();
}
if (gisEdit.pendingInteraction.editType === "VERTEX_EDIT") {
const geometry = targetFeature.getGeometry?.();
const geometryType = geometry?.getType?.();
const objectId = String(targetFeature.get("objectId") ?? "");
const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어";
if (
!geometry ||
!geometryType ||
geometryType === "Point" ||
geometryType === "MultiPoint"
) {
gisEdit.announceStatus(
`${layerName} ${objectId} 객체는 꼭지점편집 대상 geometry가 아닙니다.`,
);
gisEdit.clearPendingInteraction();
return;
}
const vertexSource = ensureGisEditVertexLayer();
const vertexLayer = gisEditVertexLayerRef.current;
if (!vertexSource || !vertexLayer) {
gisEdit.announceStatus("꼭지점 편집 레이어를 준비하지 못했습니다.");
gisEdit.clearPendingInteraction();
return;
}
const renderVertexHandles = (
hoveredMidpoint: VertexHandleCandidate | null = null,
) => {
vertexSource.clear();
buildVertexHandleFeatures(targetFeature.getGeometry?.(), map).forEach(
(handleFeature: Feature) => {
vertexSource.addFeature(handleFeature);
},
);
if (hoveredMidpoint) {
vertexSource.addFeature(
createVertexHandleFeature(
hoveredMidpoint.coordinate,
hoveredMidpoint.meta,
),
);
}
vertexSource.changed?.();
applySelectedVertexHandleStyle();
};
let geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject());
let activeMidpointKey = "";
let isDraggingHandle = false;
let originalGeometrySnapshot =
targetFeature.getGeometry?.()?.clone?.() ?? null;
renderVertexHandles();
targetFeature.set("previewLabel", "꼭지점 편집");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("crosshair");
const pointerMoveKey = map.on("pointermove", (event: any) => {
if (isDraggingHandle || event?.dragging) return;
const hoveredMidpoint = findHoveredMidpointCandidate(
map,
targetFeature.getGeometry?.(),
event.coordinate,
);
const nextMidpointKey = hoveredMidpoint
? `${hoveredMidpoint.meta.geometryType}:${hoveredMidpoint.meta.partIndex ?? -1}:${hoveredMidpoint.meta.polygonIndex ?? -1}:${hoveredMidpoint.meta.ringIndex ?? -1}:${hoveredMidpoint.meta.coordIndex}`
: "";
if (nextMidpointKey === activeMidpointKey) return;
activeMidpointKey = nextMidpointKey;
renderVertexHandles(hoveredMidpoint);
setGisEditPreviewCursor(hoveredMidpoint ? "copy" : "crosshair");
map.renderSync?.();
});
const vertexTranslate = new Translate({
layers: [vertexLayer],
hitTolerance: 14,
});
vertexTranslate.on("translatestart", (event: any) => {
isDraggingHandle = true;
geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject());
originalGeometrySnapshot =
targetFeature.getGeometry?.()?.clone?.() ?? null;
clearSelectedVertexHandle();
const handleFeature = event.features?.item?.(0);
if (handleFeature) {
handleFeature.setStyle(GIS_EDIT_VERTEX_DRAGGING_STYLE);
}
targetFeature.set("previewLabel", "꼭지점 편집 중");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("grabbing");
map.renderSync?.();
});
vertexTranslate.on("translating", (event: any) => {
const handleFeature = event.features?.item?.(0);
const handleGeometry = handleFeature?.getGeometry?.();
const nextCoordinate = handleGeometry?.getCoordinates?.();
const handleMeta = handleFeature?.get?.("handleMeta") as
| VertexHandleMeta
| undefined;
const handleMetaList =
(handleFeature?.get?.("handleMetaList") as
| VertexHandleMeta[]
| undefined) ?? (handleMeta ? [handleMeta] : []);
if (
handleMetaList.length === 0 ||
!Array.isArray(nextCoordinate) ||
!originalGeometrySnapshot?.clone
) {
return;
}
targetFeature.setGeometry(originalGeometrySnapshot.clone());
handleMetaList.forEach((meta) => {
applyVertexHandleMove(
targetFeature.getGeometry?.(),
meta,
nextCoordinate,
);
});
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
vertexTranslate.on("translateend", (event: any) => {
isDraggingHandle = false;
const handleFeature = event.features?.item?.(0);
const handleGeometry = handleFeature?.getGeometry?.();
const nextCoordinate = handleGeometry?.getCoordinates?.();
const handleMeta = handleFeature?.get?.("handleMeta") as
| VertexHandleMeta
| undefined;
const handleMetaList =
(handleFeature?.get?.("handleMetaList") as
| VertexHandleMeta[]
| undefined) ?? (handleMeta ? [handleMeta] : []);
targetFeature.set("previewLabel", "꼭지점 편집");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("crosshair");
activeMidpointKey = "";
if (handleMetaList.length === 0 || !Array.isArray(nextCoordinate)) {
renderVertexHandles();
map.renderSync?.();
return;
}
handleMetaList.forEach((meta) => {
applyVertexHandleMove(
targetFeature.getGeometry?.(),
meta,
nextCoordinate,
);
});
const nextGeometry = writeGeometryObject();
const geometryChanged =
geometryBeforeModifyKey !== geometryObjectToKey(nextGeometry);
renderVertexHandles();
if (!geometryChanged) {
gisEdit.announceStatus(
`${layerName} ${objectId} 객체는 아직 형상이 바뀌지 않았습니다. 주황색 꼭지점 또는 가상 꼭지점을 직접 드래그해 주세요.`,
);
map.renderSync?.();
return;
}
gisEdit.updateChangeGeometry(
gisEdit.pendingInteraction!.changeId,
nextGeometry,
`${layerName} ${objectId} 객체 꼭지점을 지도에서 조정했습니다.`,
{ keepVertexEditActive: true },
);
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
map.addInteraction(vertexTranslate);
gisEditModifyRef.current = vertexTranslate;
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
const snap = new Snap({
source: pipeSnapSource,
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(snap);
gisEditSnapRef.current = snap;
} else if (gisEdit.isSnapEnabled && snapFeatures.length > 0) {
const snap = new Snap({
features: new Collection(snapFeatures),
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(snap);
gisEditSnapRef.current = snap;
}
gisMaps.current?.renderSync?.();
cleanupPointerMove = () => {
unByKey(pointerMoveKey);
};
}
return cleanupPointerMove;
}, [
applySelectedVertexHandleStyle,
clearGisEditVertexLayer,
clearSelectedVertexHandle,
collectGisEditSnapFeatures,
ensureGisEditPipeSnapSource,
ensureGisEditVertexLayer,
ensureGisEditPreviewLayer,
gisEdit.isSnapEnabled,
gisEdit.pendingInteraction,
gisEdit.selectedLayer,
gisEdit.updateChangeGeometry,
gisEdit.announceStatus,
gisEdit.clearPendingInteraction,
gisEdit.isEditMode,
mapReady,
removeGisEditInteractions,
shouldUsePipeSnapSource,
]);
useEffect(() => {
if (
!mapReady ||
!gisMaps.current ||
!gisEdit.isEditMode ||
gisEdit.pendingInteraction?.editType !== "VERTEX_EDIT"
) {
clearSelectedVertexHandle();
return;
}
const pendingInteraction = gisEdit.pendingInteraction;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Delete") return;
if (
isTypingElement(event.target) ||
isTypingElement(document.activeElement)
)
return;
const source = ensureGisEditPreviewLayer();
const map = gisMaps.current;
if (!source || !map) return;
const targetFeature = source
.getFeatures()
.find(
(feature: any) =>
feature.get("changeId") === gisEdit.pendingInteraction?.changeId,
);
if (!targetFeature) return;
const selectedMetaList = gisEditSelectedVertexMetaListRef.current ?? [];
if (selectedMetaList.length === 0) {
gisEdit.announceStatus(
"Delete로 삭제할 주황색 실꼭지점을 먼저 클릭하세요.",
);
return;
}
const deleteResult = deleteSelectedVertexHandles(
targetFeature.getGeometry?.(),
selectedMetaList,
);
if (!deleteResult.ok) {
gisEdit.announceStatus(
deleteResult.message ?? "꼭지점을 삭제하지 못했습니다.",
);
return;
}
event.preventDefault();
clearSelectedVertexHandle();
const objectId = String(targetFeature.get("objectId") ?? "");
const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어";
const geometry = targetFeature.getGeometry();
if (!geometry) {
gisEdit.announceStatus(
"꼭지점 삭제 후 geometry를 확인하지 못했습니다.",
);
return;
}
targetFeature.set("previewLabel", "꼭지점 편집");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
gisEdit.updateChangeGeometry(
pendingInteraction.changeId,
new GeoJSON().writeGeometryObject(geometry, {
featureProjection: GisApp.TO_PROJ,
dataProjection: FLOW_DIRECTION_SRS,
}) as Record<string, unknown>,
`${layerName} ${objectId} 객체 꼭지점을 삭제했습니다.`,
{ keepVertexEditActive: true },
);
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [
clearSelectedVertexHandle,
ensureGisEditPreviewLayer,
gisEdit.isEditMode,
gisEdit.pendingInteraction,
gisEdit.selectedLayer,
gisEdit.updateChangeGeometry,
gisEdit.announceStatus,
mapReady,
]);
useEffect(() => {
if (
!gisEdit.isEditMode ||
(gisEdit.activeTool !== "MOVE" && gisEdit.activeTool !== "VERTEX_EDIT")
) {
removeGisEditInteractions();
}
}, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditInteractions]);
useEffect(() => {
const select = GisApp.Module.select as any;
if (!select?.setActive) return;
if (gisEdit.isEditMode && gisEdit.activeTool) {
select.resetSelectedFeature?.();
select.setActive(false);
return;
}
select.setActive(true);
}, [gisEdit.activeTool, gisEdit.isEditMode, mapReady]);
useEffect(() => {
if (!gisEdit.isEditMode || gisEdit.activeTool !== "ADD") {
removeGisEditDrawInteraction();
}
}, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditDrawInteraction]);
useEffect(() => {
if (!mapReady || !isExmnPplChecked) {
removeExmnResultLayer("PPL");
return;
}
void loadExmnResultLayer(
"PPL",
isExmnPplSettingEnabled ? exmnPplFilters : EMPTY_EXMN_MAP_FILTERS,
);
}, [
exmnPplFilters,
isExmnPplChecked,
isExmnPplSettingEnabled,
loadExmnResultLayer,
mapReady,
removeExmnResultLayer,
]);
useEffect(() => {
if (!mapReady || !isExmnMnhlChecked) {
removeExmnResultLayer("MNHL");
return;
}
void loadExmnResultLayer(
"MNHL",
isExmnMnhlSettingEnabled ? exmnMnhlFilters : EMPTY_EXMN_MAP_FILTERS,
);
}, [
exmnMnhlFilters,
isExmnMnhlChecked,
isExmnMnhlSettingEnabled,
loadExmnResultLayer,
mapReady,
removeExmnResultLayer,
]);
useEffect(() => {
if (!mapReady || !isSinkholeChecked) {
removeSinkholeLayer();
return;
}
void loadSinkholeLayer(appliedSinkholeFilters);
}, [
appliedSinkholeFilters,
isSinkholeChecked,
loadSinkholeLayer,
mapReady,
removeSinkholeLayer,
sinkholeSelectionVersion,
]);
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]);
useEffect(() => {
renderSelectedSinkhole(selectedSinkholeDetail, sinkholeRadiusMeters);
}, [renderSelectedSinkhole, selectedSinkholeDetail, sinkholeRadiusMeters]);
useEffect(() => {
renderSelectedFldngHstry(selectedFldngHstryDetail);
}, [renderSelectedFldngHstry, selectedFldngHstryDetail]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const map = gisMaps.current;
const clickKey = map.on("singleclick", (event: any) => {
if (!selectedFldngHstryDetail || !isFldngHstryChecked) return;
let clickedFldngHstry = false;
map.forEachFeatureAtPixel(
event.pixel,
(feature: any, layer: any) => {
if (
layer === fldngHstryAreaLayerRef.current ||
layer === fldngHstryDepthLayerRef.current
) {
clickedFldngHstry = true;
return feature;
}
return undefined;
},
{ hitTolerance: 4 },
);
if (!clickedFldngHstry) return;
const size = map.getSize?.() ?? [1200, 800];
const popupWidth = 220;
const popupHeight = 210;
const nextLeft = Math.min(
Math.max(event.pixel[0] + 12, 12),
size[0] - popupWidth - 12,
);
const nextTop = Math.min(
Math.max(event.pixel[1] - 12, 12),
size[1] - popupHeight - 12,
);
setFldngHstryDetailPopupPosition({
left: nextLeft,
top: nextTop,
});
setIsSearchModalOpen(false);
});
return () => {
unByKey(clickKey);
};
}, [
isFldngHstryChecked,
mapReady,
selectedFldngHstryDetail,
setIsSearchModalOpen,
]);
useEffect(() => {
return () => {
clearFldngHstryLayers();
};
}, [clearFldngHstryLayers]);
return (
<>
<div className="sn-map-control-layout relative h-[calc(100dvh-6rem)] overflow-hidden bg-container">
{layerLoadError ? (
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-background px-4 py-3 text-sm text-red-700 shadow">
레이어 설정 조회 실패: {layerLoadError}
</div>
) : null}
{agedPipeDownloadProgressText ? (
<div className="absolute left-4 top-20 z-[60] max-w-[520px] rounded border border-emerald-200 bg-background px-4 py-3 text-sm text-emerald-700 shadow">
{agedPipeDownloadProgressText}
</div>
) : null}
{selectedWaterRiskResultRow && waterRiskInfoCardStyle ? (
// 위험/정상 배지는 통수위험 상태를 즉시 구분하는 업무 의미색이므로 고정색을 유지한다.
<div
style={waterRiskInfoCardStyle}
className="absolute z-[61] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
>
<div className="flex items-center justify-between bg-secondary px-[9px] py-[7px]">
<div>
<div className="text-[10px] font-semibold text-muted-foreground">
중점관리지역
</div>
<div className="mt-[2px] text-[13px] font-bold text-foreground">
{selectedWaterRiskResultRow.areaName}
</div>
</div>
<span
className={`inline-flex min-w-[48px] items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
selectedWaterRiskDisplayStatus === "DANGER"
? "bg-[#ff1c15] text-white"
: "bg-[#e8f6f4] text-[#0f766e]"
}`}
>
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
</span>
</div>
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
<div>GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면</div>
<div>
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
<span className="font-bold text-[#1677ff]">정상</span>
</div>
</div>
<table className="w-full table-fixed border-collapse border-t border-border">
<colgroup>
<col className="w-[68%]" />
<col className="w-[32%]" />
</colgroup>
<tbody>
{[
[
"현재 누적강우량(mm)",
selectedWaterRiskMapView?.currentAccumRainQty,
1,
],
[
"예측강우량(mm)",
selectedWaterRiskMapView?.forecastRainQty,
1,
],
[
"최대 침수유량(m³/s)",
selectedWaterRiskMapView?.maxFloodingFlow,
2,
],
].map(([label, value, digits], index) => (
<tr
key={String(label)}
className={index < 2 ? "border-b border-border" : ""}
>
<td className="border-r border-border bg-secondary px-[9px] py-[8px] text-[10px] font-semibold text-label">
{label}
</td>
<td className="px-[9px] py-[7px] text-right text-[12px] font-bold text-foreground">
{typeof value === "number"
? value.toFixed(Number(digits))
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{selectedWaterRiskMapView ? (
// Capacity와 Flooding 색상은 SWMM 결과의 업무 의미색이므로 테마와 무관하게 유지한다.
<div className="absolute bottom-4 left-[270px] z-[61] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
<div className="mb-1 font-bold">Capacity</div>
<div className="space-y-1 font-semibold">
{[
["#0000ff", "≤ 0.25"],
["#00d9ff", "≤ 0.50"],
["#00d95f", "≤ 0.75"],
["#ffe100", "< 1.00"],
["#ff1c15", "≥ 1.00"],
].map(([color, label]) => (
<div key={label} className="flex items-center gap-2">
<span
className="inline-block h-[10px] w-[10px] border border-foreground"
style={{ backgroundColor: color }}
/>
<span>{label}</span>
</div>
))}
</div>
<div className="mt-2 border-t border-border pt-2">
<div className="font-bold">Flooding(m³/s)</div>
<div className="mt-1 flex gap-2">
<span className="text-[#1677ff]">● 0</span>
<span className="text-[#ff1c15]">● &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)}
onToggleGisEdit={handleToggleGisEdit}
isGisEditActive={gisEdit.isEditMode}
isInfoSearchActive={isSearchModalOpen}
/>
{isGisEditSessionInlineVisible ? (
<div
data-testid="gis-edit-session-inline"
className="absolute left-[258px] top-[16px] z-[61] w-[265px]"
>
<GisEditSessionPanel
{...gisEditSessionSnapshot}
className="max-h-[calc(50dvh-82px)] !border-0 !shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
variant="inline"
onOpenPopup={handleOpenGisEditSessionPopup}
onRemoveFocusedChange={gisEdit.removeFocusedChange}
onRemoveLastChange={gisEdit.removeLastChange}
onClearChanges={gisEdit.clearChanges}
onFocusChange={handleFocusGisEditSessionChange}
/>
</div>
) : null}
<GisEditToolbar
isEditMode={gisEdit.isEditMode}
activeTool={gisEdit.activeTool}
isSnapEnabled={gisEdit.isSnapEnabled}
canUseTools={gisEdit.canUseTools}
canSave={gisEdit.canSave}
isSaving={isGisEditSaving}
statusMessage={gisEdit.statusMessage}
allowedTools={gisEdit.selectedLayer?.allowedTools ?? []}
onClose={handleCloseGisEdit}
onToggleSnap={gisEdit.toggleSnapEnabled}
onSelectTool={handleSelectGisEditTool}
onSave={handleSaveGisEdit}
/>
<Gis
height="h-full"
treeData={treeData}
checkedLayerRequest={checkedLayerRequest}
isSearch={true}
gisMaps={gisMaps}
isMiniMap={false}
searchView={SearchModal}
detailView={GisEditAwareDetailView}
canSelectFeature={canSelectFeatureDuringGisEdit}
onFeatureSelect={onFeatureSelect}
onMapBlankClick={handleSinkholeMapBlankClick}
sinkholeLegendIconVisible={true}
featureData={featureData}
setFeatureData={setFeatureData}
isOpen={isOpen}
setIsOpen={setIsOpen}
isSearchModalOpen={isSearchModalOpen}
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={handleCheckedLayersChange}
onOpenPopup={handleOpenLayerPopup}
searchBarContainerClassName="!top-[16px] ml-[12px]"
alwaysVisibleMapTabs={true}
searchBar={
<ProcSearchBar
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}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying as any}
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
checkInterval={2500}
checkedLayerChangeDelay={0}
showZoomLevel={true}
toolBarIconSize={20}
showZoomScaleInfo={true}
showFloatingSearchButton={false}
treeTogglePosition="center"
zoomGuide={zoomGuide}
layerIconVisible={(node: any) =>
isAgedPipeLayerNode(node) ||
isSinkholeRootNode(node) ||
isSbmrgDngrMapRootNode(node) ||
isFloodDngrMapRootNode(node) ||
isFldngHstryRootNode(node)
}
layerLegendIconVisible={(node: any) => !isFldngHstryRootNode(node)}
layerIconRequiresChecked={false}
defaultZoom={7.5}
defaultLonLat={[129, 36.3]}
viewExtent={SOUTH_KOREA_VIEW_EXTENT}
/>
{isAgedPipeListMinimized && (
<div className="fixed left-[480px] top-[68px] z-[90] flex min-w-[250px] items-center gap-3 rounded-sm border border-red-200 bg-background px-3 py-2 shadow-[0_8px_22px_rgba(15,23,42,0.22)] backdrop-blur">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-red-50">
<span className="block h-4 w-4 rounded-sm border-2 border-red-500 bg-red-500/20" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[1.25rem] font-semibold leading-tight text-red-700">
노후관로 현황
</div>
<div className="text-[1rem] leading-tight text-muted-foreground">
{appliedAgedPipeFilters.elapsedYears}년 이상 ·{" "}
{agedPipeTotalCount.toLocaleString()}건
</div>
</div>
<Button
type="button"
color="primary"
size="xs"
className="h-7 px-3"
onClick={openAgedPipeList}
>
열기
</Button>
<Button
type="button"
color="white"
size="xs"
className="h-7 px-3"
onClick={() => setIsAgedPipeListMinimized(false)}
>
닫기
</Button>
</div>
)}
<style jsx global>{`
.sn-map-control-layout .mapbtn {
top: 16px;
right: 120px;
bottom: auto;
}
.sn-map-control-layout .map-type-tabs,
.sn-map-control-layout .tool-bar ul,
.sn-map-control-layout .zoom-level-panel,
.sn-map-control-layout .zoom-level-summary {
border: 0;
box-shadow:
0 1px 2px rgba(60, 64, 67, 0.3),
0 1px 3px 1px rgba(60, 64, 67, 0.15);
}
.sn-map-control-layout .custom-toolbar {
position: static;
}
.sn-map-control-layout .tool-bar {
top: 16px;
right: 16px;
bottom: auto;
}
.sn-map-control-layout .tool-bar-vector-icons ul li div {
display: flex;
align-items: center;
justify-content: center;
background-image: none !important;
color: #666;
line-height: 1;
}
.sn-map-control-layout .tool-bar-vector-icons ul li div:hover {
color: #fff;
}
.sn-map-control-layout .zoom-level-control {
right: 16px;
bottom: 16px;
}
.sn-map-control-layout .zoom-level-summary {
right: 56px;
bottom: 0;
}
.sn-map-control-layout .ol-scale-line {
display: none;
}
`}</style>
</div>
</>
);
};
export default Map;