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

9103 lines
293 KiB
TypeScript

/**
* ➡️ 경로 : 하수관망도 메인 페이지
*
* 📄 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 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 { downloadAgedPipeCsv } 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_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,
WATER_RISK_MAP_AREAS,
WATER_RISK_MAP_PIPES_BY_AREA,
WATER_RISK_RESULT_ROWS,
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 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_GRID_LAYER_ID = "SN_WATER_RISK_GRID";
const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE";
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";
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),
};
});
};
// 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다.
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 getWaterRiskAreaMapData = (areaId: string | null) =>
WATER_RISK_MAP_AREAS.find((area) => area.areaId === areaId) ?? null;
const getWaterRiskResultRow = (areaId: string | null) =>
WATER_RISK_RESULT_ROWS.find((row) => row.areaId === areaId) ?? null;
const buildWaterRiskInfoAnchor = (areaId: string | null) => {
const areaData = getWaterRiskAreaMapData(areaId);
if (!areaData) return null;
const polygon = new Polygon(areaData.polygonCoordinates);
const extent = polygon.getExtent();
const [, minY = 0, maxX = 0, maxY = 0] = extent;
return [maxX, (minY + maxY) / 2] as [number, number];
};
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)";
const buildWaterRiskGridFillColor = (colorLevel: string) => {
switch (colorLevel) {
case "LEVEL_4":
return "rgba(198, 40, 40, 0.28)";
case "LEVEL_3":
return "rgba(245, 124, 0, 0.24)";
case "LEVEL_2":
return "rgba(251, 192, 45, 0.22)";
default:
return "rgba(33, 150, 243, 0.18)";
}
};
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 createWaterRiskGridStyle = (feature: any) => {
const rainQty = Number(feature.get("rainQty") ?? 0);
const colorLevel = String(feature.get("colorLevel") ?? "LEVEL_1");
return new Style({
fill: new Fill({ color: buildWaterRiskGridFillColor(colorLevel) }),
stroke: new Stroke({
color: "rgba(71, 85, 105, 0.36)",
width: 1,
}),
text: new Text({
text: `${rainQty.toFixed(1)}mm`,
font: "600 11px sans-serif",
fill: new Fill({ color: "#0f172a" }),
overflow: true,
}),
zIndex: 9995,
});
};
const createWaterRiskPipeStyle = (feature: any) => {
const displayLabel = String(feature.get("displayLabel") ?? "");
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: "rgba(205, 52, 52, 0.96)",
width: 4,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9998,
text: new Text({
text: displayLabel,
font: "700 12px sans-serif",
fill: new Fill({ color: "#ffffff" }),
backgroundFill: new Fill({ color: "rgba(60, 60, 60, 0.9)" }),
padding: [3, 6, 3, 6],
overflow: true,
}),
}),
];
};
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 "침수흔적도";
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 waterRiskGridLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskGridSourceRef = useRef<VectorSource | null>(null);
const waterRiskPipeLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskPipeSourceRef = useRef<VectorSource | 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 ??
getWaterRiskResultRow(waterRiskSession.selectedAreaId),
[selectedWaterRiskApiResult, waterRiskSession.selectedAreaId],
);
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 = 236;
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;
}
}, []);
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 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>();
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;
const source = layer.getSource?.();
if (!source?.getFeatures) return;
selectLoadedGroupFeatures(source);
source.once?.("featuresloadend", () =>
selectLoadedGroupFeatures(source),
);
});
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
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);
};
}, []);
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(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 ensureWaterRiskGridLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (waterRiskGridLayerRef.current && waterRiskGridSourceRef.current) {
return waterRiskGridSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9995,
});
layer.set("id", WATER_RISK_GRID_LAYER_ID);
layer.set("lyrNm", "통수위험 예측강우 격자");
layer.set("lyrTyNm", WATER_RISK_GRID_LAYER_ID);
map.addLayer(layer);
waterRiskGridLayerRef.current = layer;
waterRiskGridSourceRef.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 clearWaterRiskLayers = useCallback(() => {
waterRiskAreaSourceRef.current?.clear();
waterRiskGridSourceRef.current?.clear();
waterRiskPipeSourceRef.current?.clear();
}, []);
const renderWaterRiskSelection = useCallback(
(areaId: string | null, mapView: WaterRiskMapView | null) => {
const map = gisMaps.current;
if (!map) return;
const areaSource = ensureWaterRiskAreaLayer();
const gridSource = ensureWaterRiskGridLayer();
if (!areaSource || !gridSource) return;
areaSource.clear();
gridSource.clear();
const pipeSource = ensureWaterRiskPipeLayer();
pipeSource?.clear();
if (!areaId) return;
if (mapView && mapView.areaId === areaId) {
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,
});
pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature));
pipeSource.addFeature(pipeFeature);
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,
});
return;
}
const areaData = getWaterRiskAreaMapData(areaId);
const resultRow = getWaterRiskResultRow(areaId);
if (!areaData || !resultRow) return;
const areaFeature = new Feature({
geometry: new Polygon(areaData.polygonCoordinates),
areaId: areaData.areaId,
areaName: areaData.areaName,
riskStatus: resultRow.riskStatus,
});
areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature));
areaSource.addFeature(areaFeature);
areaData.gridCells.forEach((cell) => {
const cellFeature = new Feature({
geometry: new Polygon(cell.polygonCoordinates),
gridId: cell.gridId,
rainQty: cell.rainQty,
colorLevel: cell.colorLevel,
});
cellFeature.setStyle(createWaterRiskGridStyle(cellFeature));
gridSource.addFeature(cellFeature);
});
const pipes = WATER_RISK_MAP_PIPES_BY_AREA[areaId] ?? [];
pipes.forEach((pipe) => {
if (!pipeSource) return;
const pipeFeature = new Feature({
geometry: new LineString(pipe.lineCoordinates),
pipeId: pipe.pipeId,
displayLabel: pipe.displayLabel,
});
pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature));
pipeSource.addFeature(pipeFeature);
});
const areaGeometry = areaFeature.getGeometry();
if (!areaGeometry) return;
map.getView().fit(areaGeometry.getExtent(), {
duration: 300,
padding: [120, 120, 120, 120],
maxZoom: 15,
constrainResolution: true,
nearest: true,
});
},
[
ensureWaterRiskAreaLayer,
ensureWaterRiskGridLayer,
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) return;
setWaterRiskInfoAnchor(
buildWaterRiskInfoAnchor(waterRiskSession.selectedAreaId),
);
}, [selectedWaterRiskMapView, waterRiskSession.selectedAreaId]);
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;
}
};
window.addEventListener("message", handleWaterRiskPopupMessage);
return () => {
window.removeEventListener("message", handleWaterRiskPopupMessage);
};
}, [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", "침수흔적도");
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", "침수흔적도");
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: Pick<PipeAnalysisDetailRow, "fid">) => {
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 handleAgedPipeDownload = useCallback(async () => {
if (agedPipeTotalCount === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
try {
setIsAgedPipeListLoading(true);
setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => {
if (job.status === "QUEUED") {
setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다.");
return;
}
if (job.status === "RUNNING") {
setAgedPipeDownloadProgressText(
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
});
setAgedPipeDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
setAgedPipeDownloadProgressText("");
alert(
error instanceof Error
? error.message
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
);
} 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: "침수흔적도",
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", "침수흔적도");
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;
}, 300);
}
return popup;
},
[clearWaterRiskPopupCloseWatcher],
);
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 border-b border-border 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 ${
selectedWaterRiskResultRow.riskStatus === "DANGER"
? "bg-[#ff1c15] text-white"
: "bg-[#e8f6f4] text-[#0f766e]"
}`}
>
{buildWaterRiskStatusLabel(
selectedWaterRiskResultRow.riskStatus,
)}
</span>
</div>
<div className="px-[8px] py-[8px]">
<div className="overflow-hidden rounded-[10px] border border-border bg-background shadow-blur">
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[46%]" />
<col className="w-[30%]" />
<col className="w-[24%]" />
</colgroup>
<tbody>
<tr className="border-b border-border">
<td
colSpan={2}
className="border-r border-border bg-secondary px-[9px] py-[7px] text-[10px] font-semibold leading-[1.2] text-label"
>
예측강우량(mm/hr)
</td>
<td className="px-[9px] py-[7px] text-right text-[12px] font-bold text-foreground">
{selectedWaterRiskResultRow.forecastRainQty == null
? "-"
: selectedWaterRiskResultRow.forecastRainQty.toFixed(
1,
)}
</td>
</tr>
{selectedWaterRiskResultRow.pipeSummaries.map(
(pipe, index) => {
const isDanger = pipe.runoffFlowQty > pipe.conveyQty;
const isLast =
index ===
selectedWaterRiskResultRow.pipeSummaries.length - 1;
return (
<Fragment key={`${pipe.pipeId}-row`}>
<tr>
<td
rowSpan={2}
className={`border-r border-border px-[9px] py-[8px] align-top ${!isLast ? "border-b border-border" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[10px] font-semibold leading-[1.15] text-label">
관로
<br />
ID {index + 1}
</div>
<div className="mt-[5px] whitespace-nowrap text-[11px] font-bold leading-none text-foreground">
{pipe.pipeId}
</div>
</div>
<span
className={`inline-flex min-w-[40px] shrink-0 items-center justify-center rounded-[6px] px-[5px] py-[4px] text-[9px] font-bold ${
isDanger
? "bg-[#ff1c15] text-white"
: "bg-[#eaf4ff] text-[#1565c0]"
}`}
>
{isDanger ? "위험" : "정상"}
</span>
</div>
</td>
<td className="border-b border-r border-border bg-secondary px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-label">
통수량
<br />
(㎥/s)
</td>
<td className="border-b border-border px-[9px] py-[7px] text-right text-[11px] font-medium text-foreground">
{pipe.conveyQty.toFixed(1)}
</td>
</tr>
<tr
className={
!isLast ? "border-b border-border" : ""
}
>
<td className="border-r border-border bg-secondary px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-label">
유출유량
<br />
(㎥/s)
</td>
<td
className={`px-[9px] py-[7px] text-right text-[11px] ${isDanger ? "font-bold text-[#d32f2f]" : "font-medium text-foreground"}`}
>
{pipe.runoffFlowQty.toFixed(1)}
</td>
</tr>
</Fragment>
);
},
)}
</tbody>
</table>
</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)
}
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;