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

9146 lines
297 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 {
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 LineString from "ol/geom/LineString.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,
matchesAgedPipeFilters,
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 ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel";
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 {
FLOOD_HISTORY_AREA_LAYER_ID,
FLOOD_HISTORY_DEPTH_LAYER_ID,
FLOOD_HISTORY_PREVIEW_LAYER_TYPE,
FLOOD_HISTORY_LAYER_TYPE,
FLOOD_HISTORY_LAYER_NAME,
FLOOD_HISTORY_POPUP_KEY,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
isFldngHstryNode,
type FldngHstryItem,
} from "./components/fldngHstry/fldngHstryTypes";
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";
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_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction";
const FLOW_DIRECTION_LAYER_NAME = "유하방향";
const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const FLOW_DIRECTION_SRS = "EPSG:5179";
const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13;
const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)";
const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)";
const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35;
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 FLOOD_HISTORY_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";
type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
type RightPanelTabCategory =
| "sinkhole"
| "pipe"
| "exmnSurvey"
| "treatmentFacility"
| "treatmentZone"
| "defaultFeature"
| "gisEdit";
type RightPanelTab = {
id: string;
kind: RightPanelTabKind;
category: RightPanelTabCategory;
displayLabel: string;
surveyJobType?: ExmnMapJobType;
parentTabId?: string | null;
featureData: any;
sinkholeDetail?: SinkholeDetail | null;
nearbyPipes?: SinkholeNearbyPipe[];
gisEditChangeId?: string | null;
lastActiveAt: number;
};
type RightPanelTabStyle = {
active: string;
inactive: string;
};
type RightPanelBookmarkTabsProps = {
tabs: RightPanelTab[];
activeTabId: string | null;
onActivate: (tabId: string) => void;
onBack: (tabId: string) => void;
};
// 지도 객체 유형과 조사구분을 식별하는 범례 의미색이므로 테마와 무관한 정적 class를 유지한다.
const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record<
RightPanelTabCategory,
RightPanelTabStyle
> = {
sinkhole: {
active: "border-[#d97706] bg-[#d97706] text-white",
inactive: "border-[#fdba74] bg-[#fff7ed] text-[#9a3412] hover:bg-[#ffedd5]",
},
pipe: {
active: "border-[#2563eb] bg-[#2563eb] text-white",
inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]",
},
exmnSurvey: {
active: "border-[#0f766e] bg-[#0f766e] text-white",
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
},
treatmentFacility: {
active: "border-[#15803d] bg-[#15803d] text-white",
inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]",
},
treatmentZone: {
active: "border-[#7e22ce] bg-[#7e22ce] text-white",
inactive: "border-[#d8b4fe] bg-[#faf5ff] text-[#6b21a8] hover:bg-[#f3e8ff]",
},
gisEdit: {
active: "border-[#0f766e] bg-[#0f766e] text-white",
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
},
defaultFeature: {
active: "border-[#475569] bg-[#475569] text-white",
inactive: "border-[#cbd5e1] bg-[#f8fafc] text-[#334155] hover:bg-[#f1f5f9]",
},
};
const RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE: Record<
ExmnMapJobType,
RightPanelTabStyle
> = {
PPL: {
active: "border-[#eab308] bg-[#facc15] text-[#713f12]",
inactive: "border-[#fde68a] bg-[#fefce8] text-[#854d0e] hover:bg-[#fef9c3]",
},
MNHL: {
active: "border-[#8b5cf6] bg-[#8b5cf6] text-white",
inactive: "border-[#c4b5fd] bg-[#f5f3ff] text-[#5b21b6] hover:bg-[#ede9fe]",
},
};
// 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다.
const findRightPanelTabOverflowCandidate = (
tabs: RightPanelTab[],
nextTabId: string,
) => {
return tabs.find((tab) => tab.id !== nextTabId) ?? null;
};
// 부모 지반침하가 제거되면 그 부모에서 열린 연계 관로도 같은 탭 그룹으로 제거한다.
const removeRightPanelTabGroup = (
tabs: RightPanelTab[],
removedTabId: string,
) =>
tabs.filter(
(tab) => tab.id !== removedTabId && tab.parentTabId !== removedTabId,
);
// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다.
const RightPanelBookmarkTabs = ({
tabs,
activeTabId,
onActivate,
onBack,
}: RightPanelBookmarkTabsProps) => {
if (tabs.length < 2) return null;
return (
<div className="pointer-events-auto absolute right-[350px] top-[92px] z-[70] flex w-[112px] flex-col items-end gap-2">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const style =
tab.category === "exmnSurvey" && tab.surveyJobType
? RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE[tab.surveyJobType]
: RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category];
const parentTab = tab.parentTabId
? tabs.find((candidate) => candidate.id === tab.parentTabId)
: null;
return (
<div
key={tab.id}
className={[
"flex min-h-[42px] w-[112px] items-center border border-r-0 px-1 py-1 shadow-sm transition-colors",
isActive ? style.active : style.inactive,
].join(" ")}
>
{tab.category === "pipe" && parentTab ? (
<button
type="button"
className="mr-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-sm font-bold transition-colors hover:bg-accent focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
title={parentTab.displayLabel + "로 돌아가기"}
aria-label={parentTab.displayLabel + "로 돌아가기"}
onClick={() => onBack(parentTab.id)}
>
←
</button>
) : null}
<button
type="button"
className="inline-flex min-w-0 flex-1 items-center justify-center self-stretch px-1 text-center text-[0.82rem] font-semibold leading-tight"
title={tab.displayLabel}
aria-pressed={isActive}
onClick={() => onActivate(tab.id)}
>
<span className="block max-h-[2.5rem] overflow-hidden break-all">
{tab.displayLabel}
</span>
</button>
</div>
);
})}
</div>
);
};
type FlowDirectionUsageStyle = {
color: string;
lineColor: string;
};
type FlowDirectionMode = "PIPE_START_END" | "PIPE_DEPTH";
// /map은 관로 깊이 기준, /map2는 관로 원천 geometry 시점->종점 기준만 유지한다. 두 화면 모두 BBOX 조회만 사용한다.
const getFlowDirectionModeByPathname = (
pathname: string | null,
): FlowDirectionMode => {
const normalizedPathname = String(pathname ?? "").replace(/\/$/, "");
if (normalizedPathname.endsWith("/map2")) return "PIPE_START_END";
return "PIPE_DEPTH";
};
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,
];
const SWAGE_PRCS_FCLT_LAYER_TYPE = "tb_il_swage_prcs_fclt";
const RAIN_OBSERVATION_LAYER_TYPE = "tb_ms_rain_obsvtr_pstn_info";
type SwagePrcsFcltLayerMode = "all" | "gte500" | "lt500" | "none";
// 레이어 설정 응답이 lyrTyNm/lyrTypeNm/lyr_type_nm처럼 혼재되어 내려오는 경우를 단일 기준으로 정규화한다.
const getLayerTypeName = (layerInfo: any) =>
String(
layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "",
).trim();
const getLayerFilterSql = (layerInfo: any) =>
String(
layerInfo?.filtrSql ??
layerInfo?.filtr_sql ??
layerInfo?.flterSql ??
layerInfo?.filterSql ??
layerInfo?.filter_sql ??
"",
)
.replace(/\s+/g, "")
.toLowerCase();
const getSwagePrcsFcltCapacityKind = (
layerInfo: any,
): Exclude<SwagePrcsFcltLayerMode, "all" | "none"> | null => {
if (getLayerTypeName(layerInfo) !== SWAGE_PRCS_FCLT_LAYER_TYPE) return null;
const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").replace(
/\s+/g,
"",
);
const filterSql = getLayerFilterSql(layerInfo);
if (
filterSql.includes("fclt_cpct>=500") ||
layerName.includes("500이상") ||
layerName.includes("500톤이상")
)
return "gte500";
if (
filterSql.includes("fclt_cpct<500") ||
layerName.includes("500미만") ||
layerName.includes("500톤미만")
)
return "lt500";
return null;
};
const getSwagePrcsFcltLayerMode = (
checkedNodesData: any[],
): SwagePrcsFcltLayerMode => {
const checkedKinds = new Set(
checkedNodesData
.map((node) => getSwagePrcsFcltCapacityKind(node?.data ?? node))
.filter(Boolean),
);
const hasGte500 = checkedKinds.has("gte500");
const hasLt500 = checkedKinds.has("lt500");
if (hasGte500 && hasLt500) return "all";
if (hasGte500) return "gte500";
if (hasLt500) return "lt500";
return "none";
};
// 유하방향 노드는 타입명 또는 표시명으로 식별한다. DB DML 반영 전후 응답 차이를 모두 흡수하기 위한 판별 함수이다.
const isFlowDirectionLayerInfo = (layerInfo: any) => {
const layerTypeName = getLayerTypeName(layerInfo);
const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim();
return (
layerTypeName === FLOW_DIRECTION_LAYER_TYPE ||
layerName === FLOW_DIRECTION_LAYER_NAME
);
};
// 유하방향 노드를 일반 레이어 토글 대상에서 제외하고, 앱 전용 API vector layer로 처리하도록 속성을 보정한다.
const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => {
if (!Array.isArray(data)) return data;
return data.map((item) => {
const children = Array.isArray(item.children)
? normalizeFlowDirectionLayerInfos(item.children)
: item.children;
if (!isFlowDirectionLayerInfo(item)) {
return { ...item, children };
}
return {
...item,
children,
lyrTyNm:
item.lyrTyNm ||
item.lyrTypeNm ||
item.lyr_type_nm ||
FLOW_DIRECTION_LAYER_TYPE,
lyrNm: item.lyrNm || FLOW_DIRECTION_LAYER_NAME,
gobjTy: item.gobjTy || "line",
srvcNm: "api",
cmncNm: "bbox",
actlLyrYn: "N",
clickPsblYn: "N",
initEprssYn: "N",
minViewSize:
item.minViewSize ??
item.minZoom ??
String(FLOW_DIRECTION_DEFAULT_MIN_ZOOM),
};
});
};
// 강우관측소는 위치와 라벨만 제공하며, 과거 DB 설정값이 Y여도 일반 속성 패널 선택 대상에서는 제외한다.
const normalizeRainObservationLayerInteraction = (data: any[]): any[] => {
if (!Array.isArray(data)) return data;
return data.map((item) => {
const children = Array.isArray(item.children)
? normalizeRainObservationLayerInteraction(item.children)
: item.children;
return getLayerTypeName(item) === RAIN_OBSERVATION_LAYER_TYPE
? { ...item, children, clickPsblYn: "N" }
: { ...item, children };
});
};
// 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다.
const getFiniteNumber = (value: unknown) => {
if (value === null || value === undefined || String(value).trim() === "")
return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const getFlowDirectionMinZoom = (layerInfo: any) => {
return (
getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom) ??
FLOW_DIRECTION_DEFAULT_MIN_ZOOM
);
};
// 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다.
const getFlowDirectionZIndex = (layerInfo: any) => {
return getFiniteNumber(layerInfo?.drawOrdr) ?? 9998;
};
// 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";
// 현재 지도 화면 BBOX 주변을 함께 조회해 이동 직후 가장자리 유하방향이 비어 보이는 현상을 줄인다.
const expandFlowDirectionExtent = (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 * FLOW_DIRECTION_BBOX_BUFFER_RATIO;
const bufferY = height * FLOW_DIRECTION_BBOX_BUFFER_RATIO;
return [minX - bufferX, minY - bufferY, maxX + bufferX, maxY + bufferY];
};
// API 요청과 디버깅 출력에 같은 BBOX 문자열 포맷을 사용해 FE 계산값과 네트워크 파라미터를 쉽게 대조한다.
const formatFlowDirectionBbox = (extent: number[]) =>
extent.map((value: number) => value.toFixed(3)).join(",");
// 유하방향 캐시 중복 제거 키이다. BE 응답은 같은 관로 id가 여러 geometry로 반복될 수 있어 geometry까지 포함한다.
const getFlowDirectionFeatureCacheKey = (feature: any) => {
const featureId = feature.getId?.();
const fcltMngNo = feature.get("fcltMngNo") ?? feature.get("fclt_mng_no");
const usageCode = feature.get("usgCd") ?? feature.get("usg_cd") ?? "";
const coordinates = feature.getGeometry?.()?.getCoordinates?.();
const geometryKey = JSON.stringify(coordinates);
if (featureId) return `id:${String(featureId)}:${geometryKey}`;
if (fcltMngNo) return `fclt:${String(fcltMngNo).trim()}:${geometryKey}`;
return `geom:${String(usageCode).trim()}:${geometryKey}`;
};
const getGeometryExtentCenterPoint = (geometry: any) => {
const extent = geometry?.getExtent?.();
if (!extent || extent.length < 4) return null;
const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;
return new Point([(minX + maxX) / 2, (minY + maxY) / 2]);
};
// MultiPolygon/MultiLineString preview 라벨이 각 조각마다 반복되지 않도록 대표 지점 1곳에만 텍스트를 배치한다.
const getGisEditPreviewLabelGeometry = (geometry: any) => {
const geometryType = geometry?.getType?.();
if (!geometryType) return null;
if (geometryType === "Point") {
return geometry;
}
if (geometryType === "Polygon") {
return (
geometry.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(geometry)
);
}
if (geometryType === "MultiPolygon") {
const polygons = geometry.getPolygons?.() ?? [];
if (polygons.length > 0) {
const representativePolygon = polygons.reduce(
(largest: any, current: any) =>
(current?.getArea?.() ?? 0) > (largest?.getArea?.() ?? 0)
? current
: largest,
polygons[0],
);
return (
representativePolygon?.getInteriorPoint?.() ??
getGeometryExtentCenterPoint(representativePolygon)
);
}
return getGeometryExtentCenterPoint(geometry);
}
return getGeometryExtentCenterPoint(geometry);
};
const createGisEditPreviewStyle = (feature: any) => {
const editType = String(feature.get("editType") ?? "");
const geometry = feature.getGeometry?.();
const geometryType = geometry?.getType?.();
const previewLabel = String(feature.get("previewLabel") ?? "").trim();
const isInteractionTarget = Boolean(feature.get("isInteractionTarget"));
const isDragging = Boolean(feature.get("isDragging"));
const layerPointColor = String(feature.get("layerPointColor") ?? "").trim();
const layerLineColor = String(feature.get("layerLineColor") ?? "").trim();
const layerFillColor = String(feature.get("layerFillColor") ?? "").trim();
const pointColor =
editType === "DELETE"
? "rgba(220, 38, 38, 0.85)"
: editType === "VERTEX_EDIT"
? "rgba(245, 158, 11, 0.92)"
: editType === "MOVE"
? layerPointColor || "rgba(37, 99, 235, 0.92)"
: editType === "ADD"
? "rgba(15, 23, 42, 0.96)"
: layerPointColor || "rgba(22, 163, 74, 0.92)";
const lineColor =
editType === "DELETE"
? "rgba(220, 38, 38, 0.9)"
: editType === "VERTEX_EDIT"
? "rgba(245, 158, 11, 0.95)"
: editType === "MOVE"
? layerLineColor || "rgba(37, 99, 235, 0.95)"
: editType === "ADD"
? "rgba(15, 23, 42, 0.96)"
: layerLineColor || "rgba(22, 163, 74, 0.95)";
const fillColor =
editType === "DELETE"
? "rgba(220, 38, 38, 0.18)"
: editType === "VERTEX_EDIT"
? "rgba(245, 158, 11, 0.2)"
: editType === "MOVE"
? layerFillColor || "rgba(37, 99, 235, 0.18)"
: editType === "ADD"
? "rgba(15, 23, 42, 0.2)"
: layerFillColor || "rgba(22, 163, 74, 0.18)";
const highlightColor = isDragging
? "rgba(14, 116, 144, 0.98)"
: isInteractionTarget
? "rgba(15, 23, 42, 0.9)"
: lineColor;
const emphasisWidth = isDragging
? 7
: isInteractionTarget
? 6
: editType === "ADD"
? 6
: 4;
const textStyle = previewLabel
? new Text({
text: previewLabel,
font: "600 12px sans-serif",
fill: new Fill({ color: "#ffffff" }),
backgroundFill: new Fill({ color: highlightColor }),
padding: [3, 6, 3, 6],
offsetY: geometryType === "Point" ? -18 : -12,
overflow: true,
})
: undefined;
const labelGeometry = previewLabel
? getGisEditPreviewLabelGeometry(geometry)
: null;
if (geometryType === "Point") {
return new Style({
image: new CircleStyle({
radius: isInteractionTarget ? 10 : 8,
fill: new Fill({ color: pointColor }),
stroke: new Stroke({
color: isDragging ? "rgba(255,255,255,1)" : "rgba(255,255,255,0.95)",
width: isInteractionTarget ? 3 : 2,
}),
}),
text: textStyle,
zIndex: 10001,
});
}
if (geometryType === "Polygon") {
const polygonStyles: Style[] = [
new Style({
fill: new Fill({
color:
isInteractionTarget && editType !== "DELETE"
? fillColor.replace(/0\.\d+\)$/, "0.28)")
: fillColor,
}),
stroke: new Stroke({
color: highlightColor,
width: emphasisWidth,
lineDash: editType === "DELETE" ? [8, 6] : undefined,
}),
zIndex: 10001,
}),
];
if (textStyle && labelGeometry) {
polygonStyles.push(
new Style({
geometry: labelGeometry,
text: textStyle,
zIndex: 10002,
}),
);
}
return polygonStyles;
}
const lineStyles: Style[] =
editType === "ADD"
? [
new Style({
stroke: new Stroke({
color: "rgba(255, 255, 255, 0.98)",
width: emphasisWidth + 4,
}),
zIndex: 10000,
}),
new Style({
stroke: new Stroke({
color: highlightColor,
width: emphasisWidth,
}),
zIndex: 10003,
}),
]
: [
new Style({
stroke: new Stroke({
color: highlightColor,
width: emphasisWidth,
lineDash: editType === "DELETE" ? [10, 6] : undefined,
}),
zIndex: 10001,
}),
];
if (textStyle && labelGeometry) {
lineStyles.push(
new Style({
geometry: labelGeometry,
text: textStyle,
zIndex: 10002,
}),
);
}
return lineStyles;
};
const GIS_EDIT_VERTEX_HANDLE_STYLE = new Style({
image: new CircleStyle({
radius: 10,
fill: new Fill({ color: "rgba(245, 158, 11, 0.98)" }),
stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }),
}),
zIndex: 10005,
});
const GIS_EDIT_VERTEX_MIDPOINT_STYLE = new Style({
image: new CircleStyle({
radius: 6,
fill: new Fill({ color: "rgba(255, 255, 255, 0.98)" }),
stroke: new Stroke({ color: "rgba(245, 158, 11, 0.98)", width: 2.5 }),
}),
zIndex: 10004,
});
const GIS_EDIT_VERTEX_DRAGGING_STYLE = new Style({
image: new CircleStyle({
radius: 11,
fill: new Fill({ color: "rgba(59, 130, 246, 0.95)" }),
stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }),
}),
zIndex: 10006,
});
const GIS_EDIT_VERTEX_SELECTED_STYLE = new Style({
image: new CircleStyle({
radius: 11,
fill: new Fill({ color: "rgba(217, 119, 6, 0.98)" }),
stroke: new Stroke({ color: "rgba(15, 23, 42, 0.95)", width: 3 }),
}),
zIndex: 10007,
});
const getGisEditVertexHandleStyle = (
kind: VertexHandleMeta["kind"],
isSelected = false,
) => {
if (isSelected && kind === "vertex") {
return GIS_EDIT_VERTEX_SELECTED_STYLE;
}
return kind === "midpoint"
? GIS_EDIT_VERTEX_MIDPOINT_STYLE
: GIS_EDIT_VERTEX_HANDLE_STYLE;
};
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 FLOOD_HISTORY_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,
}),
];
type VertexHandleMeta = {
kind: "vertex" | "midpoint";
geometryType: string;
partIndex?: number;
polygonIndex?: number;
ringIndex?: number;
coordIndex: number;
};
type VertexHandleCandidate = {
coordinate: number[];
meta: VertexHandleMeta;
};
type VertexDeleteResult = {
ok: boolean;
message?: string;
};
const cloneCoordinates = <T,>(value: T): T =>
JSON.parse(JSON.stringify(value)) as T;
const getMidpoint = (start: number[], end: number[]) => [
(Number(start?.[0] ?? 0) + Number(end?.[0] ?? 0)) / 2,
(Number(start?.[1] ?? 0) + Number(end?.[1] ?? 0)) / 2,
];
const buildVertexCoordinateKey = (coordinate: number[]) =>
`${Number(coordinate?.[0] ?? 0)}:${Number(coordinate?.[1] ?? 0)}`;
const buildVertexHandleMetaKey = (meta: VertexHandleMeta) =>
[
meta.kind,
meta.geometryType,
meta.partIndex ?? -1,
meta.polygonIndex ?? -1,
meta.ringIndex ?? -1,
meta.coordIndex,
].join(":");
const buildVertexHandleSelectionKey = (metaList: VertexHandleMeta[]) =>
metaList.map(buildVertexHandleMetaKey).sort().join("|");
const getSquaredPixelDistance = (
map: any,
startCoordinate: number[],
endCoordinate: number[],
) => {
const [startX = 0, startY = 0] =
map?.getPixelFromCoordinate?.(startCoordinate) ?? [];
const [endX = 0, endY = 0] =
map?.getPixelFromCoordinate?.(endCoordinate) ?? [];
return (endX - startX) ** 2 + (endY - startY) ** 2;
};
const GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD = 12;
const getAverageCoordinate = (coordinates: number[][]) => {
if (coordinates.length === 0) return [0, 0];
const [sumX, sumY] = coordinates.reduce<[number, number]>(
([accX, accY], [x = 0, y = 0]) => [accX + Number(x), accY + Number(y)],
[0, 0],
);
return [sumX / coordinates.length, sumY / coordinates.length];
};
const getPixelDistanceToSegment = (
map: any,
coordinate: number[],
startCoordinate: number[],
endCoordinate: number[],
) => {
const [pointX = 0, pointY = 0] =
map?.getPixelFromCoordinate?.(coordinate) ?? [];
const [startX = 0, startY = 0] =
map?.getPixelFromCoordinate?.(startCoordinate) ?? [];
const [endX = 0, endY = 0] =
map?.getPixelFromCoordinate?.(endCoordinate) ?? [];
const deltaX = endX - startX;
const deltaY = endY - startY;
const segmentLengthSquared = deltaX ** 2 + deltaY ** 2;
if (segmentLengthSquared === 0) {
return Math.hypot(pointX - startX, pointY - startY);
}
const ratio = Math.max(
0,
Math.min(
1,
((pointX - startX) * deltaX + (pointY - startY) * deltaY) /
segmentLengthSquared,
),
);
const projectedX = startX + deltaX * ratio;
const projectedY = startY + deltaY * ratio;
return Math.hypot(pointX - projectedX, pointY - projectedY);
};
const createVertexHandleFeature = (
coordinate: number[],
meta: VertexHandleMeta,
metaList: VertexHandleMeta[] = [meta],
) => {
const handleFeature = new Feature({
geometry: new Point(coordinate),
});
handleFeature.set("handleKind", meta.kind);
handleFeature.set("handleMeta", meta);
handleFeature.set("handleMetaList", metaList);
handleFeature.set(
"handleSelectionKey",
buildVertexHandleSelectionKey(metaList),
);
handleFeature.setStyle(getGisEditVertexHandleStyle(meta.kind));
return handleFeature;
};
const collectLineVertexHandleCandidates = (
coordinates: number[][],
geometryType: string,
partIndex?: number,
) => {
const candidates: VertexHandleCandidate[] = [];
coordinates.forEach((coordinate, coordIndex) => {
candidates.push({
coordinate,
meta: {
kind: "vertex",
geometryType,
partIndex,
coordIndex,
},
});
});
return candidates;
};
const collectPolygonRingVertexHandleCandidates = (
coordinates: number[][],
geometryType: string,
polygonIndex?: number,
ringIndex?: number,
) => {
const candidates: VertexHandleCandidate[] = [];
const logicalVertexCount = Math.max(0, coordinates.length - 1);
for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) {
const coordinate = coordinates[coordIndex];
const nextCoordinate =
coordinates[(coordIndex + 1) % logicalVertexCount] ?? coordinate;
if (!coordinate || !nextCoordinate) continue;
candidates.push({
coordinate,
meta: {
kind: "vertex",
geometryType,
polygonIndex,
ringIndex,
coordIndex,
},
});
}
return candidates;
};
const collectVertexHandleCandidates = (geometry: any) => {
const geometryType = geometry?.getType?.();
if (!geometryType) return [];
if (geometryType === "LineString") {
return collectLineVertexHandleCandidates(
geometry.getCoordinates?.() ?? [],
geometryType,
);
}
if (geometryType === "MultiLineString") {
const parts = geometry.getCoordinates?.() ?? [];
return parts.flatMap((part: number[][], partIndex: number) =>
collectLineVertexHandleCandidates(part, geometryType, partIndex),
);
}
if (geometryType === "Polygon") {
const rings = geometry.getCoordinates?.() ?? [];
return rings.flatMap((ring: number[][], ringIndex: number) =>
collectPolygonRingVertexHandleCandidates(
ring,
geometryType,
undefined,
ringIndex,
),
);
}
if (geometryType === "MultiPolygon") {
const polygons = geometry.getCoordinates?.() ?? [];
return polygons.flatMap((polygon: number[][][], polygonIndex: number) =>
polygon.flatMap((ring: number[][], ringIndex: number) =>
collectPolygonRingVertexHandleCandidates(
ring,
geometryType,
polygonIndex,
ringIndex,
),
),
);
}
return [];
};
const buildVertexHandleFeatures = (geometry: any, map?: any): Feature[] => {
const groupedCandidates: Array<{
coordinate: number[];
metaList: VertexHandleMeta[];
sourceCoordinates: number[][];
}> = [];
collectVertexHandleCandidates(geometry).forEach(
(candidate: VertexHandleCandidate) => {
const existing = groupedCandidates.find((group) => {
if (!map) {
return (
buildVertexCoordinateKey(group.coordinate) ===
buildVertexCoordinateKey(candidate.coordinate)
);
}
return (
getSquaredPixelDistance(
map,
group.coordinate,
candidate.coordinate,
) <=
GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD ** 2
);
});
if (existing) {
existing.metaList.push(candidate.meta);
existing.sourceCoordinates.push(candidate.coordinate);
existing.coordinate = getAverageCoordinate(existing.sourceCoordinates);
return;
}
groupedCandidates.push({
coordinate: candidate.coordinate,
metaList: [candidate.meta],
sourceCoordinates: [candidate.coordinate],
});
},
);
return groupedCandidates.map(({ coordinate, metaList }) =>
createVertexHandleFeature(coordinate, metaList[0]!, metaList),
);
};
const collectLineMidpointCandidates = (
coordinates: number[][],
geometryType: string,
partIndex?: number,
) => {
const candidates: VertexHandleCandidate[] = [];
for (
let coordIndex = 0;
coordIndex < coordinates.length - 1;
coordIndex += 1
) {
const startCoordinate = coordinates[coordIndex];
const endCoordinate = coordinates[coordIndex + 1];
if (!startCoordinate || !endCoordinate) continue;
candidates.push({
coordinate: getMidpoint(startCoordinate, endCoordinate),
meta: {
kind: "midpoint",
geometryType,
partIndex,
coordIndex,
},
});
}
return candidates;
};
const collectPolygonRingMidpointCandidates = (
coordinates: number[][],
geometryType: string,
polygonIndex?: number,
ringIndex?: number,
) => {
const candidates: VertexHandleCandidate[] = [];
const logicalVertexCount = Math.max(0, coordinates.length - 1);
for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) {
const startCoordinate = coordinates[coordIndex];
const endCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount];
if (!startCoordinate || !endCoordinate) continue;
candidates.push({
coordinate: getMidpoint(startCoordinate, endCoordinate),
meta: {
kind: "midpoint",
geometryType,
polygonIndex,
ringIndex,
coordIndex,
},
});
}
return candidates;
};
const collectMidpointCandidates = (geometry: any) => {
const geometryType = geometry?.getType?.();
if (!geometryType) return [];
if (geometryType === "LineString") {
return collectLineMidpointCandidates(
geometry.getCoordinates?.() ?? [],
geometryType,
);
}
if (geometryType === "MultiLineString") {
const parts = geometry.getCoordinates?.() ?? [];
return parts.flatMap((part: number[][], partIndex: number) =>
collectLineMidpointCandidates(part, geometryType, partIndex),
);
}
if (geometryType === "Polygon") {
const rings = geometry.getCoordinates?.() ?? [];
return rings.flatMap((ring: number[][], ringIndex: number) =>
collectPolygonRingMidpointCandidates(
ring,
geometryType,
undefined,
ringIndex,
),
);
}
if (geometryType === "MultiPolygon") {
const polygons = geometry.getCoordinates?.() ?? [];
return polygons.flatMap((polygon: number[][][], polygonIndex: number) =>
polygon.flatMap((ring: number[][], ringIndex: number) =>
collectPolygonRingMidpointCandidates(
ring,
geometryType,
polygonIndex,
ringIndex,
),
),
);
}
return [];
};
const getNearestVertexPixelDistance = (
map: any,
geometry: any,
coordinate: number[],
): number => {
return buildVertexHandleFeatures(geometry, map).reduce(
(minimumDistance: number, feature: Feature) => {
const vertexCoordinate = (
feature.getGeometry?.() as any
)?.getCoordinates?.();
if (!Array.isArray(vertexCoordinate)) return minimumDistance;
return Math.min(
minimumDistance,
Math.sqrt(getSquaredPixelDistance(map, coordinate, vertexCoordinate)),
);
},
Number.POSITIVE_INFINITY,
);
};
const getSegmentCoordinatesFromMeta = (
geometry: any,
meta: VertexHandleMeta,
): [number[], number[]] | null => {
const geometryType = geometry?.getType?.();
if (!geometryType) return null;
if (geometryType === "LineString") {
const coordinates = geometry.getCoordinates?.() ?? [];
return [coordinates[meta.coordIndex], coordinates[meta.coordIndex + 1]];
}
if (geometryType === "MultiLineString") {
const parts = geometry.getCoordinates?.() ?? [];
const targetPart = parts[meta.partIndex ?? 0] ?? [];
return [targetPart[meta.coordIndex], targetPart[meta.coordIndex + 1]];
}
if (geometryType === "Polygon") {
const rings = geometry.getCoordinates?.() ?? [];
const targetRing = rings[meta.ringIndex ?? 0] ?? [];
const logicalVertexCount = Math.max(0, targetRing.length - 1);
if (logicalVertexCount === 0) return null;
return [
targetRing[meta.coordIndex],
targetRing[(meta.coordIndex + 1) % logicalVertexCount],
];
}
if (geometryType === "MultiPolygon") {
const polygons = geometry.getCoordinates?.() ?? [];
const targetPolygon = polygons[meta.polygonIndex ?? 0] ?? [];
const targetRing = targetPolygon[meta.ringIndex ?? 0] ?? [];
const logicalVertexCount = Math.max(0, targetRing.length - 1);
if (logicalVertexCount === 0) return null;
return [
targetRing[meta.coordIndex],
targetRing[(meta.coordIndex + 1) % logicalVertexCount],
];
}
return null;
};
const findHoveredMidpointCandidate = (
map: any,
geometry: any,
coordinate: number[],
tolerancePx = 18,
): VertexHandleCandidate | null => {
const nearestVertexDistance = getNearestVertexPixelDistance(
map,
geometry,
coordinate,
);
if (nearestVertexDistance <= 14) {
return null;
}
const midpointCandidates = collectMidpointCandidates(geometry);
let bestCandidate: VertexHandleCandidate | null = null;
let minimumDistance = Number.POSITIVE_INFINITY;
midpointCandidates.forEach((candidate: VertexHandleCandidate) => {
const segmentCoordinates = getSegmentCoordinatesFromMeta(
geometry,
candidate.meta,
);
if (!segmentCoordinates) return;
const [startCoordinate, endCoordinate] = segmentCoordinates;
if (!Array.isArray(startCoordinate) || !Array.isArray(endCoordinate))
return;
const segmentDistance = getPixelDistanceToSegment(
map,
coordinate,
startCoordinate,
endCoordinate,
);
if (segmentDistance < minimumDistance) {
minimumDistance = segmentDistance;
bestCandidate = candidate;
}
});
return minimumDistance <= tolerancePx ? bestCandidate : null;
};
const updateLineCoordinates = (
coordinates: number[][],
meta: VertexHandleMeta,
nextCoordinate: number[],
) => {
const nextCoordinates = cloneCoordinates(coordinates);
if (meta.kind === "vertex") {
nextCoordinates[meta.coordIndex] = nextCoordinate;
} else {
nextCoordinates.splice(meta.coordIndex + 1, 0, nextCoordinate);
}
return nextCoordinates;
};
const updatePolygonRingCoordinates = (
coordinates: number[][],
meta: VertexHandleMeta,
nextCoordinate: number[],
) => {
const nextCoordinates = cloneCoordinates(coordinates);
const logicalVertexCount = Math.max(0, nextCoordinates.length - 1);
if (logicalVertexCount === 0) return nextCoordinates;
if (meta.kind === "vertex") {
nextCoordinates[meta.coordIndex] = nextCoordinate;
if (meta.coordIndex === 0) {
nextCoordinates[logicalVertexCount] = nextCoordinate;
}
} else {
const insertIndex =
meta.coordIndex === logicalVertexCount - 1
? logicalVertexCount
: meta.coordIndex + 1;
nextCoordinates.splice(insertIndex, 0, nextCoordinate);
}
return nextCoordinates;
};
const applyVertexHandleMove = (
geometry: any,
meta: VertexHandleMeta,
nextCoordinate: number[],
) => {
const geometryType = geometry?.getType?.();
if (!geometryType) return;
if (geometryType === "LineString") {
geometry.setCoordinates(
updateLineCoordinates(
geometry.getCoordinates?.() ?? [],
meta,
nextCoordinate,
),
);
return;
}
if (geometryType === "MultiLineString") {
const parts = cloneCoordinates(geometry.getCoordinates?.() ?? []);
const targetPartIndex = meta.partIndex ?? 0;
parts[targetPartIndex] = updateLineCoordinates(
parts[targetPartIndex] ?? [],
meta,
nextCoordinate,
);
geometry.setCoordinates(parts);
return;
}
if (geometryType === "Polygon") {
const rings = cloneCoordinates(geometry.getCoordinates?.() ?? []);
const targetRingIndex = meta.ringIndex ?? 0;
rings[targetRingIndex] = updatePolygonRingCoordinates(
rings[targetRingIndex] ?? [],
meta,
nextCoordinate,
);
geometry.setCoordinates(rings);
return;
}
if (geometryType === "MultiPolygon") {
const polygons = cloneCoordinates(geometry.getCoordinates?.() ?? []);
const targetPolygonIndex = meta.polygonIndex ?? 0;
const targetRingIndex = meta.ringIndex ?? 0;
polygons[targetPolygonIndex][targetRingIndex] =
updatePolygonRingCoordinates(
polygons[targetPolygonIndex][targetRingIndex] ?? [],
meta,
nextCoordinate,
);
geometry.setCoordinates(polygons);
}
};
const deleteLineVertices = (
coordinates: number[][],
coordIndexes: number[],
) => {
const nextCoordinates = cloneCoordinates(coordinates);
const uniqueIndexes = Array.from(new Set(coordIndexes)).sort(
(left, right) => right - left,
);
if (nextCoordinates.length - uniqueIndexes.length < 2) {
return null;
}
uniqueIndexes.forEach((coordIndex) => {
nextCoordinates.splice(coordIndex, 1);
});
return nextCoordinates;
};
const deletePolygonVertices = (
coordinates: number[][],
coordIndexes: number[],
) => {
const logicalVertexCount = Math.max(0, coordinates.length - 1);
const uniqueIndexes = Array.from(new Set(coordIndexes)).sort(
(left, right) => right - left,
);
if (logicalVertexCount - uniqueIndexes.length < 3) {
return null;
}
const logicalCoordinates = cloneCoordinates(
coordinates.slice(0, logicalVertexCount),
);
uniqueIndexes.forEach((coordIndex) => {
logicalCoordinates.splice(coordIndex, 1);
});
if (logicalCoordinates.length < 3) return null;
return [...logicalCoordinates, cloneCoordinates(logicalCoordinates[0] ?? [])];
};
const deleteSelectedVertexHandles = (
geometry: any,
metaList: VertexHandleMeta[],
): VertexDeleteResult => {
if (!geometry || metaList.length === 0) {
return { ok: false, message: "삭제할 꼭지점 정보를 찾지 못했습니다." };
}
if (metaList.some((meta) => meta.kind !== "vertex")) {
return {
ok: false,
message:
"흰색 midpoint는 삭제 대상이 아닙니다. 주황색 실꼭지점을 선택하세요.",
};
}
const geometryType = geometry?.getType?.();
if (!geometryType) {
return {
ok: false,
message: "꼭지점 삭제 대상 geometry를 찾지 못했습니다.",
};
}
if (geometryType === "LineString") {
const nextCoordinates = deleteLineVertices(
geometry.getCoordinates?.() ?? [],
metaList.map((meta) => meta.coordIndex),
);
if (!nextCoordinates) {
return {
ok: false,
message:
"하수관로는 최소 2개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.",
};
}
geometry.setCoordinates(nextCoordinates);
return { ok: true };
}
if (geometryType === "MultiLineString") {
const nextParts = cloneCoordinates(geometry.getCoordinates?.() ?? []);
const metaGroups = metaList.reduce<Record<number, number[]>>(
(acc, meta) => {
const partIndex = meta.partIndex ?? 0;
if (!acc[partIndex]) acc[partIndex] = [];
acc[partIndex]!.push(meta.coordIndex);
return acc;
},
{},
);
for (const [partIndexRaw, coordIndexes] of Object.entries(metaGroups)) {
const partIndex = Number(partIndexRaw);
const nextCoordinates = deleteLineVertices(
nextParts[partIndex] ?? [],
coordIndexes,
);
if (!nextCoordinates) {
return {
ok: false,
message: "하수관로는 각 선형마다 최소 2개의 꼭지점이 필요합니다.",
};
}
nextParts[partIndex] = nextCoordinates;
}
geometry.setCoordinates(nextParts);
return { ok: true };
}
if (geometryType === "Polygon") {
const nextRings = cloneCoordinates(geometry.getCoordinates?.() ?? []);
const metaGroups = metaList.reduce<Record<number, number[]>>(
(acc, meta) => {
const ringIndex = meta.ringIndex ?? 0;
if (!acc[ringIndex]) acc[ringIndex] = [];
acc[ringIndex]!.push(meta.coordIndex);
return acc;
},
{},
);
for (const [ringIndexRaw, coordIndexes] of Object.entries(metaGroups)) {
const ringIndex = Number(ringIndexRaw);
const nextCoordinates = deletePolygonVertices(
nextRings[ringIndex] ?? [],
coordIndexes,
);
if (!nextCoordinates) {
return {
ok: false,
message:
"폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.",
};
}
nextRings[ringIndex] = nextCoordinates;
}
geometry.setCoordinates(nextRings);
return { ok: true };
}
if (geometryType === "MultiPolygon") {
const nextPolygons = cloneCoordinates(geometry.getCoordinates?.() ?? []);
const metaGroups = metaList.reduce<Record<string, number[]>>(
(acc, meta) => {
const polygonIndex = meta.polygonIndex ?? 0;
const ringIndex = meta.ringIndex ?? 0;
const groupKey = `${polygonIndex}:${ringIndex}`;
if (!acc[groupKey]) acc[groupKey] = [];
acc[groupKey]!.push(meta.coordIndex);
return acc;
},
{},
);
for (const [groupKey, coordIndexes] of Object.entries(metaGroups)) {
const [polygonIndexText, ringIndexText] = groupKey.split(":");
const polygonIndex = Number(polygonIndexText);
const ringIndex = Number(ringIndexText);
const nextCoordinates = deletePolygonVertices(
nextPolygons[polygonIndex]?.[ringIndex] ?? [],
coordIndexes,
);
if (!nextCoordinates) {
return {
ok: false,
message:
"폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.",
};
}
nextPolygons[polygonIndex][ringIndex] = nextCoordinates;
}
geometry.setCoordinates(nextPolygons);
return { ok: true };
}
return {
ok: false,
message: "현재 geometry 유형은 꼭지점 삭제를 지원하지 않습니다.",
};
};
const getClosestSnapCoordinateFromSource = (
source: VectorSource | null,
map: any,
coordinate: number[],
tolerancePx = 18,
) => {
if (!source || !Array.isArray(coordinate)) return null;
const nearestFeature = source.getClosestFeatureToCoordinate?.(coordinate);
const nearestGeometry = nearestFeature?.getGeometry?.();
if (!nearestGeometry?.getClosestPoint) return null;
const closestCoordinate = nearestGeometry.getClosestPoint(coordinate);
if (!Array.isArray(closestCoordinate)) return null;
const distance = Math.sqrt(
getSquaredPixelDistance(map, coordinate, closestCoordinate),
);
if (!Number.isFinite(distance) || distance > tolerancePx) return null;
return {
coordinate: closestCoordinate as number[],
distance,
};
};
const applyGeometryVertexSnaps = (
geometry: any,
source: VectorSource | null,
map: any,
tolerancePx = 18,
) => {
if (!geometry || !source || !map) return false;
const vertexCandidates = collectVertexHandleCandidates(geometry).filter(
(candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex",
);
let changed = false;
vertexCandidates.forEach((candidate: VertexHandleCandidate) => {
const snapResult = getClosestSnapCoordinateFromSource(
source,
map,
candidate.coordinate,
tolerancePx,
);
if (!snapResult) return;
const currentKey = buildVertexCoordinateKey(candidate.coordinate);
const nextKey = buildVertexCoordinateKey(snapResult.coordinate);
if (currentKey === nextKey) return;
applyVertexHandleMove(geometry, candidate.meta, snapResult.coordinate);
changed = true;
});
return changed;
};
const applyGeometryMoveSnap = (
geometry: any,
source: VectorSource | null,
map: any,
anchorCoordinate?: number[] | null,
tolerancePx = 18,
) => {
if (!geometry || !source || !map || typeof geometry.translate !== "function")
return false;
if (Array.isArray(anchorCoordinate)) {
const snapResult = getClosestSnapCoordinateFromSource(
source,
map,
anchorCoordinate,
tolerancePx,
);
if (snapResult) {
const deltaX =
Number(snapResult.coordinate[0] ?? 0) -
Number(anchorCoordinate[0] ?? 0);
const deltaY =
Number(snapResult.coordinate[1] ?? 0) -
Number(anchorCoordinate[1] ?? 0);
if (deltaX !== 0 || deltaY !== 0) {
geometry.translate(deltaX, deltaY);
return true;
}
}
}
const vertexCandidates = collectVertexHandleCandidates(geometry).filter(
(candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex",
);
type GeometryMoveSnapCandidate = {
original: number[];
snapped: number[];
distance: number;
};
let bestCandidate: GeometryMoveSnapCandidate | null = null;
vertexCandidates.forEach((candidate: VertexHandleCandidate) => {
const snapResult = getClosestSnapCoordinateFromSource(
source,
map,
candidate.coordinate,
tolerancePx,
);
if (!snapResult) return;
if (!bestCandidate || snapResult.distance < bestCandidate.distance) {
bestCandidate = {
original: candidate.coordinate,
snapped: snapResult.coordinate,
distance: snapResult.distance,
};
}
});
if (!bestCandidate) return false;
const resolvedBestCandidate = bestCandidate as GeometryMoveSnapCandidate;
const deltaX =
Number(resolvedBestCandidate.snapped[0] ?? 0) -
Number(resolvedBestCandidate.original[0] ?? 0);
const deltaY =
Number(resolvedBestCandidate.snapped[1] ?? 0) -
Number(resolvedBestCandidate.original[1] ?? 0);
if (deltaX === 0 && deltaY === 0) return false;
geometry.translate(deltaX, deltaY);
return true;
};
const getClosestGeometryVertexCoordinate = (
geometry: any,
map: any,
coordinate: number[] | null,
) => {
if (!geometry || !map || !Array.isArray(coordinate)) return null;
const vertexCandidates = collectVertexHandleCandidates(geometry).filter(
(candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex",
);
let bestCandidate: VertexHandleCandidate | null = null;
let minimumDistance = Number.POSITIVE_INFINITY;
vertexCandidates.forEach((candidate: VertexHandleCandidate) => {
const distance = getSquaredPixelDistance(
map,
coordinate,
candidate.coordinate,
);
if (distance < minimumDistance) {
minimumDistance = distance;
bestCandidate = candidate;
}
});
return (bestCandidate as VertexHandleCandidate | null)?.coordinate ?? null;
};
const getClosestGeometryVertexMeta = (
geometry: any,
map: any,
coordinate: number[] | null,
) => {
if (!geometry || !map || !Array.isArray(coordinate)) return null;
const vertexCandidates = collectVertexHandleCandidates(geometry).filter(
(candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex",
);
let bestCandidate: VertexHandleCandidate | null = null;
let minimumDistance = Number.POSITIVE_INFINITY;
vertexCandidates.forEach((candidate: VertexHandleCandidate) => {
const distance = getSquaredPixelDistance(
map,
coordinate,
candidate.coordinate,
);
if (distance < minimumDistance) {
minimumDistance = distance;
bestCandidate = candidate;
}
});
return (bestCandidate as VertexHandleCandidate | null)?.meta ?? null;
};
const getGeometryVertexCoordinateByMeta = (
geometry: any,
meta: VertexHandleMeta | null,
) => {
if (!geometry || !meta) return null;
const geometryType = geometry?.getType?.();
if (!geometryType) return null;
if (geometryType === "LineString") {
return geometry.getCoordinates?.()?.[meta.coordIndex] ?? null;
}
if (geometryType === "MultiLineString") {
return (
geometry.getCoordinates?.()?.[meta.partIndex ?? 0]?.[meta.coordIndex] ??
null
);
}
if (geometryType === "Polygon") {
return (
geometry.getCoordinates?.()?.[meta.ringIndex ?? 0]?.[meta.coordIndex] ??
null
);
}
if (geometryType === "MultiPolygon") {
return (
geometry.getCoordinates?.()?.[meta.polygonIndex ?? 0]?.[
meta.ringIndex ?? 0
]?.[meta.coordIndex] ?? null
);
}
return null;
};
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
);
};
// 관종별 원본 레이어 색을 유하방향 선/화살표에 재사용할 때 투명도만 조정한다.
const withAlpha = (color: string | null | undefined, alpha: number) => {
const resolvedColor = String(color ?? "").trim();
if (!resolvedColor) return `rgba(40, 90, 180, ${alpha})`;
const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i);
if (rgbaMatch) {
const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "")
.split(",")
.map((part) => part.trim());
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
const hex = hexMatch[1] ?? "";
const normalized =
hex.length === 3
? hex
.split("")
.map((char) => char + char)
.join("")
: hex;
const red = parseInt(normalized.slice(0, 2), 16);
const green = parseInt(normalized.slice(2, 4), 16);
const blue = parseInt(normalized.slice(4, 6), 16);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
return resolvedColor;
};
// 화살표 위치와 대표 선분 선택을 위해 LineString 좌표열의 대략적인 길이를 계산한다.
const estimateLineLength = (coordinates: number[][]) => {
let length = 0;
for (let i = 1; i < coordinates.length; i += 1) {
const previous = coordinates[i - 1];
const current = coordinates[i];
if (!previous || !current) continue;
const [x1 = 0, y1 = 0] = previous;
const [x2 = 0, y2 = 0] = current;
length += Math.hypot(x2 - x1, y2 - y1);
}
return length;
};
// MultiLineString 관로는 가장 긴 선분을 대표 선으로 삼아 화살표 위치와 각도를 안정적으로 계산한다.
const getRepresentativeLineCoordinates = (geometry: any): number[][] => {
const type = geometry?.getType?.();
const coordinates = geometry?.getCoordinates?.();
if (type === "LineString") return coordinates ?? [];
if (type === "MultiLineString" && Array.isArray(coordinates)) {
return coordinates.reduce(
(longest: number[][], current: number[][]) =>
estimateLineLength(current) > estimateLineLength(longest)
? current
: longest,
[],
);
}
return [];
};
// 관로 중앙 지점이 속한 실제 선분을 찾아 화살표 위치와 해당 선분의 방향각을 함께 계산한다.
const getArrowPlacementAtHalfLength = (coordinates: number[][]) => {
if (coordinates.length === 0) return null;
if (coordinates.length === 1) {
const coordinate = coordinates[0];
return coordinate ? { coordinate, angle: 0 } : null;
}
const totalLength = estimateLineLength(coordinates);
if (totalLength === 0) {
const coordinate = coordinates[Math.floor(coordinates.length / 2)];
return coordinate ? { coordinate, angle: 0 } : null;
}
let accumulated = 0;
const targetLength = totalLength / 2;
let lastSegmentAngle = 0;
for (let i = 1; i < coordinates.length; i += 1) {
const previous = coordinates[i - 1];
const current = coordinates[i];
if (!previous || !current) continue;
const [previousX = 0, previousY = 0] = previous;
const [currentX = 0, currentY = 0] = current;
const segmentLength = Math.hypot(
currentX - previousX,
currentY - previousY,
);
if (segmentLength > 0) {
lastSegmentAngle = Math.atan2(currentY - previousY, currentX - previousX);
}
if (accumulated + segmentLength >= targetLength) {
const ratio =
segmentLength === 0 ? 0 : (targetLength - accumulated) / segmentLength;
return {
coordinate: [
previousX + (currentX - previousX) * ratio,
previousY + (currentY - previousY) * ratio,
],
angle: lastSegmentAngle,
};
}
accumulated += segmentLength;
}
const coordinate = coordinates[coordinates.length - 1];
return coordinate ? { coordinate, angle: lastSegmentAngle } : null;
};
// SVG 없이 Canvas renderer로 짧은 꼬리선과 화살촉을 직접 그려 방향성을 분명하게 만든다.
const createFlowDirectionArrowRenderer = (
color: string,
angle: number,
scale: number,
) => {
return (coordinates: any, state: any) => {
const pixel = Array.isArray(coordinates?.[0])
? coordinates[0]
: coordinates;
const [x, y] = pixel ?? [];
const context = state?.context as CanvasRenderingContext2D | undefined;
if (!context || !Number.isFinite(x) || !Number.isFinite(y)) return;
const length = 17 * scale;
const tailLength = 9 * scale;
const headLength = 7 * scale;
const headHalfWidth = 4.5 * scale;
const shaftWidth = 2.4 * scale;
context.save();
context.translate(x, y);
const viewRotation = Number(state?.rotation ?? 0);
const screenAngle =
(Number.isFinite(viewRotation) ? viewRotation : 0) - angle;
context.rotate(screenAngle);
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
context.lineWidth = shaftWidth + 2.4;
context.beginPath();
context.moveTo(-tailLength, 0);
context.lineTo(length - headLength, 0);
context.stroke();
context.fillStyle = "rgba(255, 255, 255, 0.9)";
context.beginPath();
context.moveTo(length, 0);
context.lineTo(length - headLength, -headHalfWidth);
context.lineTo(length - headLength, headHalfWidth);
context.closePath();
context.fill();
context.strokeStyle = color;
context.lineWidth = shaftWidth;
context.beginPath();
context.moveTo(-tailLength, 0);
context.lineTo(length - headLength, 0);
context.stroke();
context.fillStyle = color;
context.beginPath();
context.moveTo(length, 0);
context.lineTo(length - headLength, -headHalfWidth);
context.lineTo(length - headLength, headHalfWidth);
context.closePath();
context.fill();
context.restore();
};
};
// 유하방향 신뢰도와 관종 색상에 따라 선과 화살표 스타일을 만든다. LOW는 운영 검수 대상이라 더 흐리게 표시한다.
const createFlowDirectionStyle = (
feature: any,
usageStyleMap: Record<string, FlowDirectionUsageStyle>,
) => {
const confidence = String(
feature.get("directionConfidence") ??
feature.get("direction_confidence") ??
"",
).toUpperCase();
const geometry = feature.getGeometry();
const coordinates = getRepresentativeLineCoordinates(geometry);
const arrowPlacement = getArrowPlacementAtHalfLength(coordinates);
const usageCode = String(
feature.get("usgCd") ?? feature.get("usg_cd") ?? "",
).trim();
const usageStyle = usageStyleMap[usageCode];
const baseColor = usageStyle?.color ?? FLOW_DIRECTION_DEFAULT_COLOR;
const isNetworkCorrected =
String(
feature.get("networkCorrectionYn") ??
feature.get("network_correction_yn") ??
"",
).toUpperCase() === "Y";
const styleByConfidence: Record<
string,
{
color: string;
lineColor: string;
scale: number;
width: number;
lineDash?: number[];
}
> = {
HIGH: {
color: baseColor,
lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42),
scale: 1,
width: 1.3,
},
MEDIUM: {
color: baseColor,
lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42),
scale: 1,
width: 1.3,
},
LOW: {
color: withAlpha(baseColor, 0.72),
lineColor: withAlpha(baseColor, 0.28),
scale: 0.9,
width: 1.1,
},
};
const style = isNetworkCorrected
? {
color: FLOW_DIRECTION_NETWORK_CORRECTION_COLOR,
lineColor: withAlpha(FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, 0.5),
scale: 1.08,
width: 2,
lineDash: [8, 4],
}
: (styleByConfidence[confidence] ?? styleByConfidence.LOW!);
const styles = [
new Style({
stroke: new Stroke({
color: style.lineColor,
width: style.width,
lineDash: style.lineDash,
lineCap: "round",
lineJoin: "round",
}),
}),
];
if (arrowPlacement) {
styles.push(
new Style({
geometry: new Point(arrowPlacement.coordinate),
renderer: createFlowDirectionArrowRenderer(
style.color,
arrowPlacement.angle,
style.scale,
),
}),
);
}
return styles;
};
// 레이어 설정의 직접 코드와 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 [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 [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false);
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);
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/grid-map?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);
}
return normalizeSinkholeFilters(
buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope),
sinkholeScope,
);
});
}, [isSinkholePopupFilterActive, 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) {
return waterRiskAreaSourceRef.current;
}
const layers = map.getLayers?.().getArray?.() ?? [];
layers.forEach((existingLayer: any) => {
if (existingLayer?.get?.("id") === GIS_EDIT_PREVIEW_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,
}));
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;
}
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", FLOOD_HISTORY_AREA_LAYER_ID);
layer.set("lyrNm", FLOOD_HISTORY_LAYER_NAME);
layer.set("lyrTyNm", FLOOD_HISTORY_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", FLOOD_HISTORY_DEPTH_LAYER_ID);
layer.set("lyrNm", FLOOD_HISTORY_LAYER_NAME);
layer.set("lyrTyNm", FLOOD_HISTORY_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: FLOOD_HISTORY_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(FLOOD_HISTORY_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);
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 ?? "")),
);
setIsAgedPipeChecked(agedPipeChecked);
const fldngHstryChecked = checkedNodesData.some((node) =>
isFldngHstryNode(node),
);
setIsFldngHstryChecked(fldngHstryChecked);
if (!fldngHstryChecked) {
clearFldngHstrySelection();
}
const sinkholeChecked = checkedNodesData.some((node) =>
isSinkholeLayerNode(node),
);
setIsSinkholeChecked(sinkholeChecked);
const exmnPplChecked = checkedNodesData.some((node) =>
isExmnResultPplNode(node),
);
const exmnMnhlChecked = checkedNodesData.some((node) =>
isExmnResultMnhlNode(node),
);
setIsExmnPplChecked(exmnPplChecked);
setIsExmnMnhlChecked(exmnMnhlChecked);
setActiveLayerSettingOrder((currentOrder) => {
const activeKeys = [
waterDepthChecked ? "waterDepthLegend" : 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);
setAppliedSinkholeFilters(
normalizeSinkholeFilters(
buildSinkholeFiltersFromYearRange(normalizedRange, sinkholeScope),
sinkholeScope,
),
);
setSinkholeSelectionVersion((version) => version + 1);
},
[sinkholeScope, 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 (isFldngHstryNode(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) {
nextItems.push({
key: "exmnPpl",
label: "관로조사",
order: resolveSettingOrder("exmnPpl", 9995),
content: (
<ExmnLayerSettingPanel
jobType="PPL"
filters={exmnPplFilters}
onFiltersChange={setExmnPplFilters}
/>
),
});
}
if (isExmnMnhlChecked) {
nextItems.push({
key: "exmnMnhl",
label: "맨홀조사",
order: resolveSettingOrder("exmnMnhl", 9996),
content: (
<ExmnLayerSettingPanel
jobType="MNHL"
filters={exmnMnhlFilters}
onFiltersChange={setExmnMnhlFilters}
/>
),
});
}
if (isSinkholeChecked) {
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"}
compact
/>
),
});
}
if (isAgedPipeChecked) {
nextItems.push({
key: "agedPipe",
label: "노후관로",
order: resolveSettingOrder("agedPipe", 9999),
content: (
<AgedPipeLayerSettingPanel
filters={draftAgedPipeFilters}
onFiltersChange={setDraftAgedPipeFilters}
onApply={handleApplyAgedPipeFilters}
compact
/>
),
});
}
if (isWaterDepthLayerActive) {
nextItems.push({
key: "waterDepthLegend",
label: "침수심 범례",
order: resolveSettingOrder("waterDepthLegend", 9997),
content: (
<div className="overflow-hidden rounded-sm border border-border bg-secondary">
<div className="px-3 py-2">
<div className="rounded-sm border border-border bg-background px-3 py-3">
<div className="mb-2 bg-primary py-1 text-center text-[1.1rem] font-semibold text-primary-foreground">
침수심 범례
</div>
<div className="space-y-1">
{waterDepthLegendItems.map((item) => (
<div key={item.key} className="flex items-center gap-2">
<span
className="inline-block h-4 w-10 shrink-0"
style={{
backgroundColor: item.color,
border: `1px solid ${item.borderColor}`,
}}
/>
<span className="text-[1rem] font-medium text-label">
{item.label}
</span>
</div>
))}
</div>
</div>
</div>
</div>
),
});
}
return nextItems
.sort((a, b) => a.order - b.order)
.map(({ key, label, content }) => ({ key, label, content }));
}, [
activeLayerSettingOrder,
applySinkholeRadius,
applySinkholeYearRange,
draftAgedPipeFilters,
exmnMnhlFilters,
exmnPplFilters,
handleApplyAgedPipeFilters,
isAgedPipeChecked,
isExmnMnhlChecked,
isExmnPplChecked,
isSinkholeChecked,
isWaterDepthLayerActive,
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="mt-3 flex h-[34vh] max-h-[32rem] min-h-[24rem] flex-col overflow-hidden rounded-sm border border-border bg-background shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<div className="border-b border-border bg-secondary px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[1.2rem] font-semibold text-label">
선택 레이어 설정
</span>
<span className="text-[1rem] text-muted-foreground">
{activeLayerSettingItems.length}건
</span>
</div>
<div className="mt-1 flex flex-wrap gap-1">
{activeLayerSettingItems.map((item) => (
<span
key={item.key}
className="rounded-full border border-border bg-background px-2 py-[1px] text-[1rem] text-label"
>
{item.label}
</span>
))}
</div>
</div>
<div
ref={restoreActiveLayerSettingScroll}
onScroll={handleActiveLayerSettingScroll}
className="min-h-0 flex-1 overflow-y-scroll px-1 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) 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, mapReady]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const core = GisApp.Module.core as any;
core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
if (!isFldngHstryChecked) return;
const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
(item: any) =>
item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y",
) as any;
if (!baseLayerCode) return;
const layerCode = {
...baseLayerCode,
id: FLDNG_HSTRY_WFS_LAYER_ID,
lyrSn: FLDNG_HSTRY_WFS_LAYER_ID,
lyrNm: FLOOD_HISTORY_LAYER_NAME,
lyrTyNm: FLOOD_HISTORY_LAYER_TYPE,
actlLyrYn: "Y",
exprsYn: "Y",
clickPsblYn: "Y",
cmncNm: "bbox",
srvcNm: "wfs",
};
const layer = core?.addWfsLayer?.(layerCode);
layer?.set?.("id", FLDNG_HSTRY_WFS_LAYER_ID);
layer?.set?.("lyrNm", FLOOD_HISTORY_LAYER_NAME);
layer?.set?.("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE);
return () => {
core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
};
}, [isFldngHstryChecked, mapReady]);
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.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 기준으로 다시 조회한다.
const key = gisMaps.current.on("moveend", () => {
if (
!flowDirectionCheckedRef.current ||
!flowDirectionLayerInfoRef.current
)
return;
loadFlowDirectionLayer(flowDirectionLayerInfoRef.current);
});
return () => {
unByKey(key);
};
}, [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,
]);
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", exmnPplFilters);
}, [
exmnPplFilters,
isExmnPplChecked,
loadExmnResultLayer,
mapReady,
removeExmnResultLayer,
]);
useEffect(() => {
if (!mapReady || !isExmnMnhlChecked) {
removeExmnResultLayer("MNHL");
return;
}
void loadExmnResultLayer("MNHL", exmnMnhlFilters);
}, [
exmnMnhlFilters,
isExmnMnhlChecked,
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-5rem)] 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)]"
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
treeData={treeData}
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}
searchBar={
<ProcSearchBar
gisMaps={gisMaps}
facilityTypes={facilityTypes}
sidoSearchZoom={
isAgedPipeChecked ? AGED_PIPE_GUIDE_MIN_ZOOM : undefined
}
facilityLayerFilter={swagePrcsFcltSearchFilter}
/>
}
waterDepth={undefined}
layerSettingView={ActiveLayerSettingView}
treeSectionClassName="rounded-sm border border-border bg-background px-1 py-2 text-foreground"
detachedTreeToggleButton={true}
showLayerPanelTitle={false}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying as any}
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
checkInterval={2500}
checkedLayerChangeDelay={0}
showZoomLevel={true}
showZoomScaleInfo={true}
showFloatingSearchButton={false}
treeTogglePosition="center"
zoomGuide={zoomGuide}
layerIconVisible={(node: any) =>
isAgedPipeLayerNode(node) ||
isSinkholeRootNode(node) ||
isSbmrgDngrMapRootNode(node) ||
isFloodDngrMapRootNode(node) ||
isFldngHstryNode(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: auto;
right: 15px;
bottom: 439px;
}
.sn-map-control-layout .custom-toolbar {
position: static;
}
.sn-map-control-layout .tool-bar {
top: auto;
right: 15px;
bottom: 230px;
}
.sn-map-control-layout .zoom-level-control {
right: 15px;
bottom: 36px;
}
.sn-map-control-layout .zoom-level-summary {
right: 56px;
bottom: -15px;
}
.sn-map-control-layout .ol-scale-line {
display: none;
}
`}</style>
</div>
</>
);
};
export default Map;