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

7228 lines
257 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 } 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 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 { useUserStore } from "@/shared/lib/use-user-store";
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
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 FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel";
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_LAYER_TYPE,
FLOOD_HISTORY_ROW_SELECT_MESSAGE,
FLOOD_HISTORY_POPUP_KEY,
isFloodHistoryNode,
type FloodHistoryItem,
} from "./components/floodHistory/floodHistoryTypes";
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_PIPE_LAYER_TYPE,
WATER_RISK_RESULT_ROWS,
WATER_RISK_SETTING_ROWS,
type WaterRiskSessionSnapshot,
type WaterRiskStagedPipe,
} 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_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 SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
600000,
1300000,
1500000,
2500000,
];
// 유하방향은 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;
type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
type RightPanelTab = {
id: string;
kind: RightPanelTabKind;
title: string;
subTitle?: string;
featureData: any;
sinkholeDetail?: SinkholeDetail | null;
nearbyPipes?: SinkholeNearbyPipe[];
gisEditChangeId?: string | null;
lastActiveAt: number;
};
type RightPanelBookmarkTabsProps = {
tabs: RightPanelTab[];
activeTabId: string | null;
onActivate: (tabId: string) => void;
};
// 탭 제한을 넘으면 새 탭과 현재 탭을 보존하고 오래된 일반 상세부터 정리한다.
const findRightPanelTabOverflowCandidate = (
tabs: RightPanelTab[],
nextTabId: string,
activeTabId: string | null,
) => {
const protectedTabIds = new Set([nextTabId, activeTabId].filter(Boolean) as string[]);
const removableTabs = tabs
.filter((tab) => !protectedTabIds.has(tab.id))
.sort((a, b) => a.lastActiveAt - b.lastActiveAt);
return (
removableTabs.find((tab) => tab.kind === "feature") ??
removableTabs.find((tab) => tab.kind !== "gisEdit") ??
removableTabs[0] ??
tabs.filter((tab) => tab.id !== nextTabId).sort((a, b) => a.lastActiveAt - b.lastActiveAt)[0] ??
null
);
};
// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다.
const RightPanelBookmarkTabs = ({ tabs, activeTabId, onActivate }: RightPanelBookmarkTabsProps) => {
if (tabs.length < 2) return null;
return (
<div className="absolute right-[350px] top-[92px] z-[70] flex w-[52px] flex-col items-end gap-2 pointer-events-auto">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<button
key={tab.id}
type="button"
className={[
"min-h-[38px] w-[46px] border border-r-0 px-1 py-1 text-center text-[0.95rem] font-semibold leading-tight shadow-sm transition-colors",
isActive
? "bg-primary text-white border-primary"
: "bg-white text-slate-900 border-[#9db8d8] hover:bg-[#e8f2ff]",
].join(" ")}
title={tab.subTitle ? `${tab.title} ${tab.subTitle}` : tab.title}
aria-pressed={isActive}
onClick={() => onActivate(tab.id)}
>
<span className="block break-keep">{tab.title}</span>
{tab.subTitle ? (
<span className="mt-[1px] block break-all text-[0.78rem] font-bold leading-none opacity-95">
{tab.subTitle}
</span>
) : null}
</button>
);
})}
</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 buildWaterRiskSeedPipes = (areaId: string) =>
WATER_RISK_SETTING_ROWS
.filter((row) => row.areaId === areaId)
.sort((a, b) => a.setSeq - b.setSeq)
.map<WaterRiskStagedPipe>((row) => ({
pipeId: row.pipeId,
pipeDia: row.pipeDia,
pipeLen: row.pipeLen,
pipeMaterial: row.pipeMaterial,
setSeq: row.setSeq,
source: "MOCK",
}));
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 "om_dt_untrt_swg_rain":
return "중점우수";
default:
return String(data?.lyrNm ?? data?.values_?.lyrNm ?? "상세").trim() || "상세";
}
};
const getRightPanelFeatureSubTitle = (data: any) => {
const layerTypeName = getSelectedFeatureLayerTypeName(data);
if (layerTypeName !== "tb_ms_swage_ppl") return "";
const pipeManagementNo =
data?.get?.("fclt_mng_no") ??
data?.get?.("fcltMngNo") ??
data?.get?.("pipeId") ??
data?.values_?.fclt_mng_no ??
data?.values_?.fcltMngNo ??
data?.values_?.pipeId ??
data?.fcltMngNo ??
data?.pipeId ??
data?.fid ??
"";
return String(pipeManagementNo).trim();
};
const createFloodHistoryAreaStyle = (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 createFloodHistoryDepthStyle = (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 createSinkholeFocusedPipeStyle = () => [
new Style({
stroke: new Stroke({
color: "rgba(88, 28, 135, 0.22)",
width: 18,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9997,
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 255, 255, 0.96)",
width: 12,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9998,
}),
new Style({
stroke: new Stroke({
color: "rgba(37, 99, 235, 0.98)",
width: 8,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9999,
}),
];
const formatWaterRiskPipeField = (value: unknown, suffix: string) => {
const normalized = String(value ?? "").trim();
if (!normalized) return "-";
if (normalized.endsWith(suffix) || suffix === "") return normalized;
return `${normalized} ${suffix}`.trim();
};
const buildWaterRiskPipeFromFeature = (data: any): WaterRiskStagedPipe | null => {
const values = data?.values_ ?? data;
const pipeId = String(
values?.fclt_mng_no ??
values?.fcltMngNo ??
values?.pipeId ??
values?.id ??
data?.fid ??
"",
).trim();
if (!pipeId) return null;
return {
pipeId,
pipeDia: formatWaterRiskPipeField(
values?.pipe_diameter ?? values?.pdmt_vl ?? values?.pdmtVl,
"mm",
),
pipeLen: formatWaterRiskPipeField(
values?.pipe_lt ?? values?.pipeLt ?? values?.extn_lt ?? values?.extnLt,
"m",
),
pipeMaterial: String(
values?.pipe_material ??
values?.pipeMaterial ??
values?.mqlt_cd ??
values?.mqltCd ??
"-",
).trim() || "-",
setSeq: 0,
source: "MAP",
};
};
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;
if (data.some((item) => String(item.lyrSn) === AGED_PIPE_CONTROL_NODE_ID || item.lyrNm === "노후관로")) {
return data;
}
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: "",
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: null,
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: null,
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: null,
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: null,
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 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 [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 [isFloodHistoryChecked, setIsFloodHistoryChecked] = useState(false);
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 [selectedFloodHistoryDetail, setSelectedFloodHistoryDetail] = useState<FloodHistoryItem | null>(null);
const [floodHistoryDetailPopupPosition, setFloodHistoryDetailPopupPosition] = useState<{ left: number; top: number } | null>(null);
const [selectedSinkholeDetail, setSelectedSinkholeDetail] = useState<SinkholeDetail | null>(null);
const [selectedSinkholeNearbyPipes, setSelectedSinkholeNearbyPipes] = useState<SinkholeNearbyPipe[]>([]);
const [lastSinkholeSyncSummary, setLastSinkholeSyncSummary] = useState<SinkholeSyncSummary | null>(null);
const [sinkholeSelectionVersion, setSinkholeSelectionVersion] = useState(0);
const [isAgedPipeListOpen, setIsAgedPipeListOpen] = useState(false);
const [isAgedPipeListMinimized, setIsAgedPipeListMinimized] = useState(false);
const [agedPipeRows, setAgedPipeRows] = useState<AgedPipeRow[]>([]);
const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0);
const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1);
const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false);
const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState("");
const [isGisEditSaving, setIsGisEditSaving] = useState(false);
const { ctpv, sgg } = useStore();
// GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다.
const gisEdit = useGisEdit(treeData);
// 침입수 분석결과 popup과 지도 highlight 흐름은 별도 hook으로 묶어 기존 지도 로직과 충돌을 줄인다.
const inftwtResultMap = useInftwtResultMap({
gisMaps,
setFeatureData,
setIsOpen,
setIsSearchModalOpen,
});
const rdiiResultMap = useRdiiResultMap({
gisMaps,
setFeatureData,
setIsOpen,
setIsSearchModalOpen,
});
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef<Window | null>(null);
const sinkholePopupWindowRef = useRef<Window | null>(null);
// 침수발생이력 overlay layer는 지도 드래그 중에도 유지돼야 하므로 React state 대신 ref로 보관한다.
const floodHistoryAreaLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const floodHistoryDepthLayerRef = 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 [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 restoreSinkholeDetailOnCloseRef = 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,
);
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 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;
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;
}, []);
useEffect(() => () => {
if (gisEditSessionPopupCloseWatcherRef.current) {
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
gisEditSessionPopupCloseWatcherRef.current = null;
}
}, []);
const openGisEditPipeDetail = useCallback((changeId: string | null) => {
if (!changeId) {
setGisEditDetailChangeId(null);
return;
}
setGisEditDetailChangeId(changeId);
}, []);
const focusGisEditChangeOnMap = useCallback((changeId: string) => {
const map = gisMaps.current;
const change = gisEdit.changes.find((item) => item.changeId === changeId);
const geometryObject = change?.editType === "DELETE" ? change?.beforeGeometry : change?.afterGeometry;
if (!map || !geometryObject) return;
try {
const geometry = new GeoJSON().readGeometry(geometryObject, {
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
});
const extent = geometry?.getExtent?.();
if (
!extent ||
extent.length < 4 ||
extent.some((value: number) => !Number.isFinite(value))
) {
return;
}
const view = map.getView?.();
const currentZoom = Number(view?.getZoom?.());
const maxZoom = Number.isFinite(currentZoom) && currentZoom > 18 ? currentZoom : 18;
view?.fit?.(extent, {
duration: 350,
padding: [80, 420, 80, 80],
maxZoom,
});
} catch (error) {
console.warn("GIS edit change focus failed:", error);
}
}, [gisEdit.changes]);
const 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(
() => getWaterRiskResultRow(waterRiskSession.selectedAreaId),
[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(() => {
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);
}
};
window.addEventListener("message", handlePopupMessage);
return () => {
window.clearInterval(timer);
window.removeEventListener("message", handlePopupMessage);
};
}, []);
useEffect(() => {
const shouldKeepPopupOpen = gisEdit.isEditMode || gisEditVisibleSessionChanges.length > 0;
if (shouldKeepPopupOpen) return;
const popup = gisEditSessionPopupWindowRef.current;
if (!popup || popup.closed) {
gisEditSessionPopupWindowRef.current = null;
return;
}
popup.close();
gisEditSessionPopupWindowRef.current = null;
}, [gisEdit.isEditMode, gisEditVisibleSessionChanges.length]);
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 || gisEditVisibleSessionChanges.length > 0)) 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_FOCUS_CHANGE) {
const changeId = String(data.payload?.changeId ?? "").trim();
if (changeId) {
gisEdit.focusChange(changeId);
focusGisEditChangeOnMap(changeId);
setFeatureData(null);
openGisEditPipeDetail(changeId);
setIsOpen(true);
setIsSearchModalOpen(false);
}
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);
};
}, [focusGisEditChangeOnMap, gisEdit, openGisEditPipeDetail, syncGisEditSessionPopup]);
const fetchData = async () => {
try {
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(
appendAgedPipeControlNode(
appendSinkholeControlNode(
normalizeTreeRowSpacing(normalizeFlowDirectionLayerInfos(data)),
),
),
);
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) => {
const map = gisMaps.current;
if (!map) return;
const areaSource = ensureWaterRiskAreaLayer();
const gridSource = ensureWaterRiskGridLayer();
if (!areaSource || !gridSource) return;
areaSource.clear();
gridSource.clear();
if (!areaId) 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 pipeSource = ensureWaterRiskPipeLayer();
const pipes = WATER_RISK_MAP_PIPES_BY_AREA[areaId] ?? [];
pipeSource?.clear();
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]);
const setWaterRiskStatusMessage = useCallback((statusMessage: string) => {
setWaterRiskSession((current) => ({
...current,
statusMessage,
}));
}, []);
const startWaterRiskSettingEdit = useCallback((areaId: string, areaName: string) => {
setWaterRiskSession((current) => {
const shouldSeedPipes =
current.selectedSettingAreaId !== areaId || current.stagedPipes.length === 0;
return {
...current,
selectedAreaId: areaId,
selectedSettingAreaId: areaId,
selectedSettingAreaName: areaName,
isSettingEditMode: true,
stagedPipes: shouldSeedPipes ? buildWaterRiskSeedPipes(areaId) : current.stagedPipes,
statusMessage: `${areaName} 설정 편집 모드입니다. 지도에서 하수관로를 클릭하세요.`,
};
});
setIsOpen(false);
setFeatureData(null);
}, []);
const stopWaterRiskSettingEdit = useCallback(() => {
setWaterRiskSession((current) => ({
...current,
isSettingEditMode: false,
statusMessage: "",
}));
}, []);
const removeWaterRiskStagedPipe = useCallback((pipeId: string) => {
setWaterRiskSession((current) => ({
...current,
stagedPipes: current.stagedPipes
.filter((pipe) => pipe.pipeId !== pipeId)
.map((pipe, index) => ({
...pipe,
setSeq: index + 1,
})),
statusMessage: `${pipeId} 관로를 staged 목록에서 제거했습니다.`,
}));
}, []);
const reorderWaterRiskStagedPipe = useCallback((pipeId: string, direction: "UP" | "DOWN") => {
setWaterRiskSession((current) => {
const currentIndex = current.stagedPipes.findIndex((pipe) => pipe.pipeId === pipeId);
if (currentIndex < 0) return current;
const nextIndex =
direction === "UP" ? currentIndex - 1 : currentIndex + 1;
if (nextIndex < 0 || nextIndex >= current.stagedPipes.length) {
return current;
}
const reordered = [...current.stagedPipes];
const [targetPipe] = reordered.splice(currentIndex, 1);
if (!targetPipe) {
return current;
}
reordered.splice(nextIndex, 0, targetPipe);
return {
...current,
stagedPipes: reordered.map((pipe, index) => ({
...pipe,
setSeq: index + 1,
})),
statusMessage: `${pipeId} 관로 순번을 조정했습니다.`,
};
});
}, []);
const clearWaterRiskStagedPipes = useCallback(() => {
setWaterRiskSession((current) => ({
...current,
stagedPipes: [],
statusMessage: "staged 유출관로 목록을 초기화했습니다.",
}));
}, []);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
if (!waterRiskSession.selectedAreaId) {
clearWaterRiskLayers();
setWaterRiskInfoAnchor(null);
setWaterRiskInfoCardStyle(null);
return;
}
renderWaterRiskSelection(waterRiskSession.selectedAreaId);
}, [
clearWaterRiskLayers,
mapReady,
renderWaterRiskSelection,
waterRiskSession.selectedAreaId,
]);
useEffect(() => {
setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(waterRiskSession.selectedAreaId));
}, [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;
setWaterRiskInfoAnchor(buildWaterRiskInfoAnchor(areaId));
setWaterRiskSession((current) => ({
...current,
selectedAreaId: areaId,
statusMessage: `${getWaterRiskResultRow(areaId)?.areaName ?? areaId} 지도 강조를 갱신했습니다.`,
}));
setIsOpen(false);
setFeatureData(null);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT) {
const areaId = String(data.payload?.areaId ?? "").trim();
const areaName = String(data.payload?.areaName ?? "").trim();
if (!areaId) return;
startWaterRiskSettingEdit(areaId, areaName || getWaterRiskResultRow(areaId)?.areaName || areaId);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT) {
stopWaterRiskSettingEdit();
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE) {
const pipeId = String(data.payload?.pipeId ?? "").trim();
if (pipeId) {
removeWaterRiskStagedPipe(pipeId);
}
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE) {
const pipeId = String(data.payload?.pipeId ?? "").trim();
const direction = data.payload?.direction === "UP" ? "UP" : "DOWN";
if (pipeId) {
reorderWaterRiskStagedPipe(pipeId, direction);
}
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES) {
clearWaterRiskStagedPipes();
}
};
window.addEventListener("message", handleWaterRiskPopupMessage);
return () => {
window.removeEventListener("message", handleWaterRiskPopupMessage);
};
}, [
clearWaterRiskStagedPipes,
removeWaterRiskStagedPipe,
reorderWaterRiskStagedPipe,
startWaterRiskSettingEdit,
stopWaterRiskSettingEdit,
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);
// 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다.
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) {
restoreSinkholeDetailOnCloseRef.current = false;
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;
}
if (waterRiskSession.isSettingEditMode) {
const layerTypeName = getSelectedFeatureLayerTypeName(data);
if (layerTypeName !== WATER_RISK_PIPE_LAYER_TYPE) {
setWaterRiskStatusMessage("통수위험 설정 모드에서는 하수관로만 선택할 수 있습니다.");
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
const nextPipe = buildWaterRiskPipeFromFeature(data);
if (!nextPipe) {
setWaterRiskStatusMessage("선택한 하수관로 정보를 staged 목록으로 옮기지 못했습니다.");
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
setWaterRiskSession((current) => {
if (current.stagedPipes.some((pipe) => pipe.pipeId === nextPipe.pipeId)) {
return {
...current,
statusMessage: `${nextPipe.pipeId} 관로는 이미 staged 목록에 있습니다.`,
};
}
if (current.stagedPipes.length >= 3) {
return {
...current,
statusMessage: "유출관로는 최대 3건까지만 staged 목록에 담을 수 있습니다.",
};
}
return {
...current,
stagedPipes: [
...current.stagedPipes,
{
...nextPipe,
setSeq: current.stagedPipes.length + 1,
},
],
statusMessage: `${nextPipe.pipeId} 관로를 staged 목록에 추가했습니다.`,
};
});
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
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);
}
restoreSinkholeDetailOnCloseRef.current =
clickedLayerTypeName === "tb_ms_swage_ppl" && Boolean(selectedSinkholeDetail);
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 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.addFeatures([radiusFeature, 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 ensureFloodHistoryAreaSource = useCallback(() => {
if (!mapReady || !gisMaps.current) return null;
if (!floodHistoryAreaLayerRef.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_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
floodHistoryAreaLayerRef.current = layer;
}
return floodHistoryAreaLayerRef.current.getSource();
}, [mapReady]);
const ensureFloodHistoryDepthSource = useCallback(() => {
if (!mapReady || !gisMaps.current) return null;
if (!floodHistoryDepthLayerRef.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_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
floodHistoryDepthLayerRef.current = layer;
}
return floodHistoryDepthLayerRef.current.getSource();
}, [mapReady]);
const clearFloodHistoryLayers = useCallback(() => {
floodHistoryAreaLayerRef.current?.getSource?.()?.clear?.();
floodHistoryDepthLayerRef.current?.getSource?.()?.clear?.();
}, []);
const renderSelectedFloodHistory = useCallback(
(detail: FloodHistoryItem | null) => {
const areaSource = ensureFloodHistoryAreaSource();
const depthSource = ensureFloodHistoryDepthSource();
if (!areaSource || !depthSource) return;
areaSource.clear();
depthSource.clear();
if (!detail) return;
const areaFeature = new Feature({
geometry: new Polygon([detail.areaRing]),
});
areaFeature.setStyle(createFloodHistoryAreaStyle(`${detail.floodYear}년 ${detail.districtName}`));
areaSource.addFeature(areaFeature);
detail.depthZones.forEach((zone) => {
const zoneFeature = new Feature({
geometry: new Polygon([zone.ring]),
});
zoneFeature.setStyle(createFloodHistoryDepthStyle(zone.depthLabel, zone.color));
depthSource.addFeature(zoneFeature);
});
},
[ensureFloodHistoryAreaSource, ensureFloodHistoryDepthSource],
);
const focusFloodHistoryOnMap = useCallback((detail: FloodHistoryItem) => {
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 clearFloodHistorySelection = useCallback(() => {
setSelectedFloodHistoryDetail(null);
setFloodHistoryDetailPopupPosition(null);
clearFloodHistoryLayers();
}, [clearFloodHistoryLayers]);
const closeFloodHistoryDetail = useCallback(() => {
setFloodHistoryDetailPopupPosition(null);
}, []);
const handleFloodHistoryRowSelect = useCallback(
(detail: FloodHistoryItem) => {
setSelectedFloodHistoryDetail(detail);
renderSelectedFloodHistory(detail);
focusFloodHistoryOnMap(detail);
setIsSearchModalOpen(false);
},
[focusFloodHistoryOnMap, renderSelectedFloodHistory, setIsSearchModalOpen],
);
const openFloodHistoryPopupWindow = 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();
restoreSinkholeDetailOnCloseRef.current = false;
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;
}
try {
const layerTypeName = String(pipe?.lyrTypeNm ?? "tb_ms_swage_ppl");
const params = new URLSearchParams({
SERVICE: "WFS",
VERSION: "1.1.0",
REQUEST: "GetFeature",
TYPENAME: `${GisApp.Module.layer.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);
restoreSinkholeDetailOnCloseRef.current = false;
setFeatureData(firstFeature);
setIsOpen(true);
setIsSearchModalOpen(false);
}
} catch (error) {
console.error("지반침하 반경 내 관로 지도 표시 실패:", error);
alert("선택한 관로를 지도에서 표시하지 못했습니다.");
}
}, [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",
title: "지반침하",
subTitle: acdntNo,
featureData: {
lyrTypeNm: SINKHOLE_LAYER_TYPE,
acdntNo,
},
sinkholeDetail: detail,
nearbyPipes: selectedSinkholeNearbyPipes,
lastActiveAt: now,
};
}
if (gisEditDetailChangeId) {
return {
id: `gisEdit:${gisEditDetailChangeId}`,
kind: "gisEdit",
title: "관로편집",
featureData: data,
gisEditChangeId: gisEditDetailChangeId,
lastActiveAt: now,
};
}
const featureId = getRightPanelFeatureIdentifier(data) || `${layerTypeName || "feature"}:${now}`;
return {
id: `feature:${layerTypeName || "unknown"}:${featureId}`,
kind: "feature",
title: getRightPanelFeatureTitle(data),
subTitle: getRightPanelFeatureSubTitle(data),
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,
activeRightPanelTabId,
);
if (!overflowCandidate) return mergedTabs.slice(-RIGHT_PANEL_TAB_LIMIT);
return mergedTabs.filter((item) => item.id !== overflowCandidate.id);
});
setActiveRightPanelTabId(tab.id);
}, [activeRightPanelTabId]);
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.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 = rightPanelTabs.filter((tab) => tab.id !== 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);
}
setRightPanelTabs(remainingTabs);
if (remainingTabs.length === 0) {
setActiveRightPanelTabId(null);
restoreSinkholeDetailOnCloseRef.current = false;
setFeatureData(null);
setIsOpen(false);
return;
}
const fallbackTab = [...remainingTabs].sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0];
activateRightPanelTab(fallbackTab.id);
},
[activateRightPanelTab, clearSinkholeFocusedPipeLayer, clearSinkholeSelectionLayer, rightPanelTabs],
);
useEffect(() => {
if (!isOpen || !featureData) return;
const nextTab = buildRightPanelTab(featureData);
if (nextTab) upsertRightPanelTab(nextTab);
}, [buildRightPanelTab, featureData, isOpen, upsertRightPanelTab]);
useEffect(() => {
if (isOpen) return;
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 handleFloodHistoryPopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== FLOOD_HISTORY_ROW_SELECT_MESSAGE || !event.data?.row) return;
handleFloodHistoryRowSelect(event.data.row as FloodHistoryItem);
};
window.addEventListener("message", handleFloodHistoryPopupMessage);
return () => window.removeEventListener("message", handleFloodHistoryPopupMessage);
}, [handleFloodHistoryRowSelect]);
// 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 floodHistoryChecked = checkedNodesData.some((node) => isFloodHistoryNode(node));
setIsFloodHistoryChecked(floodHistoryChecked);
if (!floodHistoryChecked) {
clearFloodHistorySelection();
}
const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node));
setIsSinkholeChecked(sinkholeChecked);
setActiveLayerSettingOrder((currentOrder) => {
const activeKeys = [
waterDepthChecked ? "waterDepthLegend" : null,
sinkholeChecked ? "sinkhole" : null,
agedPipeChecked ? "agedPipe" : 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();
}
}, [clearFloodHistorySelection, closeSinkholeDetail, handleWaterDepthVisibility, 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 handleOpenLayerPopup = useCallback(
(_layerNm: string, node?: any) => {
if (isAgedPipeLayerNode(node)) {
openAgedPipeList();
return false;
}
if (isSinkholeRootNode(node)) {
openSinkholePopupWindow();
return false;
}
if (isFloodHistoryNode(node)) {
openFloodHistoryPopupWindow();
return false;
}
return true;
},
[openAgedPipeList, openFloodHistoryPopupWindow, 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 (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-sky-100 bg-sky-50/40">
<div className="px-3 py-2">
<div className="rounded-sm border border-slate-200 bg-white px-3 py-3">
<div className="mb-2 bg-blue-900 py-1 text-center text-[1.1rem] font-semibold text-white">
침수심 범례
</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-slate-700">{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,
handleApplyAgedPipeFilters,
isAgedPipeChecked,
isSinkholeChecked,
isWaterDepthLayerActive,
lastSinkholeSyncSummary,
sinkholeRadiusMeters,
sinkholeYearRange,
userInfo?.adminRoleYn,
waterDepthLegendItems,
]);
// 선택된 설정이 있을 때만 하단 전용 섹션을 노출하고, 세로 공간이 부족하면 내부 스크롤로 처리한다.
const ActiveLayerSettingView = useCallback(() => {
if (activeLayerSettingItems.length === 0) return null;
return (
<div className="mt-3 overflow-hidden rounded-sm border border-slate-200 bg-white shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<div className="border-b border-slate-200 bg-slate-50 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[1.2rem] font-semibold text-slate-700">선택 레이어 설정</span>
<span className="text-[1rem] text-slate-500">{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-slate-200 bg-white px-2 py-[1px] text-[1rem] text-slate-600"
>
{item.label}
</span>
))}
</div>
</div>
<div className="max-h-[28vh] overflow-y-auto px-1 py-2 [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]);
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: "rgba(235, 40, 40, 0.82)",
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]);
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;
stopWaterRiskSettingEdit();
}, 300);
}
return popup;
}, [clearWaterRiskPopupCloseWatcher, stopWaterRiskSettingEdit]);
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;
}, 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 편집은 map 화면의 로컬 편집 모드를 토글하면서 세션 상태는 별도 popup 창에 동기화한다.
const handleToggleGisEdit = useCallback(() => {
if (!gisEdit.isEditMode) {
const popup = openGisEditSessionPopup();
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
}
} else if (!gisEditSessionPopupWindowRef.current || gisEditSessionPopupWindowRef.current.closed) {
const popup = openGisEditSessionPopup();
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
}
syncGisEditSessionPopup();
return;
}
gisEdit.toggleEditMode();
setIsOpen(false);
}, [gisEdit, openGisEditSessionPopup, syncGisEditSessionPopup]);
const handleOpenGisEditSessionPopup = useCallback(() => {
const popup = openGisEditSessionPopup();
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
return;
}
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}
/>
);
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}
onClose={() => {
setGisEditDetailChangeId(null);
restoreSinkholeDetailOnCloseRef.current = false;
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 || !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(() => {
renderSelectedFloodHistory(selectedFloodHistoryDetail);
}, [renderSelectedFloodHistory, selectedFloodHistoryDetail]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const map = gisMaps.current;
const clickKey = map.on("singleclick", (event: any) => {
if (!selectedFloodHistoryDetail || !isFloodHistoryChecked) return;
let clickedFloodHistory = false;
map.forEachFeatureAtPixel(
event.pixel,
(feature: any, layer: any) => {
if (
layer === floodHistoryAreaLayerRef.current ||
layer === floodHistoryDepthLayerRef.current
) {
clickedFloodHistory = true;
return feature;
}
return undefined;
},
{ hitTolerance: 4 },
);
if (!clickedFloodHistory) 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);
setFloodHistoryDetailPopupPosition({
left: nextLeft,
top: nextTop,
});
setIsSearchModalOpen(false);
});
return () => {
unByKey(clickKey);
};
}, [isFloodHistoryChecked, mapReady, selectedFloodHistoryDetail, setIsSearchModalOpen]);
useEffect(() => {
return () => {
clearFloodHistoryLayers();
};
}, [clearFloodHistoryLayers]);
return (
<>
<div className="sn-map-control-layout bg-container relative h-[calc(100dvh-5rem)] overflow-hidden">
{layerLoadError ? (
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-white 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-white px-4 py-3 text-sm text-emerald-700 shadow">
{agedPipeDownloadProgressText}
</div>
) : null}
{waterRiskSession.isSettingEditMode ? (
<div className="absolute left-4 top-36 z-[60] max-w-[560px] rounded border border-[#f0d27b] bg-white px-4 py-3 text-sm text-[#7a5b00] shadow">
<div className="font-semibold">
통수위험 설정 모드: {waterRiskSession.selectedSettingAreaName || "중점관리지역 선택 전"}
</div>
<div className="mt-1">
지도에서 하수관로를 클릭하면 staged 목록에 추가됩니다. 최대 3건까지 선택할 수 있습니다.
</div>
</div>
) : null}
{selectedWaterRiskResultRow && waterRiskInfoCardStyle ? (
<div
style={waterRiskInfoCardStyle}
className="absolute z-[61] w-[244px] overflow-hidden rounded-[12px] border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] shadow-[0_14px_28px_rgba(15,23,42,0.16)]"
>
<div className="flex items-center justify-between border-b border-[#e5eaf1] bg-[#f8fafc] px-[9px] py-[7px]">
<div>
<div className="text-[10px] font-semibold text-[#64748b]">중점관리지역</div>
<div className="mt-[2px] text-[13px] font-bold text-[#1f2937]">
{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-[#d8dee8] bg-white shadow-[0_6px_14px_rgba(15,23,42,0.05)]">
<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-[#e5eaf1]">
<td colSpan={2} className="border-r border-[#e5eaf1] bg-[#fbfdff] px-[9px] py-[7px] text-[10px] font-semibold leading-[1.2] text-[#1f2937]">
예측강우량(mm/hr)
</td>
<td className="px-[9px] py-[7px] text-right text-[12px] font-bold text-[#1f2937]">
{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={`align-top border-r border-[#e5eaf1] px-[9px] py-[8px] ${!isLast ? "border-b border-[#e5eaf1]" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[10px] font-semibold leading-[1.15] text-[#1f2937]">
관로
<br />
ID {index + 1}
</div>
<div className="mt-[5px] whitespace-nowrap text-[11px] font-bold leading-none text-[#1f2937]">
{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-[#e5eaf1] bg-[#fbfdff] px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-[#1f2937]">
통수량
<br />
(㎥/hr)
</td>
<td className="border-b border-[#e5eaf1] px-[9px] py-[7px] text-right text-[11px] font-medium text-[#1f2937]">
{pipe.conveyQty.toFixed(1)}
</td>
</tr>
<tr className={!isLast ? "border-b border-[#e5eaf1]" : ""}>
<td className="border-r border-[#e5eaf1] bg-[#fbfdff] px-[9px] py-[7px] text-[10px] font-semibold leading-[1.15] text-[#1f2937]">
유출유량
<br />
(㎥/hr)
</td>
<td className={`px-[9px] py-[7px] text-right text-[11px] ${isDanger ? "font-bold text-[#d32f2f]" : "font-medium text-[#1f2937]"}`}>
{pipe.runoffFlowQty.toFixed(1)}
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
</div>
</div>
) : null}
{selectedFloodHistoryDetail && floodHistoryDetailPopupPosition ? (
<FloodHistoryDetailPanel
detail={selectedFloodHistoryDetail}
position={floodHistoryDetailPopupPosition}
onClose={closeFloodHistoryDetail}
/>
) : null}
<AnalysisPopupMenu
initialActiveKey={initialAnalysisPopupKey}
onOpenPopup={openAnalysisPopup}
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
onToggleGisEdit={handleToggleGisEdit}
isGisEditActive={gisEdit.isEditMode}
isInfoSearchActive={isSearchModalOpen}
/>
<GisEditToolbar
isEditMode={gisEdit.isEditMode}
activeTool={gisEdit.activeTool}
isSnapEnabled={gisEdit.isSnapEnabled}
canUseTools={gisEdit.canUseTools}
canSave={gisEdit.canSave}
isSaving={isGisEditSaving}
sessionChangeCount={gisEditVisibleSessionChanges.length}
statusMessage={gisEdit.statusMessage}
allowedTools={gisEdit.selectedLayer?.allowedTools ?? []}
onOpenSession={handleOpenGisEditSessionPopup}
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}
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-slate-300 px-1 py-2"
detachedTreeToggleButton={true}
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) || isFloodHistoryNode(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-white/95 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-slate-500">
{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: 451px;
}
.sn-map-control-layout .custom-toolbar {
position: static;
}
.sn-map-control-layout .tool-bar {
top: auto;
right: 15px;
bottom: 248px;
}
.sn-map-control-layout .zoom-level-control {
right: 16px;
bottom: 56px;
}
.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;