9067 lines
292 KiB
TypeScript
9067 lines
292 KiB
TypeScript
/**
|
|
* ➡️ 경로 : 하수관망도 메인 페이지
|
|
*
|
|
* 📄 Map Page
|
|
* 지도 페이지 컴포넌트
|
|
*
|
|
* - OpenLayers 기반 GIS 지도 렌더링 및 상호작용
|
|
* - 레이어 트리 데이터 관리 및 동적 레이어 표시/숨김
|
|
* - 피처 검색 및 선택 기능 (SearchModal, ProcSearchBar)
|
|
* - 피처 클릭 이벤트 처리 및 상세 정보 팝업 표시
|
|
* - 수심(WaterDepth) 레이어 애니메이션 재생 기능
|
|
* - Zustand를 통한 검색 모달 상태 관리
|
|
*
|
|
* 🔗 사용 API
|
|
* - 레이어 설정 조회: GET /api/v1/map/lyrStngs/{lyrGrp} (sewerage-service)
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import {
|
|
Fragment,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
type UIEvent,
|
|
} from "react";
|
|
import { usePathname, useSearchParams } from "next/navigation";
|
|
|
|
import Collection from "ol/Collection.js";
|
|
import Feature from "ol/Feature.js";
|
|
import GeoJSON from "ol/format/GeoJSON.js";
|
|
import CircleGeom from "ol/geom/Circle.js";
|
|
import Draw from "ol/interaction/Draw.js";
|
|
import Snap from "ol/interaction/Snap.js";
|
|
import Translate from "ol/interaction/Translate.js";
|
|
import Fill from "ol/style/Fill.js";
|
|
import Icon from "ol/style/Icon.js";
|
|
import LineString from "ol/geom/LineString.js";
|
|
import Point from "ol/geom/Point.js";
|
|
import Polygon from "ol/geom/Polygon.js";
|
|
import VectorLayer from "ol/layer/Vector.js";
|
|
import { unByKey } from "ol/Observable.js";
|
|
import VectorSource from "ol/source/Vector.js";
|
|
import CircleStyle from "ol/style/Circle.js";
|
|
import Stroke from "ol/style/Stroke.js";
|
|
import Style from "ol/style/Style.js";
|
|
import Text from "ol/style/Text.js";
|
|
|
|
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
|
import {
|
|
isWindowPopupKey,
|
|
type WindowPopupKey,
|
|
} from "@/features/windowPopup/model/popupDefinitions";
|
|
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
|
import Gis from "@common/business_components/ui/map/Gis";
|
|
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
|
import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
|
|
import { Button } from "@common/components";
|
|
|
|
import { getData, getRawJson, postRawJson } from "../../components/lib/api";
|
|
import useSearchModalStore from "@/store/useSearchModalStore";
|
|
import useStore from "@/store/useStore";
|
|
import { useAlertStore } from "@/shared/lib";
|
|
import { useUserStore } from "@/shared/lib/use-user-store";
|
|
|
|
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
|
|
import GisEditSessionPanel from "./components/gisEdit/GisEditSessionPanel";
|
|
import GisEditToolbar from "./components/gisEdit/GisEditToolbar";
|
|
import GisEditPipeSessionPopup from "./components/gisEdit/GisEditPipeSessionPopup";
|
|
import { useGisEdit } from "./components/gisEdit/useGisEdit";
|
|
import type {
|
|
GisEditPipeBasicInfo,
|
|
GisEditSessionSnapshot,
|
|
} from "./components/gisEdit/gisEditTypes";
|
|
import {
|
|
PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
|
|
type PipeAnalysisDetailRow,
|
|
} from "./components/pipeAnalysis/pipeAnalysisUtils";
|
|
import {
|
|
AGED_PIPE_DEFAULT_FILTERS,
|
|
AGED_PIPE_GUIDE_MIN_ZOOM,
|
|
AGED_PIPE_MIN_ZOOM,
|
|
AGED_PIPE_LAYER_TYPE,
|
|
AGED_PIPE_PAGE_SIZE,
|
|
type AgedPipeFilters,
|
|
type AgedPipeRow,
|
|
buildAgedPipeCqlFilters,
|
|
isAgedPipeLayerNode,
|
|
matchesAgedPipeFilters,
|
|
normalizeAgedPipeFilters,
|
|
toDisplayPipeRow,
|
|
validateElapsedYears,
|
|
} from "./components/agedPipe/agedPipeUtils";
|
|
import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport";
|
|
import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap";
|
|
import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap";
|
|
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
|
|
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
|
|
import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel";
|
|
import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel";
|
|
import { getExmnMapLayer } from "./components/exmn/exmnMapApi";
|
|
import {
|
|
EMPTY_EXMN_MAP_FILTERS,
|
|
EXMN_RESULT_MNHL_LAYER_ID,
|
|
EXMN_RESULT_PPL_LAYER_ID,
|
|
appendExmnResultControlNodes,
|
|
buildExmnMapEntryContext,
|
|
isExmnResultMnhlNode,
|
|
isExmnResultPplNode,
|
|
type ExmnMapFilters,
|
|
type ExmnMapJobType,
|
|
} from "./components/exmn/exmnMapTypes";
|
|
import {
|
|
buildSinkholeDefaultYearRange,
|
|
buildSinkholeFiltersFromYearRange,
|
|
buildSinkholeListQueryString,
|
|
buildSinkholeScope,
|
|
isSinkholeLayerNode,
|
|
isSinkholeRootNode,
|
|
normalizeSinkholeFilters,
|
|
normalizeSinkholeYearRange,
|
|
SINKHOLE_CONTROL_NODE_ID,
|
|
SINKHOLE_DEFAULT_RADIUS,
|
|
SINKHOLE_FILTER_SYNC_MESSAGE,
|
|
SINKHOLE_LAYER_ID,
|
|
SINKHOLE_LAYER_NAME,
|
|
SINKHOLE_LAYER_TYPE,
|
|
SINKHOLE_POPUP_KEY,
|
|
SINKHOLE_ROW_SELECT_MESSAGE,
|
|
SINKHOLE_SELECTED_LAYER_ID,
|
|
SINKHOLE_SYNC_COMPLETED_MESSAGE,
|
|
type SinkholeDetail,
|
|
type SinkholeFilters,
|
|
type SinkholeListItem,
|
|
type SinkholeNearbyPipe,
|
|
type SinkholeSyncSummary,
|
|
type SinkholeYearRange,
|
|
} from "./components/sinkhole/sinkholeTypes";
|
|
import {
|
|
FLOOD_HISTORY_AREA_LAYER_ID,
|
|
FLOOD_HISTORY_DEPTH_LAYER_ID,
|
|
FLOOD_HISTORY_PREVIEW_LAYER_TYPE,
|
|
FLOOD_HISTORY_LAYER_TYPE,
|
|
FLOOD_HISTORY_POPUP_KEY,
|
|
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
|
|
isFldngHstryNode,
|
|
type FldngHstryItem,
|
|
} from "./components/fldngHstry/fldngHstryTypes";
|
|
import {
|
|
SBMRG_DNGR_MAP_POPUP_KEY,
|
|
isSbmrgDngrMapRootNode,
|
|
} from "./components/sbmrgDngrMap/sbmrgDngrMapTypes";
|
|
import {
|
|
FLOOD_DNGR_MAP_LAYER_TYPE,
|
|
FLOOD_DNGR_MAP_POPUP_KEY,
|
|
isFloodDngrMapRootNode,
|
|
} from "./components/floodDngrMap/floodDngrMapTypes";
|
|
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
|
|
import SearchModal from "./searchModal/searchModal";
|
|
import {
|
|
WATER_DEPTH_LAYER_TYPES,
|
|
extractWaterDepthLegendItems,
|
|
} from "./waterDepth/waterDepth";
|
|
import {
|
|
buildWaterRiskStatusLabel,
|
|
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
|
|
type WaterRiskMapView,
|
|
type WaterRiskResultRow,
|
|
type WaterRiskSessionSnapshot,
|
|
} from "./components/waterRisk/waterRiskTypes";
|
|
|
|
const AnalysisPopupMenu = dynamic(
|
|
() => import("./components/analysisMenu/AnalysisPopupMenu"),
|
|
{ ssr: false },
|
|
);
|
|
|
|
const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE";
|
|
const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
|
|
// 노후관로 지도 강조선과 시설물 트리의 선형 범례가 동일하게 사용하는 빨간색이다.
|
|
const AGED_PIPE_HIGHLIGHT_COLOR = "rgba(235, 40, 40, 0.82)";
|
|
const FLDNG_HSTRY_WFS_LAYER_ID = "SN_FLDNG_HSTRY_WFS";
|
|
const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
|
|
const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
|
|
const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
|
|
const SINKHOLE_DEFAULT_POINT_LIMIT = 2000;
|
|
const EXMN_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
|
|
// 지반침하 지도 포인트와 같은 파란 계열의 기존 레이어 트리 심볼 자산을 재사용한다.
|
|
const SINKHOLE_TREE_ICON_NAME = "SN_SINKHOLE_SPOT_BLUE.png";
|
|
const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
|
|
600000, 1300000, 1500000, 2500000,
|
|
];
|
|
|
|
const EXMN_GRADE_COLORS: Record<number, string> = {
|
|
1: "#22c55e",
|
|
2: "#84cc16",
|
|
3: "#facc15",
|
|
4: "#f97316",
|
|
5: "#dc2626",
|
|
};
|
|
const EXMN_RESULT_MANHOLE_ICON_SRC =
|
|
"/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png?v=3";
|
|
|
|
const getExmnGradeColor = (feature: any) => {
|
|
const grade = Number(feature?.get?.("structuralGrade") ?? 0);
|
|
return EXMN_GRADE_COLORS[grade] ?? "#2563eb";
|
|
};
|
|
|
|
const createExmnPipeStyle = (feature: any) =>
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: getExmnGradeColor(feature),
|
|
width: 4,
|
|
}),
|
|
});
|
|
|
|
// 레이어 트리 범례와 지도 피처가 동일한 기존 맨홀 형태의 보라색 심볼을 사용한다.
|
|
const createExmnManholeStyle = () =>
|
|
new Style({
|
|
image: new Icon({
|
|
src: EXMN_RESULT_MANHOLE_ICON_SRC,
|
|
anchor: [0.5, 0.5],
|
|
anchorXUnits: "fraction",
|
|
anchorYUnits: "fraction",
|
|
}),
|
|
});
|
|
|
|
// 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다.
|
|
const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction";
|
|
const FLOW_DIRECTION_LAYER_NAME = "유하방향";
|
|
const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
|
|
const FLOW_DIRECTION_SRS = "EPSG:5179";
|
|
const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13;
|
|
const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)";
|
|
const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)";
|
|
const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35;
|
|
const GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO = 0.15;
|
|
const GIS_EDIT_PREVIEW_LAYER_ID = "SN_GIS_EDIT_PREVIEW";
|
|
const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES";
|
|
const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA";
|
|
const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE";
|
|
const WATER_RISK_MANHOLE_LAYER_ID = "SN_WATER_RISK_MANHOLE";
|
|
const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [
|
|
100, 380, 120, 120,
|
|
];
|
|
const RIGHT_PANEL_TAB_LIMIT = 5;
|
|
// Only pipes opened from sinkhole details share one slot within the parent-return flow.
|
|
const RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID =
|
|
"feature:tb_ms_swage_ppl:sinkhole-singleton";
|
|
|
|
type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
|
|
type RightPanelTabCategory =
|
|
| "sinkhole"
|
|
| "pipe"
|
|
| "exmnSurvey"
|
|
| "treatmentFacility"
|
|
| "treatmentZone"
|
|
| "defaultFeature"
|
|
| "gisEdit";
|
|
|
|
type RightPanelTab = {
|
|
id: string;
|
|
kind: RightPanelTabKind;
|
|
category: RightPanelTabCategory;
|
|
displayLabel: string;
|
|
surveyJobType?: ExmnMapJobType;
|
|
parentTabId?: string | null;
|
|
featureData: any;
|
|
sinkholeDetail?: SinkholeDetail | null;
|
|
nearbyPipes?: SinkholeNearbyPipe[];
|
|
gisEditChangeId?: string | null;
|
|
lastActiveAt: number;
|
|
};
|
|
|
|
type RightPanelTabStyle = {
|
|
active: string;
|
|
inactive: string;
|
|
};
|
|
|
|
type RightPanelBookmarkTabsProps = {
|
|
tabs: RightPanelTab[];
|
|
activeTabId: string | null;
|
|
onActivate: (tabId: string) => void;
|
|
onBack: (tabId: string) => void;
|
|
};
|
|
|
|
// 지도 객체 유형과 조사구분을 식별하는 범례 의미색이므로 테마와 무관한 정적 class를 유지한다.
|
|
const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record<
|
|
RightPanelTabCategory,
|
|
RightPanelTabStyle
|
|
> = {
|
|
sinkhole: {
|
|
active: "border-[#d97706] bg-[#d97706] text-white",
|
|
inactive: "border-[#fdba74] bg-[#fff7ed] text-[#9a3412] hover:bg-[#ffedd5]",
|
|
},
|
|
pipe: {
|
|
active: "border-[#2563eb] bg-[#2563eb] text-white",
|
|
inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]",
|
|
},
|
|
exmnSurvey: {
|
|
active: "border-[#0f766e] bg-[#0f766e] text-white",
|
|
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
|
|
},
|
|
treatmentFacility: {
|
|
active: "border-[#15803d] bg-[#15803d] text-white",
|
|
inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]",
|
|
},
|
|
treatmentZone: {
|
|
active: "border-[#7e22ce] bg-[#7e22ce] text-white",
|
|
inactive: "border-[#d8b4fe] bg-[#faf5ff] text-[#6b21a8] hover:bg-[#f3e8ff]",
|
|
},
|
|
gisEdit: {
|
|
active: "border-[#0f766e] bg-[#0f766e] text-white",
|
|
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
|
|
},
|
|
defaultFeature: {
|
|
active: "border-[#475569] bg-[#475569] text-white",
|
|
inactive: "border-[#cbd5e1] bg-[#f8fafc] text-[#334155] hover:bg-[#f1f5f9]",
|
|
},
|
|
};
|
|
|
|
const RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE: Record<
|
|
ExmnMapJobType,
|
|
RightPanelTabStyle
|
|
> = {
|
|
PPL: {
|
|
active: "border-[#eab308] bg-[#facc15] text-[#713f12]",
|
|
inactive: "border-[#fde68a] bg-[#fefce8] text-[#854d0e] hover:bg-[#fef9c3]",
|
|
},
|
|
MNHL: {
|
|
active: "border-[#8b5cf6] bg-[#8b5cf6] text-white",
|
|
inactive: "border-[#c4b5fd] bg-[#f5f3ff] text-[#5b21b6] hover:bg-[#ede9fe]",
|
|
},
|
|
};
|
|
|
|
// 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다.
|
|
const findRightPanelTabOverflowCandidate = (
|
|
tabs: RightPanelTab[],
|
|
nextTabId: string,
|
|
) => {
|
|
return tabs.find((tab) => tab.id !== nextTabId) ?? null;
|
|
};
|
|
|
|
// 부모 지반침하가 제거되면 그 부모에서 열린 연계 관로도 같은 탭 그룹으로 제거한다.
|
|
const removeRightPanelTabGroup = (
|
|
tabs: RightPanelTab[],
|
|
removedTabId: string,
|
|
) =>
|
|
tabs.filter(
|
|
(tab) => tab.id !== removedTabId && tab.parentTabId !== removedTabId,
|
|
);
|
|
|
|
// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다.
|
|
const RightPanelBookmarkTabs = ({
|
|
tabs,
|
|
activeTabId,
|
|
onActivate,
|
|
onBack,
|
|
}: RightPanelBookmarkTabsProps) => {
|
|
if (tabs.length < 2) return null;
|
|
|
|
return (
|
|
<div className="pointer-events-auto absolute right-[350px] top-[92px] z-[70] flex w-[112px] flex-col items-end gap-2">
|
|
{tabs.map((tab) => {
|
|
const isActive = tab.id === activeTabId;
|
|
const style =
|
|
tab.category === "exmnSurvey" && tab.surveyJobType
|
|
? RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE[tab.surveyJobType]
|
|
: RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category];
|
|
const parentTab = tab.parentTabId
|
|
? tabs.find((candidate) => candidate.id === tab.parentTabId)
|
|
: null;
|
|
|
|
return (
|
|
<div
|
|
key={tab.id}
|
|
className={[
|
|
"flex min-h-[42px] w-[112px] items-center border border-r-0 px-1 py-1 shadow-sm transition-colors",
|
|
isActive ? style.active : style.inactive,
|
|
].join(" ")}
|
|
>
|
|
{tab.category === "pipe" && parentTab ? (
|
|
<button
|
|
type="button"
|
|
className="mr-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-sm font-bold transition-colors hover:bg-accent focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
|
title={parentTab.displayLabel + "로 돌아가기"}
|
|
aria-label={parentTab.displayLabel + "로 돌아가기"}
|
|
onClick={() => onBack(parentTab.id)}
|
|
>
|
|
←
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="inline-flex min-w-0 flex-1 items-center justify-center self-stretch px-1 text-center text-[0.82rem] font-semibold leading-tight"
|
|
title={tab.displayLabel}
|
|
aria-pressed={isActive}
|
|
onClick={() => onActivate(tab.id)}
|
|
>
|
|
<span className="block max-h-[2.5rem] overflow-hidden break-all">
|
|
{tab.displayLabel}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type FlowDirectionUsageStyle = {
|
|
color: string;
|
|
lineColor: string;
|
|
};
|
|
|
|
type FlowDirectionMode = "PIPE_START_END" | "PIPE_DEPTH";
|
|
|
|
// /map은 관로 깊이 기준, /map2는 관로 원천 geometry 시점->종점 기준만 유지한다. 두 화면 모두 BBOX 조회만 사용한다.
|
|
const getFlowDirectionModeByPathname = (
|
|
pathname: string | null,
|
|
): FlowDirectionMode => {
|
|
const normalizedPathname = String(pathname ?? "").replace(/\/$/, "");
|
|
if (normalizedPathname.endsWith("/map2")) return "PIPE_START_END";
|
|
return "PIPE_DEPTH";
|
|
};
|
|
|
|
const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [
|
|
120, 120, 120, 120,
|
|
];
|
|
const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [
|
|
50, 50, 50, 50,
|
|
];
|
|
const SWAGE_PRCS_FCLT_LAYER_TYPE = "tb_il_swage_prcs_fclt";
|
|
const RAIN_OBSERVATION_LAYER_TYPE = "tb_ms_rain_obsvtr_pstn_info";
|
|
|
|
type SwagePrcsFcltLayerMode = "all" | "gte500" | "lt500" | "none";
|
|
|
|
// 레이어 설정 응답이 lyrTyNm/lyrTypeNm/lyr_type_nm처럼 혼재되어 내려오는 경우를 단일 기준으로 정규화한다.
|
|
const getLayerTypeName = (layerInfo: any) =>
|
|
String(
|
|
layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "",
|
|
).trim();
|
|
|
|
const getLayerFilterSql = (layerInfo: any) =>
|
|
String(
|
|
layerInfo?.filtrSql ??
|
|
layerInfo?.filtr_sql ??
|
|
layerInfo?.flterSql ??
|
|
layerInfo?.filterSql ??
|
|
layerInfo?.filter_sql ??
|
|
"",
|
|
)
|
|
.replace(/\s+/g, "")
|
|
.toLowerCase();
|
|
|
|
const getSwagePrcsFcltCapacityKind = (
|
|
layerInfo: any,
|
|
): Exclude<SwagePrcsFcltLayerMode, "all" | "none"> | null => {
|
|
if (getLayerTypeName(layerInfo) !== SWAGE_PRCS_FCLT_LAYER_TYPE) return null;
|
|
|
|
const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").replace(
|
|
/\s+/g,
|
|
"",
|
|
);
|
|
const filterSql = getLayerFilterSql(layerInfo);
|
|
|
|
if (
|
|
filterSql.includes("fclt_cpct>=500") ||
|
|
layerName.includes("500이상") ||
|
|
layerName.includes("500톤이상")
|
|
)
|
|
return "gte500";
|
|
if (
|
|
filterSql.includes("fclt_cpct<500") ||
|
|
layerName.includes("500미만") ||
|
|
layerName.includes("500톤미만")
|
|
)
|
|
return "lt500";
|
|
|
|
return null;
|
|
};
|
|
|
|
const getSwagePrcsFcltLayerMode = (
|
|
checkedNodesData: any[],
|
|
): SwagePrcsFcltLayerMode => {
|
|
const checkedKinds = new Set(
|
|
checkedNodesData
|
|
.map((node) => getSwagePrcsFcltCapacityKind(node?.data ?? node))
|
|
.filter(Boolean),
|
|
);
|
|
const hasGte500 = checkedKinds.has("gte500");
|
|
const hasLt500 = checkedKinds.has("lt500");
|
|
|
|
if (hasGte500 && hasLt500) return "all";
|
|
if (hasGte500) return "gte500";
|
|
if (hasLt500) return "lt500";
|
|
return "none";
|
|
};
|
|
|
|
// 유하방향 노드는 타입명 또는 표시명으로 식별한다. DB DML 반영 전후 응답 차이를 모두 흡수하기 위한 판별 함수이다.
|
|
const isFlowDirectionLayerInfo = (layerInfo: any) => {
|
|
const layerTypeName = getLayerTypeName(layerInfo);
|
|
const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim();
|
|
|
|
return (
|
|
layerTypeName === FLOW_DIRECTION_LAYER_TYPE ||
|
|
layerName === FLOW_DIRECTION_LAYER_NAME
|
|
);
|
|
};
|
|
|
|
// 유하방향 노드를 일반 레이어 토글 대상에서 제외하고, 앱 전용 API vector layer로 처리하도록 속성을 보정한다.
|
|
const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => {
|
|
if (!Array.isArray(data)) return data;
|
|
|
|
return data.map((item) => {
|
|
const children = Array.isArray(item.children)
|
|
? normalizeFlowDirectionLayerInfos(item.children)
|
|
: item.children;
|
|
|
|
if (!isFlowDirectionLayerInfo(item)) {
|
|
return { ...item, children };
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
children,
|
|
lyrTyNm:
|
|
item.lyrTyNm ||
|
|
item.lyrTypeNm ||
|
|
item.lyr_type_nm ||
|
|
FLOW_DIRECTION_LAYER_TYPE,
|
|
lyrNm: item.lyrNm || FLOW_DIRECTION_LAYER_NAME,
|
|
gobjTy: item.gobjTy || "line",
|
|
srvcNm: "api",
|
|
cmncNm: "bbox",
|
|
actlLyrYn: "N",
|
|
clickPsblYn: "N",
|
|
initEprssYn: "N",
|
|
minViewSize:
|
|
item.minViewSize ??
|
|
item.minZoom ??
|
|
String(FLOW_DIRECTION_DEFAULT_MIN_ZOOM),
|
|
};
|
|
});
|
|
};
|
|
|
|
// 강우관측소는 위치와 라벨만 제공하며, 과거 DB 설정값이 Y여도 일반 속성 패널 선택 대상에서는 제외한다.
|
|
const normalizeRainObservationLayerInteraction = (data: any[]): any[] => {
|
|
if (!Array.isArray(data)) return data;
|
|
|
|
return data.map((item) => {
|
|
const children = Array.isArray(item.children)
|
|
? normalizeRainObservationLayerInteraction(item.children)
|
|
: item.children;
|
|
|
|
return getLayerTypeName(item) === RAIN_OBSERVATION_LAYER_TYPE
|
|
? { ...item, children, clickPsblYn: "N" }
|
|
: { ...item, children };
|
|
});
|
|
};
|
|
|
|
// 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다.
|
|
const getFiniteNumber = (value: unknown) => {
|
|
if (value === null || value === undefined || String(value).trim() === "")
|
|
return null;
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
};
|
|
|
|
const getFlowDirectionMinZoom = (layerInfo: any) => {
|
|
return (
|
|
getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom) ??
|
|
FLOW_DIRECTION_DEFAULT_MIN_ZOOM
|
|
);
|
|
};
|
|
|
|
// 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다.
|
|
const getFlowDirectionZIndex = (layerInfo: any) => {
|
|
return getFiniteNumber(layerInfo?.drawOrdr) ?? 9998;
|
|
};
|
|
|
|
// BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다.
|
|
const getFeatureCollection = (payload: any) => {
|
|
if (payload?.type === "FeatureCollection") return payload;
|
|
if (payload?.data?.type === "FeatureCollection") return payload.data;
|
|
return { type: "FeatureCollection", features: [] };
|
|
};
|
|
|
|
// 지반침하 목록 API가 정상일 때는 x/y를 바로 Point feature로 바꿔 geometry 전용 API 장애를 우회할 수 있다.
|
|
const buildSinkholeFeatureCollectionFromItems = (
|
|
items: SinkholeListItem[],
|
|
) => ({
|
|
type: "FeatureCollection",
|
|
features: items
|
|
.map((item) => {
|
|
const x = Number(item?.x);
|
|
const y = Number(item?.y);
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: "Feature",
|
|
id: `${SINKHOLE_LAYER_TYPE}.${String(item?.acdntNo ?? "").trim()}`,
|
|
geometry: {
|
|
type: "Point",
|
|
coordinates: [x, y],
|
|
},
|
|
properties: {
|
|
...item,
|
|
lyrTypeNm: SINKHOLE_LAYER_TYPE,
|
|
x,
|
|
y,
|
|
},
|
|
};
|
|
})
|
|
.filter(Boolean),
|
|
});
|
|
|
|
const SINKHOLE_POINT_PALETTE = {
|
|
orange: {
|
|
fill: "rgba(214, 102, 31, 0.88)",
|
|
fillSelected: "rgba(255, 138, 0, 0.92)",
|
|
stroke: "rgba(255, 255, 255, 0.95)",
|
|
strokeSelected: "rgba(120, 53, 15, 0.98)",
|
|
radiusStroke: "rgba(214, 102, 31, 0.75)",
|
|
radiusFill: "rgba(251, 146, 60, 0.12)",
|
|
},
|
|
blue: {
|
|
fill: "rgba(37, 99, 235, 0.82)",
|
|
fillSelected: "rgba(37, 99, 235, 0.9)",
|
|
stroke: "rgba(255, 255, 255, 0.95)",
|
|
strokeSelected: "rgba(30, 64, 175, 0.98)",
|
|
radiusStroke: "rgba(37, 99, 235, 0.7)",
|
|
radiusFill: "rgba(96, 165, 250, 0.12)",
|
|
},
|
|
} as const;
|
|
|
|
type SinkholePointVariant = keyof typeof SINKHOLE_POINT_PALETTE;
|
|
|
|
const resolveSinkholePointVariant = (
|
|
_ocrnYmd?: string | null,
|
|
): SinkholePointVariant => "blue";
|
|
|
|
// 현재 지도 화면 BBOX 주변을 함께 조회해 이동 직후 가장자리 유하방향이 비어 보이는 현상을 줄인다.
|
|
const expandFlowDirectionExtent = (extent: number[]) => {
|
|
const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;
|
|
const width = Math.max(0, maxX - minX);
|
|
const height = Math.max(0, maxY - minY);
|
|
const bufferX = width * FLOW_DIRECTION_BBOX_BUFFER_RATIO;
|
|
const bufferY = height * FLOW_DIRECTION_BBOX_BUFFER_RATIO;
|
|
return [minX - bufferX, minY - bufferY, maxX + bufferX, maxY + bufferY];
|
|
};
|
|
|
|
// API 요청과 디버깅 출력에 같은 BBOX 문자열 포맷을 사용해 FE 계산값과 네트워크 파라미터를 쉽게 대조한다.
|
|
const formatFlowDirectionBbox = (extent: number[]) =>
|
|
extent.map((value: number) => value.toFixed(3)).join(",");
|
|
|
|
// 유하방향 캐시 중복 제거 키이다. BE 응답은 같은 관로 id가 여러 geometry로 반복될 수 있어 geometry까지 포함한다.
|
|
const getFlowDirectionFeatureCacheKey = (feature: any) => {
|
|
const featureId = feature.getId?.();
|
|
const fcltMngNo = feature.get("fcltMngNo") ?? feature.get("fclt_mng_no");
|
|
const usageCode = feature.get("usgCd") ?? feature.get("usg_cd") ?? "";
|
|
const coordinates = feature.getGeometry?.()?.getCoordinates?.();
|
|
const geometryKey = JSON.stringify(coordinates);
|
|
|
|
if (featureId) return `id:${String(featureId)}:${geometryKey}`;
|
|
if (fcltMngNo) return `fclt:${String(fcltMngNo).trim()}:${geometryKey}`;
|
|
return `geom:${String(usageCode).trim()}:${geometryKey}`;
|
|
};
|
|
|
|
const getGeometryExtentCenterPoint = (geometry: any) => {
|
|
const extent = geometry?.getExtent?.();
|
|
if (!extent || extent.length < 4) return null;
|
|
|
|
const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;
|
|
return new Point([(minX + maxX) / 2, (minY + maxY) / 2]);
|
|
};
|
|
|
|
// MultiPolygon/MultiLineString preview 라벨이 각 조각마다 반복되지 않도록 대표 지점 1곳에만 텍스트를 배치한다.
|
|
const getGisEditPreviewLabelGeometry = (geometry: any) => {
|
|
const geometryType = geometry?.getType?.();
|
|
if (!geometryType) return null;
|
|
|
|
if (geometryType === "Point") {
|
|
return geometry;
|
|
}
|
|
|
|
if (geometryType === "Polygon") {
|
|
return (
|
|
geometry.getInteriorPoint?.() ?? getGeometryExtentCenterPoint(geometry)
|
|
);
|
|
}
|
|
|
|
if (geometryType === "MultiPolygon") {
|
|
const polygons = geometry.getPolygons?.() ?? [];
|
|
if (polygons.length > 0) {
|
|
const representativePolygon = polygons.reduce(
|
|
(largest: any, current: any) =>
|
|
(current?.getArea?.() ?? 0) > (largest?.getArea?.() ?? 0)
|
|
? current
|
|
: largest,
|
|
polygons[0],
|
|
);
|
|
return (
|
|
representativePolygon?.getInteriorPoint?.() ??
|
|
getGeometryExtentCenterPoint(representativePolygon)
|
|
);
|
|
}
|
|
return getGeometryExtentCenterPoint(geometry);
|
|
}
|
|
|
|
return getGeometryExtentCenterPoint(geometry);
|
|
};
|
|
|
|
const createGisEditPreviewStyle = (feature: any) => {
|
|
const editType = String(feature.get("editType") ?? "");
|
|
const geometry = feature.getGeometry?.();
|
|
const geometryType = geometry?.getType?.();
|
|
const previewLabel = String(feature.get("previewLabel") ?? "").trim();
|
|
const isInteractionTarget = Boolean(feature.get("isInteractionTarget"));
|
|
const isDragging = Boolean(feature.get("isDragging"));
|
|
const layerPointColor = String(feature.get("layerPointColor") ?? "").trim();
|
|
const layerLineColor = String(feature.get("layerLineColor") ?? "").trim();
|
|
const layerFillColor = String(feature.get("layerFillColor") ?? "").trim();
|
|
const pointColor =
|
|
editType === "DELETE"
|
|
? "rgba(220, 38, 38, 0.85)"
|
|
: editType === "VERTEX_EDIT"
|
|
? "rgba(245, 158, 11, 0.92)"
|
|
: editType === "MOVE"
|
|
? layerPointColor || "rgba(37, 99, 235, 0.92)"
|
|
: editType === "ADD"
|
|
? "rgba(15, 23, 42, 0.96)"
|
|
: layerPointColor || "rgba(22, 163, 74, 0.92)";
|
|
const lineColor =
|
|
editType === "DELETE"
|
|
? "rgba(220, 38, 38, 0.9)"
|
|
: editType === "VERTEX_EDIT"
|
|
? "rgba(245, 158, 11, 0.95)"
|
|
: editType === "MOVE"
|
|
? layerLineColor || "rgba(37, 99, 235, 0.95)"
|
|
: editType === "ADD"
|
|
? "rgba(15, 23, 42, 0.96)"
|
|
: layerLineColor || "rgba(22, 163, 74, 0.95)";
|
|
const fillColor =
|
|
editType === "DELETE"
|
|
? "rgba(220, 38, 38, 0.18)"
|
|
: editType === "VERTEX_EDIT"
|
|
? "rgba(245, 158, 11, 0.2)"
|
|
: editType === "MOVE"
|
|
? layerFillColor || "rgba(37, 99, 235, 0.18)"
|
|
: editType === "ADD"
|
|
? "rgba(15, 23, 42, 0.2)"
|
|
: layerFillColor || "rgba(22, 163, 74, 0.18)";
|
|
const highlightColor = isDragging
|
|
? "rgba(14, 116, 144, 0.98)"
|
|
: isInteractionTarget
|
|
? "rgba(15, 23, 42, 0.9)"
|
|
: lineColor;
|
|
const emphasisWidth = isDragging
|
|
? 7
|
|
: isInteractionTarget
|
|
? 6
|
|
: editType === "ADD"
|
|
? 6
|
|
: 4;
|
|
const textStyle = previewLabel
|
|
? new Text({
|
|
text: previewLabel,
|
|
font: "600 12px sans-serif",
|
|
fill: new Fill({ color: "#ffffff" }),
|
|
backgroundFill: new Fill({ color: highlightColor }),
|
|
padding: [3, 6, 3, 6],
|
|
offsetY: geometryType === "Point" ? -18 : -12,
|
|
overflow: true,
|
|
})
|
|
: undefined;
|
|
const labelGeometry = previewLabel
|
|
? getGisEditPreviewLabelGeometry(geometry)
|
|
: null;
|
|
|
|
if (geometryType === "Point") {
|
|
return new Style({
|
|
image: new CircleStyle({
|
|
radius: isInteractionTarget ? 10 : 8,
|
|
fill: new Fill({ color: pointColor }),
|
|
stroke: new Stroke({
|
|
color: isDragging ? "rgba(255,255,255,1)" : "rgba(255,255,255,0.95)",
|
|
width: isInteractionTarget ? 3 : 2,
|
|
}),
|
|
}),
|
|
text: textStyle,
|
|
zIndex: 10001,
|
|
});
|
|
}
|
|
|
|
if (geometryType === "Polygon") {
|
|
const polygonStyles: Style[] = [
|
|
new Style({
|
|
fill: new Fill({
|
|
color:
|
|
isInteractionTarget && editType !== "DELETE"
|
|
? fillColor.replace(/0\.\d+\)$/, "0.28)")
|
|
: fillColor,
|
|
}),
|
|
stroke: new Stroke({
|
|
color: highlightColor,
|
|
width: emphasisWidth,
|
|
lineDash: editType === "DELETE" ? [8, 6] : undefined,
|
|
}),
|
|
zIndex: 10001,
|
|
}),
|
|
];
|
|
|
|
if (textStyle && labelGeometry) {
|
|
polygonStyles.push(
|
|
new Style({
|
|
geometry: labelGeometry,
|
|
text: textStyle,
|
|
zIndex: 10002,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return polygonStyles;
|
|
}
|
|
|
|
const lineStyles: Style[] =
|
|
editType === "ADD"
|
|
? [
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: "rgba(255, 255, 255, 0.98)",
|
|
width: emphasisWidth + 4,
|
|
}),
|
|
zIndex: 10000,
|
|
}),
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: highlightColor,
|
|
width: emphasisWidth,
|
|
}),
|
|
zIndex: 10003,
|
|
}),
|
|
]
|
|
: [
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: highlightColor,
|
|
width: emphasisWidth,
|
|
lineDash: editType === "DELETE" ? [10, 6] : undefined,
|
|
}),
|
|
zIndex: 10001,
|
|
}),
|
|
];
|
|
|
|
if (textStyle && labelGeometry) {
|
|
lineStyles.push(
|
|
new Style({
|
|
geometry: labelGeometry,
|
|
text: textStyle,
|
|
zIndex: 10002,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return lineStyles;
|
|
};
|
|
|
|
const GIS_EDIT_VERTEX_HANDLE_STYLE = new Style({
|
|
image: new CircleStyle({
|
|
radius: 10,
|
|
fill: new Fill({ color: "rgba(245, 158, 11, 0.98)" }),
|
|
stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }),
|
|
}),
|
|
zIndex: 10005,
|
|
});
|
|
|
|
const GIS_EDIT_VERTEX_MIDPOINT_STYLE = new Style({
|
|
image: new CircleStyle({
|
|
radius: 6,
|
|
fill: new Fill({ color: "rgba(255, 255, 255, 0.98)" }),
|
|
stroke: new Stroke({ color: "rgba(245, 158, 11, 0.98)", width: 2.5 }),
|
|
}),
|
|
zIndex: 10004,
|
|
});
|
|
|
|
const GIS_EDIT_VERTEX_DRAGGING_STYLE = new Style({
|
|
image: new CircleStyle({
|
|
radius: 11,
|
|
fill: new Fill({ color: "rgba(59, 130, 246, 0.95)" }),
|
|
stroke: new Stroke({ color: "rgba(255,255,255,0.98)", width: 3 }),
|
|
}),
|
|
zIndex: 10006,
|
|
});
|
|
|
|
const GIS_EDIT_VERTEX_SELECTED_STYLE = new Style({
|
|
image: new CircleStyle({
|
|
radius: 11,
|
|
fill: new Fill({ color: "rgba(217, 119, 6, 0.98)" }),
|
|
stroke: new Stroke({ color: "rgba(15, 23, 42, 0.95)", width: 3 }),
|
|
}),
|
|
zIndex: 10007,
|
|
});
|
|
|
|
const getGisEditVertexHandleStyle = (
|
|
kind: VertexHandleMeta["kind"],
|
|
isSelected = false,
|
|
) => {
|
|
if (isSelected && kind === "vertex") {
|
|
return GIS_EDIT_VERTEX_SELECTED_STYLE;
|
|
}
|
|
return kind === "midpoint"
|
|
? GIS_EDIT_VERTEX_MIDPOINT_STYLE
|
|
: GIS_EDIT_VERTEX_HANDLE_STYLE;
|
|
};
|
|
|
|
const expandGisEditPipeSnapExtent = (extent: number[]) => {
|
|
const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;
|
|
const width = Math.max(0, maxX - minX);
|
|
const height = Math.max(0, maxY - minY);
|
|
const bufferX = width * GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO;
|
|
const bufferY = height * GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO;
|
|
return [minX - bufferX, minY - bufferY, maxX + bufferX, maxY + bufferY];
|
|
};
|
|
|
|
const geometryObjectToKey = (geometry: Record<string, unknown> | null) =>
|
|
geometry ? JSON.stringify(geometry) : "";
|
|
|
|
const buildWaterRiskStatusFillColor = (riskStatus: "DANGER" | "NORMAL") =>
|
|
riskStatus === "DANGER"
|
|
? "rgba(198, 40, 40, 0.24)"
|
|
: "rgba(21, 101, 192, 0.2)";
|
|
|
|
const buildWaterRiskStatusStrokeColor = (riskStatus: "DANGER" | "NORMAL") =>
|
|
riskStatus === "DANGER"
|
|
? "rgba(198, 40, 40, 0.92)"
|
|
: "rgba(21, 101, 192, 0.92)";
|
|
|
|
/** capacity를 첨부 범례의 다섯 고정 구간색으로 변환한다. */
|
|
const buildWaterRiskCapacityColor = (capacityRatio: number) => {
|
|
if (capacityRatio <= 0.25) return "#0000ff";
|
|
if (capacityRatio <= 0.5) return "#00d9ff";
|
|
if (capacityRatio <= 0.75) return "#00d95f";
|
|
if (capacityRatio < 1) return "#ffe100";
|
|
return "#ff1c15";
|
|
};
|
|
|
|
const createWaterRiskAreaStyle = (feature: any) => {
|
|
const areaName = String(feature.get("areaName") ?? "");
|
|
const riskStatus = String(feature.get("riskStatus") ?? "NORMAL") as
|
|
| "DANGER"
|
|
| "NORMAL";
|
|
return new Style({
|
|
fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }),
|
|
stroke: new Stroke({
|
|
color: buildWaterRiskStatusStrokeColor(riskStatus),
|
|
width: 3,
|
|
}),
|
|
text: new Text({
|
|
text: `${areaName}\n${buildWaterRiskStatusLabel(riskStatus)}`,
|
|
font: "700 13px sans-serif",
|
|
fill: new Fill({ color: "#ffffff" }),
|
|
backgroundFill: new Fill({
|
|
color: buildWaterRiskStatusStrokeColor(riskStatus),
|
|
}),
|
|
padding: [5, 8, 5, 8],
|
|
overflow: true,
|
|
}),
|
|
zIndex: 9996,
|
|
});
|
|
};
|
|
|
|
const createWaterRiskPipeStyle = (feature: any) => {
|
|
const capacityRatio = Number(feature.get("capacityRatio") ?? 0);
|
|
const capacityColor = buildWaterRiskCapacityColor(capacityRatio);
|
|
return [
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: "rgba(255,255,255,0.98)",
|
|
width: 9,
|
|
lineCap: "round",
|
|
lineJoin: "round",
|
|
}),
|
|
zIndex: 9997,
|
|
}),
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: capacityColor,
|
|
width: 4,
|
|
lineCap: "round",
|
|
lineJoin: "round",
|
|
}),
|
|
zIndex: 9998,
|
|
}),
|
|
];
|
|
};
|
|
|
|
/** 맨홀 Flooding이 0이면 파랑, 양수이면 빨강으로 표시한다. */
|
|
const createWaterRiskManholeStyle = (feature: any) => {
|
|
const floodingFlow = Number(feature.get("floodingFlow") ?? 0);
|
|
const isFlooding = floodingFlow > 0;
|
|
return new Style({
|
|
image: new CircleStyle({
|
|
radius: isFlooding ? 7 : 4,
|
|
fill: new Fill({ color: isFlooding ? "#ff1c15" : "#1677ff" }),
|
|
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
|
|
}),
|
|
zIndex: 9999,
|
|
});
|
|
};
|
|
|
|
const getSelectedFeatureLayerTypeName = (data: any) => {
|
|
const previewLayerId = String(data?.get?.("layerId") ?? "").trim();
|
|
if (String(data?.get?.("changeId") ?? "").trim() && previewLayerId) {
|
|
return previewLayerId;
|
|
}
|
|
|
|
if (typeof data?.getId === "function") {
|
|
return String(data.getId() ?? "").split(".")[0];
|
|
}
|
|
|
|
if (typeof data?.id_ === "string") {
|
|
return String(data.id_).split(".")[0];
|
|
}
|
|
|
|
return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim();
|
|
};
|
|
|
|
const getRightPanelFeatureIdentifier = (data: any) => {
|
|
const rawId =
|
|
data?.get?.("changeId") ??
|
|
data?.get?.("fid") ??
|
|
data?.values_?.fid ??
|
|
data?.fid ??
|
|
data?.getId?.() ??
|
|
data?.id_ ??
|
|
data?.fcltMngNo ??
|
|
data?.values_?.fclt_mng_no ??
|
|
data?.swtrmtFcltCd ??
|
|
data?.values_?.swtrmt_fclt_cd ??
|
|
"";
|
|
|
|
return String(rawId).trim();
|
|
};
|
|
|
|
const getRightPanelFeatureTitle = (data: any) => {
|
|
const layerTypeName = getSelectedFeatureLayerTypeName(data);
|
|
|
|
switch (layerTypeName) {
|
|
case "tb_ms_swage_ppl":
|
|
return "하수관로";
|
|
case "tb_ms_swage_mhl":
|
|
return "하수맨홀";
|
|
case "tb_ms_swage_pmstn":
|
|
return "펌프장";
|
|
case "tb_ms_stofcbr":
|
|
return "우수토실";
|
|
case "tb_il_swage_prcs_fclt":
|
|
return "처리시설";
|
|
case "tb_ms_swage_prcs_zone":
|
|
return "처리구역";
|
|
case "tb_ms_fldng_wtlv_map":
|
|
return "침수흔적도";
|
|
case "om_dt_untrt_swg_rain":
|
|
return "중점우수";
|
|
default:
|
|
return (
|
|
String(data?.lyrNm ?? data?.values_?.lyrNm ?? "상세").trim() || "상세"
|
|
);
|
|
}
|
|
};
|
|
|
|
// OpenLayers Feature와 plain object 양쪽에서 탭 표시값 후보를 같은 순서로 읽는다.
|
|
const getRightPanelFeatureProperty = (data: any, keys: string[]) => {
|
|
for (const key of keys) {
|
|
const value = data?.get?.(key) ?? data?.values_?.[key] ?? data?.[key];
|
|
const normalizedValue = String(value ?? "").trim();
|
|
if (normalizedValue) return normalizedValue;
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const getRightPanelTabCategory = (data: any): RightPanelTabCategory => {
|
|
const surveyOverlayType = getRightPanelFeatureProperty(data, [
|
|
"surveyOverlayType",
|
|
]);
|
|
if (surveyOverlayType === "EXMN_RESULT") return "exmnSurvey";
|
|
const layerTypeName = getSelectedFeatureLayerTypeName(data);
|
|
if (layerTypeName === "tb_ms_swage_ppl") return "pipe";
|
|
if (layerTypeName === "tb_il_swage_prcs_fclt") return "treatmentFacility";
|
|
if (layerTypeName === "tb_ms_swage_prcs_zone") return "treatmentZone";
|
|
return "defaultFeature";
|
|
};
|
|
|
|
const getRightPanelFeatureDisplayLabel = (
|
|
data: any,
|
|
category: RightPanelTabCategory,
|
|
) => {
|
|
if (category === "exmnSurvey") {
|
|
const managementNo =
|
|
getRightPanelFeatureProperty(data, [
|
|
"gisManagementNo",
|
|
"fclt_mng_no",
|
|
"fcltMngNo",
|
|
]) || "-";
|
|
const jobType = getRightPanelFeatureProperty(data, ["jobType"]);
|
|
const surveyLabel =
|
|
jobType === "PPL"
|
|
? "관로조사"
|
|
: jobType === "MNHL"
|
|
? "맨홀조사"
|
|
: "조사관리";
|
|
return `${surveyLabel}(${managementNo})`;
|
|
}
|
|
|
|
if (category === "pipe") {
|
|
const managementNo =
|
|
getRightPanelFeatureProperty(data, [
|
|
"fclt_mng_no",
|
|
"fcltMngNo",
|
|
"pipeId",
|
|
"fid",
|
|
]) ||
|
|
getRightPanelFeatureIdentifier(data) ||
|
|
"-";
|
|
return `관로(${managementNo})`;
|
|
}
|
|
|
|
if (category === "treatmentFacility") {
|
|
const facilityName =
|
|
getRightPanelFeatureProperty(data, ["swtrmt_fclt_nm", "swtrmtFcltNm"]) ||
|
|
getRightPanelFeatureProperty(data, ["swtrmt_fclt_cd", "swtrmtFcltCd"]) ||
|
|
"-";
|
|
return `처리시설(${facilityName})`;
|
|
}
|
|
|
|
if (category === "treatmentZone") {
|
|
const zoneName =
|
|
getRightPanelFeatureProperty(data, ["swtrmt_zone_nm", "swtrmtZoneNm"]) ||
|
|
getRightPanelFeatureProperty(data, [
|
|
"swtrmt_zone_sn",
|
|
"swtrmtZoneSn",
|
|
"swtrmt_fclt_cd",
|
|
"swtrmtFcltCd",
|
|
]) ||
|
|
"-";
|
|
return `처리구역(${zoneName})`;
|
|
}
|
|
|
|
return getRightPanelFeatureTitle(data);
|
|
};
|
|
|
|
const createFldngHstryAreaStyle = (label: string) =>
|
|
new Style({
|
|
fill: new Fill({
|
|
color: "rgba(30, 136, 229, 0.16)",
|
|
}),
|
|
stroke: new Stroke({
|
|
color: "rgba(30, 136, 229, 0.95)",
|
|
width: 3,
|
|
}),
|
|
text: new Text({
|
|
text: label,
|
|
font: "700 12px sans-serif",
|
|
fill: new Fill({ color: "#ffffff" }),
|
|
backgroundFill: new Fill({ color: "rgba(15, 23, 42, 0.88)" }),
|
|
padding: [4, 7, 4, 7],
|
|
overflow: true,
|
|
}),
|
|
zIndex: 9994,
|
|
});
|
|
|
|
const createFldngHstryDepthStyle = (label: string, color: string) =>
|
|
new Style({
|
|
fill: new Fill({
|
|
color,
|
|
}),
|
|
stroke: new Stroke({
|
|
color: "rgba(71, 85, 105, 0.72)",
|
|
width: 1.4,
|
|
}),
|
|
text: new Text({
|
|
text: label,
|
|
font: "700 11px sans-serif",
|
|
fill: new Fill({ color: "#0f172a" }),
|
|
overflow: true,
|
|
}),
|
|
zIndex: 9993,
|
|
});
|
|
|
|
const normalizeGisEditFid = (value: unknown) => {
|
|
const raw = String(value ?? "").trim();
|
|
if (!raw) return "";
|
|
const withoutNamespace = raw.includes(":")
|
|
? (raw.split(":").pop() ?? raw)
|
|
: raw;
|
|
return withoutNamespace.startsWith("tb_ms_swage_ppl.")
|
|
? withoutNamespace.slice("tb_ms_swage_ppl.".length)
|
|
: withoutNamespace;
|
|
};
|
|
|
|
const getGisEditFeatureFid = (data: any) =>
|
|
normalizeGisEditFid(
|
|
data?.get?.("fid") ??
|
|
data?.values_?.fid ??
|
|
data?.fid ??
|
|
data?.getId?.() ??
|
|
data?.id_ ??
|
|
"",
|
|
);
|
|
|
|
const isGisEditPreviewFeatureData = (data: any) =>
|
|
Boolean(String(data?.get?.("changeId") ?? data?.changeId ?? "").trim());
|
|
|
|
const highlightGisEditSelectedFeature = (featureData: any) => {
|
|
if (
|
|
!featureData ||
|
|
typeof featureData.getGeometry !== "function" ||
|
|
typeof featureData.setStyle !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const select = GisApp.Module.select as any;
|
|
select?.selectFeature?.(featureData, true);
|
|
};
|
|
|
|
const isVisibleGisEditSessionChange = (
|
|
change: GisEditSessionSnapshot["changes"][number],
|
|
) => !change.attributeOnly || Boolean(change.basicInfoSavedAt);
|
|
|
|
const getLatestNumericEditSn = (histories: any[]) =>
|
|
histories.reduce<number | null>((latest, history) => {
|
|
const editSn = String(history?.editSn ?? "").trim();
|
|
if (!/^\d+$/.test(editSn)) return latest;
|
|
const value = Number(editSn);
|
|
return latest === null || value > latest ? value : latest;
|
|
}, null);
|
|
|
|
const getGisEditAttributeText = (
|
|
attributes: Record<string, unknown> | null | undefined,
|
|
keys: string[],
|
|
) => {
|
|
for (const key of keys) {
|
|
const value = String(attributes?.[key] ?? "").trim();
|
|
if (value) return value;
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const buildGisEditHiddenPipeFilter = (fids: string[]) => {
|
|
const normalizedFids = Array.from(
|
|
new Set(
|
|
fids
|
|
.map((fid) => normalizeGisEditFid(fid))
|
|
.filter((fid) => /^\d+$/.test(fid)),
|
|
),
|
|
);
|
|
if (normalizedFids.length === 0) return null;
|
|
return `fid NOT IN (${normalizedFids.join(",")})`;
|
|
};
|
|
|
|
const findInvalidPipeRequiredChange = (
|
|
changes: Array<{
|
|
changeId: string;
|
|
editType: string;
|
|
clientTempId?: string;
|
|
attributes?: Record<string, unknown> | null;
|
|
}>,
|
|
) =>
|
|
changes.find((change) => {
|
|
const attributes = change.attributes ?? {};
|
|
const hasAttributePayload = Object.keys(attributes).length > 0;
|
|
if (change.editType !== "ADD" && !hasAttributePayload) return false;
|
|
|
|
const mngInstCd = getGisEditAttributeText(attributes, ["mngInstCd"]);
|
|
const fcltMngNo = getGisEditAttributeText(attributes, ["fcltMngNo"]);
|
|
const instMngNo = getGisEditAttributeText(attributes, ["instMngNo"]);
|
|
const usgCd = getGisEditAttributeText(attributes, ["usgCd"]);
|
|
return (
|
|
!mngInstCd ||
|
|
!fcltMngNo ||
|
|
/^NEW-/i.test(fcltMngNo) ||
|
|
!instMngNo ||
|
|
/^NEW-/i.test(instMngNo) ||
|
|
!usgCd
|
|
);
|
|
});
|
|
|
|
const getSinkholeSelectionValue = (data: any, key: string) =>
|
|
data?.[key] ??
|
|
data?.values_?.[key] ??
|
|
data?.get?.(key) ??
|
|
data?.properties?.[key];
|
|
|
|
const getSinkholeAccidentId = (data: any) =>
|
|
String(getSinkholeSelectionValue(data, "acdntNo") ?? "").trim();
|
|
|
|
const getSinkholeFallbackDetailFromSelection = (
|
|
data: any,
|
|
): Partial<SinkholeDetail> | null => {
|
|
const acdntNo = getSinkholeAccidentId(data);
|
|
if (!acdntNo) return null;
|
|
|
|
const point = getSinkholePointFromSelection(data);
|
|
return {
|
|
acdntNo,
|
|
ctpvNm: String(getSinkholeSelectionValue(data, "ctpvNm") ?? ""),
|
|
sggNm: String(getSinkholeSelectionValue(data, "sggNm") ?? ""),
|
|
emdNm: String(getSinkholeSelectionValue(data, "emdNm") ?? ""),
|
|
daddr: String(getSinkholeSelectionValue(data, "daddr") ?? ""),
|
|
fullAddress: String(
|
|
getSinkholeSelectionValue(data, "fullAddress") ??
|
|
getSinkholeSelectionValue(data, "daddr") ??
|
|
"",
|
|
),
|
|
ocrnYmd: String(getSinkholeSelectionValue(data, "ocrnYmd") ?? ""),
|
|
restoreStatus: String(
|
|
getSinkholeSelectionValue(data, "restoreStatus") ?? "",
|
|
),
|
|
restoreCompletedYmd: String(
|
|
getSinkholeSelectionValue(data, "restoreCompletedYmd") ?? "",
|
|
),
|
|
coordStatus: String(
|
|
getSinkholeSelectionValue(data, "coordStatus") ??
|
|
getSinkholeSelectionValue(data, "geocodeStatus") ??
|
|
"",
|
|
),
|
|
geocodeStatus: String(
|
|
getSinkholeSelectionValue(data, "geocodeStatus") ??
|
|
getSinkholeSelectionValue(data, "coordStatus") ??
|
|
"",
|
|
),
|
|
geocodeSource: String(
|
|
getSinkholeSelectionValue(data, "geocodeSource") ?? "",
|
|
),
|
|
geocodeErrorMessage: String(
|
|
getSinkholeSelectionValue(data, "geocodeErrorMessage") ?? "",
|
|
),
|
|
latitude: getSinkholeSelectionValue(data, "latitude") ?? null,
|
|
longitude: getSinkholeSelectionValue(data, "longitude") ?? null,
|
|
x: point?.x ?? getSinkholeSelectionValue(data, "x") ?? null,
|
|
y: point?.y ?? getSinkholeSelectionValue(data, "y") ?? null,
|
|
sizeWidth: getSinkholeSelectionValue(data, "sizeWidth") ?? null,
|
|
sizeLength: getSinkholeSelectionValue(data, "sizeLength") ?? null,
|
|
sizeDepth: getSinkholeSelectionValue(data, "sizeDepth") ?? null,
|
|
detailCause: getSinkholeSelectionValue(data, "detailCause") ?? null,
|
|
deathCount: getSinkholeSelectionValue(data, "deathCount") ?? null,
|
|
injuryCount: getSinkholeSelectionValue(data, "injuryCount") ?? null,
|
|
vehicleDamageCount:
|
|
getSinkholeSelectionValue(data, "vehicleDamageCount") ?? null,
|
|
};
|
|
};
|
|
|
|
const getSinkholePointFromSelection = (
|
|
data: any,
|
|
): { x: number; y: number } | null => {
|
|
const directX = Number(getSinkholeSelectionValue(data, "x"));
|
|
const directY = Number(getSinkholeSelectionValue(data, "y"));
|
|
if (Number.isFinite(directX) && Number.isFinite(directY)) {
|
|
return { x: directX, y: directY };
|
|
}
|
|
|
|
const geometry = data?.getGeometry?.();
|
|
if (geometry?.getType?.() === "Point") {
|
|
const coordinates = geometry.getCoordinates?.();
|
|
const x = Number(coordinates?.[0]);
|
|
const y = Number(coordinates?.[1]);
|
|
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
return { x, y };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const createSinkholePointStyle = (
|
|
variant: SinkholePointVariant = "blue",
|
|
selected = false,
|
|
) =>
|
|
new Style({
|
|
image: new CircleStyle({
|
|
radius: selected ? 9 : 7,
|
|
fill: new Fill({
|
|
color: selected
|
|
? SINKHOLE_POINT_PALETTE[variant].fillSelected
|
|
: SINKHOLE_POINT_PALETTE[variant].fill,
|
|
}),
|
|
stroke: new Stroke({
|
|
color: selected
|
|
? SINKHOLE_POINT_PALETTE[variant].strokeSelected
|
|
: SINKHOLE_POINT_PALETTE[variant].stroke,
|
|
width: selected ? 2.6 : 2,
|
|
}),
|
|
}),
|
|
zIndex: selected ? 9997 : 9994,
|
|
});
|
|
|
|
const createSinkholeRadiusStyle = (variant: SinkholePointVariant = "blue") =>
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: SINKHOLE_POINT_PALETTE[variant].radiusStroke,
|
|
width: 2.2,
|
|
lineDash: [8, 6],
|
|
}),
|
|
fill: new Fill({
|
|
color: SINKHOLE_POINT_PALETTE[variant].radiusFill,
|
|
}),
|
|
zIndex: 9993,
|
|
});
|
|
// 반경 원 상단에 실제 적용 거리값을 표시하는 선택 전용 지도 라벨 스타일이다.
|
|
const createSinkholeRadiusLabelStyle = (
|
|
radiusMeters: number,
|
|
variant: SinkholePointVariant = "blue",
|
|
) =>
|
|
new Style({
|
|
text: new Text({
|
|
text: `반경 ${radiusMeters.toLocaleString("ko-KR")}m`,
|
|
font: "600 13px Pretendard, sans-serif",
|
|
textAlign: "center",
|
|
offsetY: -14,
|
|
padding: [4, 7, 4, 7],
|
|
fill: new Fill({ color: SINKHOLE_POINT_PALETTE[variant].strokeSelected }),
|
|
stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 3 }),
|
|
backgroundFill: new Fill({ color: "rgba(255, 255, 255, 0.9)" }),
|
|
backgroundStroke: new Stroke({
|
|
color: SINKHOLE_POINT_PALETTE[variant].radiusStroke,
|
|
width: 1.5,
|
|
}),
|
|
}),
|
|
zIndex: 9998,
|
|
});
|
|
|
|
const createSinkholeFocusedPipeStyle = () => [
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: "rgba(88, 28, 135, 0.22)",
|
|
width: 18,
|
|
lineCap: "round",
|
|
lineJoin: "round",
|
|
}),
|
|
zIndex: 9997,
|
|
}),
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: "rgba(255, 255, 255, 0.96)",
|
|
width: 12,
|
|
lineCap: "round",
|
|
lineJoin: "round",
|
|
}),
|
|
zIndex: 9998,
|
|
}),
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: "rgba(37, 99, 235, 0.98)",
|
|
width: 8,
|
|
lineCap: "round",
|
|
lineJoin: "round",
|
|
}),
|
|
zIndex: 9999,
|
|
}),
|
|
];
|
|
|
|
type VertexHandleMeta = {
|
|
kind: "vertex" | "midpoint";
|
|
geometryType: string;
|
|
partIndex?: number;
|
|
polygonIndex?: number;
|
|
ringIndex?: number;
|
|
coordIndex: number;
|
|
};
|
|
|
|
type VertexHandleCandidate = {
|
|
coordinate: number[];
|
|
meta: VertexHandleMeta;
|
|
};
|
|
|
|
type VertexDeleteResult = {
|
|
ok: boolean;
|
|
message?: string;
|
|
};
|
|
|
|
const cloneCoordinates = <T,>(value: T): T =>
|
|
JSON.parse(JSON.stringify(value)) as T;
|
|
|
|
const getMidpoint = (start: number[], end: number[]) => [
|
|
(Number(start?.[0] ?? 0) + Number(end?.[0] ?? 0)) / 2,
|
|
(Number(start?.[1] ?? 0) + Number(end?.[1] ?? 0)) / 2,
|
|
];
|
|
|
|
const buildVertexCoordinateKey = (coordinate: number[]) =>
|
|
`${Number(coordinate?.[0] ?? 0)}:${Number(coordinate?.[1] ?? 0)}`;
|
|
|
|
const buildVertexHandleMetaKey = (meta: VertexHandleMeta) =>
|
|
[
|
|
meta.kind,
|
|
meta.geometryType,
|
|
meta.partIndex ?? -1,
|
|
meta.polygonIndex ?? -1,
|
|
meta.ringIndex ?? -1,
|
|
meta.coordIndex,
|
|
].join(":");
|
|
|
|
const buildVertexHandleSelectionKey = (metaList: VertexHandleMeta[]) =>
|
|
metaList.map(buildVertexHandleMetaKey).sort().join("|");
|
|
|
|
const getSquaredPixelDistance = (
|
|
map: any,
|
|
startCoordinate: number[],
|
|
endCoordinate: number[],
|
|
) => {
|
|
const [startX = 0, startY = 0] =
|
|
map?.getPixelFromCoordinate?.(startCoordinate) ?? [];
|
|
const [endX = 0, endY = 0] =
|
|
map?.getPixelFromCoordinate?.(endCoordinate) ?? [];
|
|
return (endX - startX) ** 2 + (endY - startY) ** 2;
|
|
};
|
|
|
|
const GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD = 12;
|
|
|
|
const getAverageCoordinate = (coordinates: number[][]) => {
|
|
if (coordinates.length === 0) return [0, 0];
|
|
|
|
const [sumX, sumY] = coordinates.reduce<[number, number]>(
|
|
([accX, accY], [x = 0, y = 0]) => [accX + Number(x), accY + Number(y)],
|
|
[0, 0],
|
|
);
|
|
|
|
return [sumX / coordinates.length, sumY / coordinates.length];
|
|
};
|
|
|
|
const getPixelDistanceToSegment = (
|
|
map: any,
|
|
coordinate: number[],
|
|
startCoordinate: number[],
|
|
endCoordinate: number[],
|
|
) => {
|
|
const [pointX = 0, pointY = 0] =
|
|
map?.getPixelFromCoordinate?.(coordinate) ?? [];
|
|
const [startX = 0, startY = 0] =
|
|
map?.getPixelFromCoordinate?.(startCoordinate) ?? [];
|
|
const [endX = 0, endY = 0] =
|
|
map?.getPixelFromCoordinate?.(endCoordinate) ?? [];
|
|
const deltaX = endX - startX;
|
|
const deltaY = endY - startY;
|
|
const segmentLengthSquared = deltaX ** 2 + deltaY ** 2;
|
|
|
|
if (segmentLengthSquared === 0) {
|
|
return Math.hypot(pointX - startX, pointY - startY);
|
|
}
|
|
|
|
const ratio = Math.max(
|
|
0,
|
|
Math.min(
|
|
1,
|
|
((pointX - startX) * deltaX + (pointY - startY) * deltaY) /
|
|
segmentLengthSquared,
|
|
),
|
|
);
|
|
const projectedX = startX + deltaX * ratio;
|
|
const projectedY = startY + deltaY * ratio;
|
|
return Math.hypot(pointX - projectedX, pointY - projectedY);
|
|
};
|
|
|
|
const createVertexHandleFeature = (
|
|
coordinate: number[],
|
|
meta: VertexHandleMeta,
|
|
metaList: VertexHandleMeta[] = [meta],
|
|
) => {
|
|
const handleFeature = new Feature({
|
|
geometry: new Point(coordinate),
|
|
});
|
|
handleFeature.set("handleKind", meta.kind);
|
|
handleFeature.set("handleMeta", meta);
|
|
handleFeature.set("handleMetaList", metaList);
|
|
handleFeature.set(
|
|
"handleSelectionKey",
|
|
buildVertexHandleSelectionKey(metaList),
|
|
);
|
|
handleFeature.setStyle(getGisEditVertexHandleStyle(meta.kind));
|
|
return handleFeature;
|
|
};
|
|
|
|
const collectLineVertexHandleCandidates = (
|
|
coordinates: number[][],
|
|
geometryType: string,
|
|
partIndex?: number,
|
|
) => {
|
|
const candidates: VertexHandleCandidate[] = [];
|
|
coordinates.forEach((coordinate, coordIndex) => {
|
|
candidates.push({
|
|
coordinate,
|
|
meta: {
|
|
kind: "vertex",
|
|
geometryType,
|
|
partIndex,
|
|
coordIndex,
|
|
},
|
|
});
|
|
});
|
|
return candidates;
|
|
};
|
|
|
|
const collectPolygonRingVertexHandleCandidates = (
|
|
coordinates: number[][],
|
|
geometryType: string,
|
|
polygonIndex?: number,
|
|
ringIndex?: number,
|
|
) => {
|
|
const candidates: VertexHandleCandidate[] = [];
|
|
const logicalVertexCount = Math.max(0, coordinates.length - 1);
|
|
for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) {
|
|
const coordinate = coordinates[coordIndex];
|
|
const nextCoordinate =
|
|
coordinates[(coordIndex + 1) % logicalVertexCount] ?? coordinate;
|
|
if (!coordinate || !nextCoordinate) continue;
|
|
candidates.push({
|
|
coordinate,
|
|
meta: {
|
|
kind: "vertex",
|
|
geometryType,
|
|
polygonIndex,
|
|
ringIndex,
|
|
coordIndex,
|
|
},
|
|
});
|
|
}
|
|
return candidates;
|
|
};
|
|
|
|
const collectVertexHandleCandidates = (geometry: any) => {
|
|
const geometryType = geometry?.getType?.();
|
|
if (!geometryType) return [];
|
|
|
|
if (geometryType === "LineString") {
|
|
return collectLineVertexHandleCandidates(
|
|
geometry.getCoordinates?.() ?? [],
|
|
geometryType,
|
|
);
|
|
}
|
|
|
|
if (geometryType === "MultiLineString") {
|
|
const parts = geometry.getCoordinates?.() ?? [];
|
|
return parts.flatMap((part: number[][], partIndex: number) =>
|
|
collectLineVertexHandleCandidates(part, geometryType, partIndex),
|
|
);
|
|
}
|
|
|
|
if (geometryType === "Polygon") {
|
|
const rings = geometry.getCoordinates?.() ?? [];
|
|
return rings.flatMap((ring: number[][], ringIndex: number) =>
|
|
collectPolygonRingVertexHandleCandidates(
|
|
ring,
|
|
geometryType,
|
|
undefined,
|
|
ringIndex,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (geometryType === "MultiPolygon") {
|
|
const polygons = geometry.getCoordinates?.() ?? [];
|
|
return polygons.flatMap((polygon: number[][][], polygonIndex: number) =>
|
|
polygon.flatMap((ring: number[][], ringIndex: number) =>
|
|
collectPolygonRingVertexHandleCandidates(
|
|
ring,
|
|
geometryType,
|
|
polygonIndex,
|
|
ringIndex,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
const buildVertexHandleFeatures = (geometry: any, map?: any): Feature[] => {
|
|
const groupedCandidates: Array<{
|
|
coordinate: number[];
|
|
metaList: VertexHandleMeta[];
|
|
sourceCoordinates: number[][];
|
|
}> = [];
|
|
|
|
collectVertexHandleCandidates(geometry).forEach(
|
|
(candidate: VertexHandleCandidate) => {
|
|
const existing = groupedCandidates.find((group) => {
|
|
if (!map) {
|
|
return (
|
|
buildVertexCoordinateKey(group.coordinate) ===
|
|
buildVertexCoordinateKey(candidate.coordinate)
|
|
);
|
|
}
|
|
|
|
return (
|
|
getSquaredPixelDistance(
|
|
map,
|
|
group.coordinate,
|
|
candidate.coordinate,
|
|
) <=
|
|
GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD ** 2
|
|
);
|
|
});
|
|
|
|
if (existing) {
|
|
existing.metaList.push(candidate.meta);
|
|
existing.sourceCoordinates.push(candidate.coordinate);
|
|
existing.coordinate = getAverageCoordinate(existing.sourceCoordinates);
|
|
return;
|
|
}
|
|
|
|
groupedCandidates.push({
|
|
coordinate: candidate.coordinate,
|
|
metaList: [candidate.meta],
|
|
sourceCoordinates: [candidate.coordinate],
|
|
});
|
|
},
|
|
);
|
|
|
|
return groupedCandidates.map(({ coordinate, metaList }) =>
|
|
createVertexHandleFeature(coordinate, metaList[0]!, metaList),
|
|
);
|
|
};
|
|
|
|
const collectLineMidpointCandidates = (
|
|
coordinates: number[][],
|
|
geometryType: string,
|
|
partIndex?: number,
|
|
) => {
|
|
const candidates: VertexHandleCandidate[] = [];
|
|
for (
|
|
let coordIndex = 0;
|
|
coordIndex < coordinates.length - 1;
|
|
coordIndex += 1
|
|
) {
|
|
const startCoordinate = coordinates[coordIndex];
|
|
const endCoordinate = coordinates[coordIndex + 1];
|
|
if (!startCoordinate || !endCoordinate) continue;
|
|
candidates.push({
|
|
coordinate: getMidpoint(startCoordinate, endCoordinate),
|
|
meta: {
|
|
kind: "midpoint",
|
|
geometryType,
|
|
partIndex,
|
|
coordIndex,
|
|
},
|
|
});
|
|
}
|
|
return candidates;
|
|
};
|
|
|
|
const collectPolygonRingMidpointCandidates = (
|
|
coordinates: number[][],
|
|
geometryType: string,
|
|
polygonIndex?: number,
|
|
ringIndex?: number,
|
|
) => {
|
|
const candidates: VertexHandleCandidate[] = [];
|
|
const logicalVertexCount = Math.max(0, coordinates.length - 1);
|
|
for (let coordIndex = 0; coordIndex < logicalVertexCount; coordIndex += 1) {
|
|
const startCoordinate = coordinates[coordIndex];
|
|
const endCoordinate = coordinates[(coordIndex + 1) % logicalVertexCount];
|
|
if (!startCoordinate || !endCoordinate) continue;
|
|
candidates.push({
|
|
coordinate: getMidpoint(startCoordinate, endCoordinate),
|
|
meta: {
|
|
kind: "midpoint",
|
|
geometryType,
|
|
polygonIndex,
|
|
ringIndex,
|
|
coordIndex,
|
|
},
|
|
});
|
|
}
|
|
return candidates;
|
|
};
|
|
|
|
const collectMidpointCandidates = (geometry: any) => {
|
|
const geometryType = geometry?.getType?.();
|
|
if (!geometryType) return [];
|
|
|
|
if (geometryType === "LineString") {
|
|
return collectLineMidpointCandidates(
|
|
geometry.getCoordinates?.() ?? [],
|
|
geometryType,
|
|
);
|
|
}
|
|
|
|
if (geometryType === "MultiLineString") {
|
|
const parts = geometry.getCoordinates?.() ?? [];
|
|
return parts.flatMap((part: number[][], partIndex: number) =>
|
|
collectLineMidpointCandidates(part, geometryType, partIndex),
|
|
);
|
|
}
|
|
|
|
if (geometryType === "Polygon") {
|
|
const rings = geometry.getCoordinates?.() ?? [];
|
|
return rings.flatMap((ring: number[][], ringIndex: number) =>
|
|
collectPolygonRingMidpointCandidates(
|
|
ring,
|
|
geometryType,
|
|
undefined,
|
|
ringIndex,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (geometryType === "MultiPolygon") {
|
|
const polygons = geometry.getCoordinates?.() ?? [];
|
|
return polygons.flatMap((polygon: number[][][], polygonIndex: number) =>
|
|
polygon.flatMap((ring: number[][], ringIndex: number) =>
|
|
collectPolygonRingMidpointCandidates(
|
|
ring,
|
|
geometryType,
|
|
polygonIndex,
|
|
ringIndex,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
const getNearestVertexPixelDistance = (
|
|
map: any,
|
|
geometry: any,
|
|
coordinate: number[],
|
|
): number => {
|
|
return buildVertexHandleFeatures(geometry, map).reduce(
|
|
(minimumDistance: number, feature: Feature) => {
|
|
const vertexCoordinate = (
|
|
feature.getGeometry?.() as any
|
|
)?.getCoordinates?.();
|
|
if (!Array.isArray(vertexCoordinate)) return minimumDistance;
|
|
return Math.min(
|
|
minimumDistance,
|
|
Math.sqrt(getSquaredPixelDistance(map, coordinate, vertexCoordinate)),
|
|
);
|
|
},
|
|
Number.POSITIVE_INFINITY,
|
|
);
|
|
};
|
|
|
|
const getSegmentCoordinatesFromMeta = (
|
|
geometry: any,
|
|
meta: VertexHandleMeta,
|
|
): [number[], number[]] | null => {
|
|
const geometryType = geometry?.getType?.();
|
|
if (!geometryType) return null;
|
|
|
|
if (geometryType === "LineString") {
|
|
const coordinates = geometry.getCoordinates?.() ?? [];
|
|
return [coordinates[meta.coordIndex], coordinates[meta.coordIndex + 1]];
|
|
}
|
|
|
|
if (geometryType === "MultiLineString") {
|
|
const parts = geometry.getCoordinates?.() ?? [];
|
|
const targetPart = parts[meta.partIndex ?? 0] ?? [];
|
|
return [targetPart[meta.coordIndex], targetPart[meta.coordIndex + 1]];
|
|
}
|
|
|
|
if (geometryType === "Polygon") {
|
|
const rings = geometry.getCoordinates?.() ?? [];
|
|
const targetRing = rings[meta.ringIndex ?? 0] ?? [];
|
|
const logicalVertexCount = Math.max(0, targetRing.length - 1);
|
|
if (logicalVertexCount === 0) return null;
|
|
return [
|
|
targetRing[meta.coordIndex],
|
|
targetRing[(meta.coordIndex + 1) % logicalVertexCount],
|
|
];
|
|
}
|
|
|
|
if (geometryType === "MultiPolygon") {
|
|
const polygons = geometry.getCoordinates?.() ?? [];
|
|
const targetPolygon = polygons[meta.polygonIndex ?? 0] ?? [];
|
|
const targetRing = targetPolygon[meta.ringIndex ?? 0] ?? [];
|
|
const logicalVertexCount = Math.max(0, targetRing.length - 1);
|
|
if (logicalVertexCount === 0) return null;
|
|
return [
|
|
targetRing[meta.coordIndex],
|
|
targetRing[(meta.coordIndex + 1) % logicalVertexCount],
|
|
];
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const findHoveredMidpointCandidate = (
|
|
map: any,
|
|
geometry: any,
|
|
coordinate: number[],
|
|
tolerancePx = 18,
|
|
): VertexHandleCandidate | null => {
|
|
const nearestVertexDistance = getNearestVertexPixelDistance(
|
|
map,
|
|
geometry,
|
|
coordinate,
|
|
);
|
|
if (nearestVertexDistance <= 14) {
|
|
return null;
|
|
}
|
|
|
|
const midpointCandidates = collectMidpointCandidates(geometry);
|
|
let bestCandidate: VertexHandleCandidate | null = null;
|
|
let minimumDistance = Number.POSITIVE_INFINITY;
|
|
|
|
midpointCandidates.forEach((candidate: VertexHandleCandidate) => {
|
|
const segmentCoordinates = getSegmentCoordinatesFromMeta(
|
|
geometry,
|
|
candidate.meta,
|
|
);
|
|
if (!segmentCoordinates) return;
|
|
const [startCoordinate, endCoordinate] = segmentCoordinates;
|
|
if (!Array.isArray(startCoordinate) || !Array.isArray(endCoordinate))
|
|
return;
|
|
|
|
const segmentDistance = getPixelDistanceToSegment(
|
|
map,
|
|
coordinate,
|
|
startCoordinate,
|
|
endCoordinate,
|
|
);
|
|
if (segmentDistance < minimumDistance) {
|
|
minimumDistance = segmentDistance;
|
|
bestCandidate = candidate;
|
|
}
|
|
});
|
|
|
|
return minimumDistance <= tolerancePx ? bestCandidate : null;
|
|
};
|
|
|
|
const updateLineCoordinates = (
|
|
coordinates: number[][],
|
|
meta: VertexHandleMeta,
|
|
nextCoordinate: number[],
|
|
) => {
|
|
const nextCoordinates = cloneCoordinates(coordinates);
|
|
if (meta.kind === "vertex") {
|
|
nextCoordinates[meta.coordIndex] = nextCoordinate;
|
|
} else {
|
|
nextCoordinates.splice(meta.coordIndex + 1, 0, nextCoordinate);
|
|
}
|
|
return nextCoordinates;
|
|
};
|
|
|
|
const updatePolygonRingCoordinates = (
|
|
coordinates: number[][],
|
|
meta: VertexHandleMeta,
|
|
nextCoordinate: number[],
|
|
) => {
|
|
const nextCoordinates = cloneCoordinates(coordinates);
|
|
const logicalVertexCount = Math.max(0, nextCoordinates.length - 1);
|
|
if (logicalVertexCount === 0) return nextCoordinates;
|
|
|
|
if (meta.kind === "vertex") {
|
|
nextCoordinates[meta.coordIndex] = nextCoordinate;
|
|
if (meta.coordIndex === 0) {
|
|
nextCoordinates[logicalVertexCount] = nextCoordinate;
|
|
}
|
|
} else {
|
|
const insertIndex =
|
|
meta.coordIndex === logicalVertexCount - 1
|
|
? logicalVertexCount
|
|
: meta.coordIndex + 1;
|
|
nextCoordinates.splice(insertIndex, 0, nextCoordinate);
|
|
}
|
|
|
|
return nextCoordinates;
|
|
};
|
|
|
|
const applyVertexHandleMove = (
|
|
geometry: any,
|
|
meta: VertexHandleMeta,
|
|
nextCoordinate: number[],
|
|
) => {
|
|
const geometryType = geometry?.getType?.();
|
|
if (!geometryType) return;
|
|
|
|
if (geometryType === "LineString") {
|
|
geometry.setCoordinates(
|
|
updateLineCoordinates(
|
|
geometry.getCoordinates?.() ?? [],
|
|
meta,
|
|
nextCoordinate,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (geometryType === "MultiLineString") {
|
|
const parts = cloneCoordinates(geometry.getCoordinates?.() ?? []);
|
|
const targetPartIndex = meta.partIndex ?? 0;
|
|
parts[targetPartIndex] = updateLineCoordinates(
|
|
parts[targetPartIndex] ?? [],
|
|
meta,
|
|
nextCoordinate,
|
|
);
|
|
geometry.setCoordinates(parts);
|
|
return;
|
|
}
|
|
|
|
if (geometryType === "Polygon") {
|
|
const rings = cloneCoordinates(geometry.getCoordinates?.() ?? []);
|
|
const targetRingIndex = meta.ringIndex ?? 0;
|
|
rings[targetRingIndex] = updatePolygonRingCoordinates(
|
|
rings[targetRingIndex] ?? [],
|
|
meta,
|
|
nextCoordinate,
|
|
);
|
|
geometry.setCoordinates(rings);
|
|
return;
|
|
}
|
|
|
|
if (geometryType === "MultiPolygon") {
|
|
const polygons = cloneCoordinates(geometry.getCoordinates?.() ?? []);
|
|
const targetPolygonIndex = meta.polygonIndex ?? 0;
|
|
const targetRingIndex = meta.ringIndex ?? 0;
|
|
polygons[targetPolygonIndex][targetRingIndex] =
|
|
updatePolygonRingCoordinates(
|
|
polygons[targetPolygonIndex][targetRingIndex] ?? [],
|
|
meta,
|
|
nextCoordinate,
|
|
);
|
|
geometry.setCoordinates(polygons);
|
|
}
|
|
};
|
|
|
|
const deleteLineVertices = (
|
|
coordinates: number[][],
|
|
coordIndexes: number[],
|
|
) => {
|
|
const nextCoordinates = cloneCoordinates(coordinates);
|
|
const uniqueIndexes = Array.from(new Set(coordIndexes)).sort(
|
|
(left, right) => right - left,
|
|
);
|
|
if (nextCoordinates.length - uniqueIndexes.length < 2) {
|
|
return null;
|
|
}
|
|
uniqueIndexes.forEach((coordIndex) => {
|
|
nextCoordinates.splice(coordIndex, 1);
|
|
});
|
|
return nextCoordinates;
|
|
};
|
|
|
|
const deletePolygonVertices = (
|
|
coordinates: number[][],
|
|
coordIndexes: number[],
|
|
) => {
|
|
const logicalVertexCount = Math.max(0, coordinates.length - 1);
|
|
const uniqueIndexes = Array.from(new Set(coordIndexes)).sort(
|
|
(left, right) => right - left,
|
|
);
|
|
if (logicalVertexCount - uniqueIndexes.length < 3) {
|
|
return null;
|
|
}
|
|
|
|
const logicalCoordinates = cloneCoordinates(
|
|
coordinates.slice(0, logicalVertexCount),
|
|
);
|
|
uniqueIndexes.forEach((coordIndex) => {
|
|
logicalCoordinates.splice(coordIndex, 1);
|
|
});
|
|
if (logicalCoordinates.length < 3) return null;
|
|
|
|
return [...logicalCoordinates, cloneCoordinates(logicalCoordinates[0] ?? [])];
|
|
};
|
|
|
|
const deleteSelectedVertexHandles = (
|
|
geometry: any,
|
|
metaList: VertexHandleMeta[],
|
|
): VertexDeleteResult => {
|
|
if (!geometry || metaList.length === 0) {
|
|
return { ok: false, message: "삭제할 꼭지점 정보를 찾지 못했습니다." };
|
|
}
|
|
|
|
if (metaList.some((meta) => meta.kind !== "vertex")) {
|
|
return {
|
|
ok: false,
|
|
message:
|
|
"흰색 midpoint는 삭제 대상이 아닙니다. 주황색 실꼭지점을 선택하세요.",
|
|
};
|
|
}
|
|
|
|
const geometryType = geometry?.getType?.();
|
|
if (!geometryType) {
|
|
return {
|
|
ok: false,
|
|
message: "꼭지점 삭제 대상 geometry를 찾지 못했습니다.",
|
|
};
|
|
}
|
|
|
|
if (geometryType === "LineString") {
|
|
const nextCoordinates = deleteLineVertices(
|
|
geometry.getCoordinates?.() ?? [],
|
|
metaList.map((meta) => meta.coordIndex),
|
|
);
|
|
if (!nextCoordinates) {
|
|
return {
|
|
ok: false,
|
|
message:
|
|
"하수관로는 최소 2개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.",
|
|
};
|
|
}
|
|
geometry.setCoordinates(nextCoordinates);
|
|
return { ok: true };
|
|
}
|
|
|
|
if (geometryType === "MultiLineString") {
|
|
const nextParts = cloneCoordinates(geometry.getCoordinates?.() ?? []);
|
|
const metaGroups = metaList.reduce<Record<number, number[]>>(
|
|
(acc, meta) => {
|
|
const partIndex = meta.partIndex ?? 0;
|
|
if (!acc[partIndex]) acc[partIndex] = [];
|
|
acc[partIndex]!.push(meta.coordIndex);
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
|
|
for (const [partIndexRaw, coordIndexes] of Object.entries(metaGroups)) {
|
|
const partIndex = Number(partIndexRaw);
|
|
const nextCoordinates = deleteLineVertices(
|
|
nextParts[partIndex] ?? [],
|
|
coordIndexes,
|
|
);
|
|
if (!nextCoordinates) {
|
|
return {
|
|
ok: false,
|
|
message: "하수관로는 각 선형마다 최소 2개의 꼭지점이 필요합니다.",
|
|
};
|
|
}
|
|
nextParts[partIndex] = nextCoordinates;
|
|
}
|
|
|
|
geometry.setCoordinates(nextParts);
|
|
return { ok: true };
|
|
}
|
|
|
|
if (geometryType === "Polygon") {
|
|
const nextRings = cloneCoordinates(geometry.getCoordinates?.() ?? []);
|
|
const metaGroups = metaList.reduce<Record<number, number[]>>(
|
|
(acc, meta) => {
|
|
const ringIndex = meta.ringIndex ?? 0;
|
|
if (!acc[ringIndex]) acc[ringIndex] = [];
|
|
acc[ringIndex]!.push(meta.coordIndex);
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
|
|
for (const [ringIndexRaw, coordIndexes] of Object.entries(metaGroups)) {
|
|
const ringIndex = Number(ringIndexRaw);
|
|
const nextCoordinates = deletePolygonVertices(
|
|
nextRings[ringIndex] ?? [],
|
|
coordIndexes,
|
|
);
|
|
if (!nextCoordinates) {
|
|
return {
|
|
ok: false,
|
|
message:
|
|
"폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.",
|
|
};
|
|
}
|
|
nextRings[ringIndex] = nextCoordinates;
|
|
}
|
|
|
|
geometry.setCoordinates(nextRings);
|
|
return { ok: true };
|
|
}
|
|
|
|
if (geometryType === "MultiPolygon") {
|
|
const nextPolygons = cloneCoordinates(geometry.getCoordinates?.() ?? []);
|
|
const metaGroups = metaList.reduce<Record<string, number[]>>(
|
|
(acc, meta) => {
|
|
const polygonIndex = meta.polygonIndex ?? 0;
|
|
const ringIndex = meta.ringIndex ?? 0;
|
|
const groupKey = `${polygonIndex}:${ringIndex}`;
|
|
if (!acc[groupKey]) acc[groupKey] = [];
|
|
acc[groupKey]!.push(meta.coordIndex);
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
|
|
for (const [groupKey, coordIndexes] of Object.entries(metaGroups)) {
|
|
const [polygonIndexText, ringIndexText] = groupKey.split(":");
|
|
const polygonIndex = Number(polygonIndexText);
|
|
const ringIndex = Number(ringIndexText);
|
|
const nextCoordinates = deletePolygonVertices(
|
|
nextPolygons[polygonIndex]?.[ringIndex] ?? [],
|
|
coordIndexes,
|
|
);
|
|
if (!nextCoordinates) {
|
|
return {
|
|
ok: false,
|
|
message:
|
|
"폴리곤은 최소 3개의 꼭지점이 필요해 더 이상 삭제할 수 없습니다.",
|
|
};
|
|
}
|
|
nextPolygons[polygonIndex][ringIndex] = nextCoordinates;
|
|
}
|
|
|
|
geometry.setCoordinates(nextPolygons);
|
|
return { ok: true };
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
message: "현재 geometry 유형은 꼭지점 삭제를 지원하지 않습니다.",
|
|
};
|
|
};
|
|
|
|
const getClosestSnapCoordinateFromSource = (
|
|
source: VectorSource | null,
|
|
map: any,
|
|
coordinate: number[],
|
|
tolerancePx = 18,
|
|
) => {
|
|
if (!source || !Array.isArray(coordinate)) return null;
|
|
const nearestFeature = source.getClosestFeatureToCoordinate?.(coordinate);
|
|
const nearestGeometry = nearestFeature?.getGeometry?.();
|
|
if (!nearestGeometry?.getClosestPoint) return null;
|
|
|
|
const closestCoordinate = nearestGeometry.getClosestPoint(coordinate);
|
|
if (!Array.isArray(closestCoordinate)) return null;
|
|
|
|
const distance = Math.sqrt(
|
|
getSquaredPixelDistance(map, coordinate, closestCoordinate),
|
|
);
|
|
if (!Number.isFinite(distance) || distance > tolerancePx) return null;
|
|
|
|
return {
|
|
coordinate: closestCoordinate as number[],
|
|
distance,
|
|
};
|
|
};
|
|
|
|
const applyGeometryVertexSnaps = (
|
|
geometry: any,
|
|
source: VectorSource | null,
|
|
map: any,
|
|
tolerancePx = 18,
|
|
) => {
|
|
if (!geometry || !source || !map) return false;
|
|
|
|
const vertexCandidates = collectVertexHandleCandidates(geometry).filter(
|
|
(candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex",
|
|
);
|
|
let changed = false;
|
|
|
|
vertexCandidates.forEach((candidate: VertexHandleCandidate) => {
|
|
const snapResult = getClosestSnapCoordinateFromSource(
|
|
source,
|
|
map,
|
|
candidate.coordinate,
|
|
tolerancePx,
|
|
);
|
|
if (!snapResult) return;
|
|
|
|
const currentKey = buildVertexCoordinateKey(candidate.coordinate);
|
|
const nextKey = buildVertexCoordinateKey(snapResult.coordinate);
|
|
if (currentKey === nextKey) return;
|
|
|
|
applyVertexHandleMove(geometry, candidate.meta, snapResult.coordinate);
|
|
changed = true;
|
|
});
|
|
|
|
return changed;
|
|
};
|
|
|
|
const applyGeometryMoveSnap = (
|
|
geometry: any,
|
|
source: VectorSource | null,
|
|
map: any,
|
|
anchorCoordinate?: number[] | null,
|
|
tolerancePx = 18,
|
|
) => {
|
|
if (!geometry || !source || !map || typeof geometry.translate !== "function")
|
|
return false;
|
|
|
|
if (Array.isArray(anchorCoordinate)) {
|
|
const snapResult = getClosestSnapCoordinateFromSource(
|
|
source,
|
|
map,
|
|
anchorCoordinate,
|
|
tolerancePx,
|
|
);
|
|
if (snapResult) {
|
|
const deltaX =
|
|
Number(snapResult.coordinate[0] ?? 0) -
|
|
Number(anchorCoordinate[0] ?? 0);
|
|
const deltaY =
|
|
Number(snapResult.coordinate[1] ?? 0) -
|
|
Number(anchorCoordinate[1] ?? 0);
|
|
if (deltaX !== 0 || deltaY !== 0) {
|
|
geometry.translate(deltaX, deltaY);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const vertexCandidates = collectVertexHandleCandidates(geometry).filter(
|
|
(candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex",
|
|
);
|
|
type GeometryMoveSnapCandidate = {
|
|
original: number[];
|
|
snapped: number[];
|
|
distance: number;
|
|
};
|
|
let bestCandidate: GeometryMoveSnapCandidate | null = null;
|
|
|
|
vertexCandidates.forEach((candidate: VertexHandleCandidate) => {
|
|
const snapResult = getClosestSnapCoordinateFromSource(
|
|
source,
|
|
map,
|
|
candidate.coordinate,
|
|
tolerancePx,
|
|
);
|
|
if (!snapResult) return;
|
|
|
|
if (!bestCandidate || snapResult.distance < bestCandidate.distance) {
|
|
bestCandidate = {
|
|
original: candidate.coordinate,
|
|
snapped: snapResult.coordinate,
|
|
distance: snapResult.distance,
|
|
};
|
|
}
|
|
});
|
|
|
|
if (!bestCandidate) return false;
|
|
const resolvedBestCandidate = bestCandidate as GeometryMoveSnapCandidate;
|
|
|
|
const deltaX =
|
|
Number(resolvedBestCandidate.snapped[0] ?? 0) -
|
|
Number(resolvedBestCandidate.original[0] ?? 0);
|
|
const deltaY =
|
|
Number(resolvedBestCandidate.snapped[1] ?? 0) -
|
|
Number(resolvedBestCandidate.original[1] ?? 0);
|
|
if (deltaX === 0 && deltaY === 0) return false;
|
|
|
|
geometry.translate(deltaX, deltaY);
|
|
return true;
|
|
};
|
|
|
|
const getClosestGeometryVertexCoordinate = (
|
|
geometry: any,
|
|
map: any,
|
|
coordinate: number[] | null,
|
|
) => {
|
|
if (!geometry || !map || !Array.isArray(coordinate)) return null;
|
|
|
|
const vertexCandidates = collectVertexHandleCandidates(geometry).filter(
|
|
(candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex",
|
|
);
|
|
let bestCandidate: VertexHandleCandidate | null = null;
|
|
let minimumDistance = Number.POSITIVE_INFINITY;
|
|
|
|
vertexCandidates.forEach((candidate: VertexHandleCandidate) => {
|
|
const distance = getSquaredPixelDistance(
|
|
map,
|
|
coordinate,
|
|
candidate.coordinate,
|
|
);
|
|
if (distance < minimumDistance) {
|
|
minimumDistance = distance;
|
|
bestCandidate = candidate;
|
|
}
|
|
});
|
|
|
|
return (bestCandidate as VertexHandleCandidate | null)?.coordinate ?? null;
|
|
};
|
|
|
|
const getClosestGeometryVertexMeta = (
|
|
geometry: any,
|
|
map: any,
|
|
coordinate: number[] | null,
|
|
) => {
|
|
if (!geometry || !map || !Array.isArray(coordinate)) return null;
|
|
|
|
const vertexCandidates = collectVertexHandleCandidates(geometry).filter(
|
|
(candidate: VertexHandleCandidate) => candidate.meta.kind === "vertex",
|
|
);
|
|
let bestCandidate: VertexHandleCandidate | null = null;
|
|
let minimumDistance = Number.POSITIVE_INFINITY;
|
|
|
|
vertexCandidates.forEach((candidate: VertexHandleCandidate) => {
|
|
const distance = getSquaredPixelDistance(
|
|
map,
|
|
coordinate,
|
|
candidate.coordinate,
|
|
);
|
|
if (distance < minimumDistance) {
|
|
minimumDistance = distance;
|
|
bestCandidate = candidate;
|
|
}
|
|
});
|
|
|
|
return (bestCandidate as VertexHandleCandidate | null)?.meta ?? null;
|
|
};
|
|
|
|
const getGeometryVertexCoordinateByMeta = (
|
|
geometry: any,
|
|
meta: VertexHandleMeta | null,
|
|
) => {
|
|
if (!geometry || !meta) return null;
|
|
const geometryType = geometry?.getType?.();
|
|
if (!geometryType) return null;
|
|
|
|
if (geometryType === "LineString") {
|
|
return geometry.getCoordinates?.()?.[meta.coordIndex] ?? null;
|
|
}
|
|
|
|
if (geometryType === "MultiLineString") {
|
|
return (
|
|
geometry.getCoordinates?.()?.[meta.partIndex ?? 0]?.[meta.coordIndex] ??
|
|
null
|
|
);
|
|
}
|
|
|
|
if (geometryType === "Polygon") {
|
|
return (
|
|
geometry.getCoordinates?.()?.[meta.ringIndex ?? 0]?.[meta.coordIndex] ??
|
|
null
|
|
);
|
|
}
|
|
|
|
if (geometryType === "MultiPolygon") {
|
|
return (
|
|
geometry.getCoordinates?.()?.[meta.polygonIndex ?? 0]?.[
|
|
meta.ringIndex ?? 0
|
|
]?.[meta.coordIndex] ?? null
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const isTypingElement = (target: EventTarget | null) => {
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
const tagName = target.tagName.toLowerCase();
|
|
return (
|
|
tagName === "input" ||
|
|
tagName === "textarea" ||
|
|
tagName === "select" ||
|
|
target.isContentEditable
|
|
);
|
|
};
|
|
|
|
// 관종별 원본 레이어 색을 유하방향 선/화살표에 재사용할 때 투명도만 조정한다.
|
|
const withAlpha = (color: string | null | undefined, alpha: number) => {
|
|
const resolvedColor = String(color ?? "").trim();
|
|
if (!resolvedColor) return `rgba(40, 90, 180, ${alpha})`;
|
|
|
|
const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i);
|
|
if (rgbaMatch) {
|
|
const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "")
|
|
.split(",")
|
|
.map((part) => part.trim());
|
|
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
}
|
|
|
|
const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
if (hexMatch) {
|
|
const hex = hexMatch[1] ?? "";
|
|
const normalized =
|
|
hex.length === 3
|
|
? hex
|
|
.split("")
|
|
.map((char) => char + char)
|
|
.join("")
|
|
: hex;
|
|
const red = parseInt(normalized.slice(0, 2), 16);
|
|
const green = parseInt(normalized.slice(2, 4), 16);
|
|
const blue = parseInt(normalized.slice(4, 6), 16);
|
|
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
}
|
|
|
|
return resolvedColor;
|
|
};
|
|
|
|
// 화살표 위치와 대표 선분 선택을 위해 LineString 좌표열의 대략적인 길이를 계산한다.
|
|
const estimateLineLength = (coordinates: number[][]) => {
|
|
let length = 0;
|
|
for (let i = 1; i < coordinates.length; i += 1) {
|
|
const previous = coordinates[i - 1];
|
|
const current = coordinates[i];
|
|
if (!previous || !current) continue;
|
|
|
|
const [x1 = 0, y1 = 0] = previous;
|
|
const [x2 = 0, y2 = 0] = current;
|
|
length += Math.hypot(x2 - x1, y2 - y1);
|
|
}
|
|
return length;
|
|
};
|
|
|
|
// MultiLineString 관로는 가장 긴 선분을 대표 선으로 삼아 화살표 위치와 각도를 안정적으로 계산한다.
|
|
const getRepresentativeLineCoordinates = (geometry: any): number[][] => {
|
|
const type = geometry?.getType?.();
|
|
const coordinates = geometry?.getCoordinates?.();
|
|
|
|
if (type === "LineString") return coordinates ?? [];
|
|
if (type === "MultiLineString" && Array.isArray(coordinates)) {
|
|
return coordinates.reduce(
|
|
(longest: number[][], current: number[][]) =>
|
|
estimateLineLength(current) > estimateLineLength(longest)
|
|
? current
|
|
: longest,
|
|
[],
|
|
);
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
// 관로 중앙 지점이 속한 실제 선분을 찾아 화살표 위치와 해당 선분의 방향각을 함께 계산한다.
|
|
const getArrowPlacementAtHalfLength = (coordinates: number[][]) => {
|
|
if (coordinates.length === 0) return null;
|
|
if (coordinates.length === 1) {
|
|
const coordinate = coordinates[0];
|
|
return coordinate ? { coordinate, angle: 0 } : null;
|
|
}
|
|
|
|
const totalLength = estimateLineLength(coordinates);
|
|
if (totalLength === 0) {
|
|
const coordinate = coordinates[Math.floor(coordinates.length / 2)];
|
|
return coordinate ? { coordinate, angle: 0 } : null;
|
|
}
|
|
|
|
let accumulated = 0;
|
|
const targetLength = totalLength / 2;
|
|
let lastSegmentAngle = 0;
|
|
|
|
for (let i = 1; i < coordinates.length; i += 1) {
|
|
const previous = coordinates[i - 1];
|
|
const current = coordinates[i];
|
|
if (!previous || !current) continue;
|
|
|
|
const [previousX = 0, previousY = 0] = previous;
|
|
const [currentX = 0, currentY = 0] = current;
|
|
const segmentLength = Math.hypot(
|
|
currentX - previousX,
|
|
currentY - previousY,
|
|
);
|
|
if (segmentLength > 0) {
|
|
lastSegmentAngle = Math.atan2(currentY - previousY, currentX - previousX);
|
|
}
|
|
|
|
if (accumulated + segmentLength >= targetLength) {
|
|
const ratio =
|
|
segmentLength === 0 ? 0 : (targetLength - accumulated) / segmentLength;
|
|
return {
|
|
coordinate: [
|
|
previousX + (currentX - previousX) * ratio,
|
|
previousY + (currentY - previousY) * ratio,
|
|
],
|
|
angle: lastSegmentAngle,
|
|
};
|
|
}
|
|
|
|
accumulated += segmentLength;
|
|
}
|
|
|
|
const coordinate = coordinates[coordinates.length - 1];
|
|
return coordinate ? { coordinate, angle: lastSegmentAngle } : null;
|
|
};
|
|
|
|
// SVG 없이 Canvas renderer로 짧은 꼬리선과 화살촉을 직접 그려 방향성을 분명하게 만든다.
|
|
const createFlowDirectionArrowRenderer = (
|
|
color: string,
|
|
angle: number,
|
|
scale: number,
|
|
) => {
|
|
return (coordinates: any, state: any) => {
|
|
const pixel = Array.isArray(coordinates?.[0])
|
|
? coordinates[0]
|
|
: coordinates;
|
|
const [x, y] = pixel ?? [];
|
|
const context = state?.context as CanvasRenderingContext2D | undefined;
|
|
if (!context || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
|
|
|
const length = 17 * scale;
|
|
const tailLength = 9 * scale;
|
|
const headLength = 7 * scale;
|
|
const headHalfWidth = 4.5 * scale;
|
|
const shaftWidth = 2.4 * scale;
|
|
|
|
context.save();
|
|
context.translate(x, y);
|
|
const viewRotation = Number(state?.rotation ?? 0);
|
|
const screenAngle =
|
|
(Number.isFinite(viewRotation) ? viewRotation : 0) - angle;
|
|
|
|
context.rotate(screenAngle);
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
|
|
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
|
|
context.lineWidth = shaftWidth + 2.4;
|
|
context.beginPath();
|
|
context.moveTo(-tailLength, 0);
|
|
context.lineTo(length - headLength, 0);
|
|
context.stroke();
|
|
|
|
context.fillStyle = "rgba(255, 255, 255, 0.9)";
|
|
context.beginPath();
|
|
context.moveTo(length, 0);
|
|
context.lineTo(length - headLength, -headHalfWidth);
|
|
context.lineTo(length - headLength, headHalfWidth);
|
|
context.closePath();
|
|
context.fill();
|
|
|
|
context.strokeStyle = color;
|
|
context.lineWidth = shaftWidth;
|
|
context.beginPath();
|
|
context.moveTo(-tailLength, 0);
|
|
context.lineTo(length - headLength, 0);
|
|
context.stroke();
|
|
|
|
context.fillStyle = color;
|
|
context.beginPath();
|
|
context.moveTo(length, 0);
|
|
context.lineTo(length - headLength, -headHalfWidth);
|
|
context.lineTo(length - headLength, headHalfWidth);
|
|
context.closePath();
|
|
context.fill();
|
|
|
|
context.restore();
|
|
};
|
|
};
|
|
|
|
// 유하방향 신뢰도와 관종 색상에 따라 선과 화살표 스타일을 만든다. LOW는 운영 검수 대상이라 더 흐리게 표시한다.
|
|
const createFlowDirectionStyle = (
|
|
feature: any,
|
|
usageStyleMap: Record<string, FlowDirectionUsageStyle>,
|
|
) => {
|
|
const confidence = String(
|
|
feature.get("directionConfidence") ??
|
|
feature.get("direction_confidence") ??
|
|
"",
|
|
).toUpperCase();
|
|
const geometry = feature.getGeometry();
|
|
const coordinates = getRepresentativeLineCoordinates(geometry);
|
|
const arrowPlacement = getArrowPlacementAtHalfLength(coordinates);
|
|
const usageCode = String(
|
|
feature.get("usgCd") ?? feature.get("usg_cd") ?? "",
|
|
).trim();
|
|
const usageStyle = usageStyleMap[usageCode];
|
|
const baseColor = usageStyle?.color ?? FLOW_DIRECTION_DEFAULT_COLOR;
|
|
const isNetworkCorrected =
|
|
String(
|
|
feature.get("networkCorrectionYn") ??
|
|
feature.get("network_correction_yn") ??
|
|
"",
|
|
).toUpperCase() === "Y";
|
|
|
|
const styleByConfidence: Record<
|
|
string,
|
|
{
|
|
color: string;
|
|
lineColor: string;
|
|
scale: number;
|
|
width: number;
|
|
lineDash?: number[];
|
|
}
|
|
> = {
|
|
HIGH: {
|
|
color: baseColor,
|
|
lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42),
|
|
scale: 1,
|
|
width: 1.3,
|
|
},
|
|
MEDIUM: {
|
|
color: baseColor,
|
|
lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42),
|
|
scale: 1,
|
|
width: 1.3,
|
|
},
|
|
LOW: {
|
|
color: withAlpha(baseColor, 0.72),
|
|
lineColor: withAlpha(baseColor, 0.28),
|
|
scale: 0.9,
|
|
width: 1.1,
|
|
},
|
|
};
|
|
const style = isNetworkCorrected
|
|
? {
|
|
color: FLOW_DIRECTION_NETWORK_CORRECTION_COLOR,
|
|
lineColor: withAlpha(FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, 0.5),
|
|
scale: 1.08,
|
|
width: 2,
|
|
lineDash: [8, 4],
|
|
}
|
|
: (styleByConfidence[confidence] ?? styleByConfidence.LOW!);
|
|
const styles = [
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: style.lineColor,
|
|
width: style.width,
|
|
lineDash: style.lineDash,
|
|
lineCap: "round",
|
|
lineJoin: "round",
|
|
}),
|
|
}),
|
|
];
|
|
|
|
if (arrowPlacement) {
|
|
styles.push(
|
|
new Style({
|
|
geometry: new Point(arrowPlacement.coordinate),
|
|
renderer: createFlowDirectionArrowRenderer(
|
|
style.color,
|
|
arrowPlacement.angle,
|
|
style.scale,
|
|
),
|
|
}),
|
|
);
|
|
}
|
|
|
|
return styles;
|
|
};
|
|
|
|
// 레이어 설정의 직접 코드와 filterSql 안의 usg_cd 조건을 모두 관종 코드 후보로 해석한다.
|
|
const extractUsageCodesFromLayerInfo = (layerInfo: any) => {
|
|
const usageCodes = new Set<string>();
|
|
const directCode = layerInfo?.usgCd ?? layerInfo?.usg_cd;
|
|
if (directCode) usageCodes.add(String(directCode).trim());
|
|
|
|
const filterSql = String(layerInfo?.flterSql ?? layerInfo?.filterSql ?? "");
|
|
const equalsMatches = filterSql.matchAll(/usg_cd\s*=\s*'([^']+)'/gi);
|
|
for (const match of equalsMatches) {
|
|
const code = match[1]?.trim();
|
|
if (code) usageCodes.add(code);
|
|
}
|
|
|
|
const inMatches = filterSql.matchAll(/usg_cd\s+in\s*\(([^)]+)\)/gi);
|
|
for (const match of inMatches) {
|
|
const codeList = match[1];
|
|
if (!codeList) continue;
|
|
|
|
codeList
|
|
.split(",")
|
|
.map((item) => item.replace(/'/g, "").trim())
|
|
.filter(Boolean)
|
|
.forEach((code) => usageCodes.add(code));
|
|
}
|
|
|
|
return Array.from(usageCodes);
|
|
};
|
|
|
|
// 현재 줌에서 실제 표출 가능한 관로 레이어인지 확인해 숨겨진 관종의 유하방향 요청을 막는다.
|
|
const isLayerVisibleAtZoom = (layerInfo: any, zoom: number) => {
|
|
const minZoom = getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom);
|
|
return minZoom === null || zoom >= minZoom;
|
|
};
|
|
|
|
// 현재 체크되어 화면에 보이는 관로 레이어의 관종 코드와 선 색상을 유하방향 조회/스타일 기준으로 변환한다.
|
|
const extractCheckedPipeUsageStyles = (
|
|
checkedNodesData: any[],
|
|
zoom: number,
|
|
) => {
|
|
const usageStyleMap: Record<string, FlowDirectionUsageStyle> = {};
|
|
|
|
checkedNodesData.forEach((node) => {
|
|
const data = node?.data ?? node;
|
|
if (!data || isFlowDirectionLayerInfo(data)) return;
|
|
if (getLayerTypeName(data) !== FLOW_DIRECTION_PIPE_LAYER_TYPE) return;
|
|
if (!isLayerVisibleAtZoom(data, zoom)) return;
|
|
|
|
const usageCodes = extractUsageCodesFromLayerInfo(data);
|
|
if (usageCodes.length === 0) return;
|
|
|
|
const layerColor = String(
|
|
data.lnColor ?? data.lineColor ?? FLOW_DIRECTION_DEFAULT_COLOR,
|
|
).trim();
|
|
usageCodes.forEach((code) => {
|
|
usageStyleMap[code] = {
|
|
color: layerColor,
|
|
lineColor: withAlpha(layerColor, 0.28),
|
|
};
|
|
});
|
|
});
|
|
|
|
return usageStyleMap;
|
|
};
|
|
|
|
const appendAgedPipeControlNode = (data: any[]) => {
|
|
if (!Array.isArray(data)) return data;
|
|
const isAgedPipeControlNode = (item: any) =>
|
|
String(item.lyrSn ?? item.id) === AGED_PIPE_CONTROL_NODE_ID ||
|
|
item.lyrNm === "노후관로";
|
|
|
|
if (data.some(isAgedPipeControlNode)) {
|
|
return data.map((item) =>
|
|
isAgedPipeControlNode(item)
|
|
? {
|
|
...item,
|
|
gobjTy: "line",
|
|
iconNm: null,
|
|
lnColor: AGED_PIPE_HIGHLIGHT_COLOR,
|
|
}
|
|
: item,
|
|
);
|
|
}
|
|
|
|
return [
|
|
...data,
|
|
{
|
|
lyrSn: AGED_PIPE_CONTROL_NODE_ID,
|
|
id: AGED_PIPE_CONTROL_NODE_ID,
|
|
lyrNm: "노후관로",
|
|
upperLyrSn: null,
|
|
upId: null,
|
|
sortOrdr: "4",
|
|
drawOrdr: null,
|
|
flterSql: null,
|
|
gobjTy: "line",
|
|
lyrTyNm: "tb_old_pipe",
|
|
useYn: "Y",
|
|
minViewSize: null,
|
|
iconNm: null,
|
|
iconSize: null,
|
|
lblAtrbNm: null,
|
|
fontNm: null,
|
|
fontIntnColor: null,
|
|
fontExtnColor: null,
|
|
fontExtnWdth: null,
|
|
lnWdth: null,
|
|
lnColor: AGED_PIPE_HIGHLIGHT_COLOR,
|
|
polgnColor: null,
|
|
initEprssYn: "Y",
|
|
clickPsblYn: "N",
|
|
exprsYn: "Y",
|
|
cmncNm: "all",
|
|
srvcNm: "wfs",
|
|
actlLyrYn: "N",
|
|
clckZoom: null,
|
|
children: null,
|
|
},
|
|
];
|
|
};
|
|
|
|
const normalizeTreeRowSpacing = (data: any[]) => {
|
|
if (!Array.isArray(data)) return data;
|
|
|
|
return data.map((item) => {
|
|
if (item?.lyrNm !== "지반침하정보") {
|
|
return item;
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
iconNm: item.iconNm || SINKHOLE_TREE_ICON_NAME,
|
|
gobjTy: "",
|
|
};
|
|
});
|
|
};
|
|
|
|
const appendSinkholeControlNode = (data: any[]) => {
|
|
if (!Array.isArray(data)) return data;
|
|
const existingRoot = data.find((item) => {
|
|
const nodeId = String(item?.lyrSn ?? item?.id ?? "");
|
|
const layerName = String(item?.lyrNm ?? item?.name ?? "").trim();
|
|
const layerTypeName = String(
|
|
item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "",
|
|
).trim();
|
|
return (
|
|
nodeId === SINKHOLE_CONTROL_NODE_ID ||
|
|
layerName === SINKHOLE_LAYER_NAME ||
|
|
layerTypeName === SINKHOLE_LAYER_TYPE
|
|
);
|
|
});
|
|
|
|
const existingRootId =
|
|
existingRoot?.lyrSn ?? existingRoot?.id ?? SINKHOLE_CONTROL_NODE_ID;
|
|
const filtered = data.filter((item) => {
|
|
const nodeId = String(item?.lyrSn ?? item?.id ?? "");
|
|
const upperId = String(item?.upperLyrSn ?? item?.upId ?? "");
|
|
const layerName = String(item?.lyrNm ?? item?.name ?? "").trim();
|
|
const layerTypeName = String(
|
|
item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "",
|
|
).trim();
|
|
|
|
if (nodeId === String(existingRootId)) return false;
|
|
if (upperId === String(existingRootId)) return false;
|
|
if (!existingRoot && nodeId === SINKHOLE_CONTROL_NODE_ID) return false;
|
|
if (!existingRoot && upperId === SINKHOLE_CONTROL_NODE_ID) return false;
|
|
if (!existingRoot && layerName === SINKHOLE_LAYER_NAME) return false;
|
|
if (!existingRoot && layerTypeName === SINKHOLE_LAYER_TYPE) return false;
|
|
return true;
|
|
});
|
|
|
|
const rootNode = existingRoot
|
|
? {
|
|
...existingRoot,
|
|
lyrSn: existingRootId,
|
|
id: existingRootId,
|
|
gobjTy: "",
|
|
iconNm: existingRoot.iconNm || SINKHOLE_TREE_ICON_NAME,
|
|
clickPsblYn: "N",
|
|
actlLyrYn: "N",
|
|
children: null,
|
|
}
|
|
: {
|
|
lyrSn: SINKHOLE_CONTROL_NODE_ID,
|
|
id: SINKHOLE_CONTROL_NODE_ID,
|
|
lyrNm: SINKHOLE_LAYER_NAME,
|
|
upperLyrSn: null,
|
|
upId: null,
|
|
sortOrdr: "3",
|
|
drawOrdr: null,
|
|
flterSql: null,
|
|
gobjTy: "",
|
|
lyrTyNm: SINKHOLE_LAYER_TYPE,
|
|
useYn: "Y",
|
|
minViewSize: null,
|
|
iconNm: SINKHOLE_TREE_ICON_NAME,
|
|
iconSize: null,
|
|
lblAtrbNm: null,
|
|
fontNm: null,
|
|
fontIntnColor: null,
|
|
fontExtnColor: null,
|
|
fontExtnWdth: null,
|
|
lnWdth: null,
|
|
lnColor: null,
|
|
polgnColor: null,
|
|
initEprssYn: "Y",
|
|
clickPsblYn: "N",
|
|
exprsYn: "Y",
|
|
cmncNm: "all",
|
|
srvcNm: "api",
|
|
actlLyrYn: "N",
|
|
clckZoom: null,
|
|
children: null,
|
|
};
|
|
|
|
return [...filtered, rootNode];
|
|
};
|
|
|
|
const Map = () => {
|
|
const searchParams = useSearchParams();
|
|
const pathname = usePathname();
|
|
const alert = useAlertStore((state) => state.setMessage);
|
|
const exmnMapEntryContext = useMemo(
|
|
() => buildExmnMapEntryContext(searchParams),
|
|
[searchParams],
|
|
);
|
|
const initialAnalysisPopupKey = useMemo(() => {
|
|
const popupKey = searchParams.get("analysisPopupKey");
|
|
if (!popupKey || !isWindowPopupKey(popupKey)) return null;
|
|
if (popupKey !== "inftwtResultPopup" && popupKey !== "rdiiResultPopup")
|
|
return null;
|
|
return popupKey;
|
|
}, [searchParams]);
|
|
const flowDirectionMode = useMemo(
|
|
() => getFlowDirectionModeByPathname(pathname),
|
|
[pathname],
|
|
);
|
|
const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore();
|
|
const userInfo = useUserStore((state) => state.userInfo);
|
|
const sinkholeScope = useMemo(() => buildSinkholeScope(userInfo), [userInfo]);
|
|
|
|
const [treeData, setTreeData] = useState<any[]>([]); // 초기값을 객체 또는 null로 설정
|
|
const [layerLoadError, setLayerLoadError] = useState<string | null>(null);
|
|
const gisMaps = useRef<any | null>(null);
|
|
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
|
|
const [featureData, setFeatureData] = useState<any>(null); // 선택된 피쳐 데이터
|
|
// 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다.
|
|
const [rightPanelTabs, setRightPanelTabs] = useState<RightPanelTab[]>([]);
|
|
const [activeRightPanelTabId, setActiveRightPanelTabId] = useState<
|
|
string | null
|
|
>(null);
|
|
// 단일 관로 탭이 어느 지반침하 상세에서 열렸는지 비동기 피처 조회 사이에 보관한다.
|
|
const pipeParentTabIdRef = useRef<string | null>(null);
|
|
const [isWaterDepthLayerActive, setIsWaterDepthLayerActive] = useState(false); // 침수심 레이어 활성 여부
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [mapReady, setMapReady] = useState(false);
|
|
const [checkedLayerLegendVersion, setCheckedLayerLegendVersion] = useState(0);
|
|
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
|
|
const [isSinkholeChecked, setIsSinkholeChecked] = useState(false);
|
|
const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false);
|
|
const [isExmnPplChecked, setIsExmnPplChecked] = useState(false);
|
|
const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false);
|
|
const [exmnPplFilters, setExmnPplFilters] = useState<ExmnMapFilters>(() => ({
|
|
...EMPTY_EXMN_MAP_FILTERS,
|
|
bizCycl: exmnMapEntryContext.bizCycl,
|
|
}));
|
|
const [exmnMnhlFilters, setExmnMnhlFilters] = useState<ExmnMapFilters>(
|
|
() => ({
|
|
...EMPTY_EXMN_MAP_FILTERS,
|
|
bizCycl: exmnMapEntryContext.bizCycl,
|
|
}),
|
|
);
|
|
const [swagePrcsFcltLayerMode, setSwagePrcsFcltLayerMode] =
|
|
useState<SwagePrcsFcltLayerMode>("all");
|
|
const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState<
|
|
string[]
|
|
>([]);
|
|
const [draftAgedPipeFilters, setDraftAgedPipeFilters] =
|
|
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
|
const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] =
|
|
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
|
const [sinkholeYearRange, setSinkholeYearRange] = useState<SinkholeYearRange>(
|
|
() => buildSinkholeDefaultYearRange(),
|
|
);
|
|
const [appliedSinkholeFilters, setAppliedSinkholeFilters] =
|
|
useState<SinkholeFilters>(() =>
|
|
normalizeSinkholeFilters(
|
|
buildSinkholeFiltersFromYearRange(
|
|
buildSinkholeDefaultYearRange(),
|
|
sinkholeScope,
|
|
),
|
|
sinkholeScope,
|
|
),
|
|
);
|
|
const [isSinkholePopupFilterActive, setIsSinkholePopupFilterActive] =
|
|
useState(false);
|
|
const [sinkholeRadiusMeters, setSinkholeRadiusMeters] = useState(
|
|
SINKHOLE_DEFAULT_RADIUS,
|
|
);
|
|
const [selectedFldngHstryDetail, setSelectedFldngHstryDetail] =
|
|
useState<FldngHstryItem | null>(null);
|
|
const [fldngHstryDetailPopupPosition, setFldngHstryDetailPopupPosition] =
|
|
useState<{ left: number; top: number } | null>(null);
|
|
const [selectedSinkholeDetail, setSelectedSinkholeDetail] =
|
|
useState<SinkholeDetail | null>(null);
|
|
const [selectedSinkholeNearbyPipes, setSelectedSinkholeNearbyPipes] =
|
|
useState<SinkholeNearbyPipe[]>([]);
|
|
const [lastSinkholeSyncSummary, setLastSinkholeSyncSummary] =
|
|
useState<SinkholeSyncSummary | null>(null);
|
|
const [sinkholeSelectionVersion, setSinkholeSelectionVersion] = useState(0);
|
|
const [isAgedPipeListOpen, setIsAgedPipeListOpen] = useState(false);
|
|
const [isAgedPipeListMinimized, setIsAgedPipeListMinimized] = useState(false);
|
|
const [agedPipeRows, setAgedPipeRows] = useState<AgedPipeRow[]>([]);
|
|
const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0);
|
|
const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1);
|
|
const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false);
|
|
const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] =
|
|
useState("");
|
|
const [isGisEditSaving, setIsGisEditSaving] = useState(false);
|
|
const { ctpv, sgg } = useStore();
|
|
// 조사시설 관로번호는 전국에서 중복될 수 있어 시군구코드 앞 5자리와 함께 GIS 원천을 조회한다.
|
|
const exmnMapRegionCodes = useMemo(() => {
|
|
const ctpvOption = ctpv.find(
|
|
(option) =>
|
|
String(option.text ?? "").trim() === exmnMapEntryContext.ctpvNm,
|
|
);
|
|
const ctpvCd = String(ctpvOption?.val ?? "0").trim() || "0";
|
|
const sggOption = sgg.find((option) => {
|
|
const code = String(option.val ?? "").trim();
|
|
return (
|
|
String(option.text ?? "").trim() === exmnMapEntryContext.sggNm &&
|
|
(ctpvCd === "0" || code.startsWith(ctpvCd.slice(0, 2)))
|
|
);
|
|
});
|
|
|
|
return {
|
|
ctpvCd,
|
|
sggCd: String(sggOption?.val ?? "0").trim() || "0",
|
|
};
|
|
}, [ctpv, exmnMapEntryContext.ctpvNm, exmnMapEntryContext.sggNm, sgg]);
|
|
// GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다.
|
|
const gisEdit = useGisEdit(treeData);
|
|
// 침입수 분석결과 popup과 지도 highlight 흐름은 별도 hook으로 묶어 기존 지도 로직과 충돌을 줄인다.
|
|
const inftwtResultMap = useInftwtResultMap({
|
|
gisMaps,
|
|
setFeatureData,
|
|
setIsOpen,
|
|
setIsSearchModalOpen,
|
|
});
|
|
const rdiiResultMap = useRdiiResultMap({
|
|
gisMaps,
|
|
setFeatureData,
|
|
setIsOpen,
|
|
setIsSearchModalOpen,
|
|
});
|
|
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
|
|
const agedPipePopupWindowRef = useRef<Window | null>(null);
|
|
const sinkholePopupWindowRef = useRef<Window | null>(null);
|
|
// 침수발생이력 overlay layer는 지도 드래그 중에도 유지돼야 하므로 React state 대신 ref로 보관한다.
|
|
const fldngHstryAreaLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
|
const fldngHstryDepthLayerRef = useRef<VectorLayer<VectorSource> | null>(
|
|
null,
|
|
);
|
|
const sinkholePopupCloseWatcherRef = useRef<number | null>(null);
|
|
const pipeAnalysisPopupWindowRef = useRef<Window | null>(null);
|
|
const gisEditSessionPopupWindowRef = useRef<Window | null>(null);
|
|
const gisEditSessionPopupCloseWatcherRef = useRef<number | null>(null);
|
|
const [gisEditSessionView, setGisEditSessionView] = useState<
|
|
"inline" | "popup"
|
|
>("inline");
|
|
const [gisEditDetailChangeId, setGisEditDetailChangeId] = useState<
|
|
string | null
|
|
>(null);
|
|
const gisEditDetailChangeRef = useRef<any>(null);
|
|
const gisEditDetailFeatureDataRef = useRef<any>(null);
|
|
const gisEditIsEditModeRef = useRef(false);
|
|
// vertex Delete는 현재 선택된 주황색 실꼭지점 묶음을 ref로 기억해 키보드 이벤트에서 즉시 사용한다.
|
|
const gisEditSelectedVertexHandleKeyRef = useRef("");
|
|
const gisEditSelectedVertexMetaListRef = useRef<VertexHandleMeta[] | null>(
|
|
null,
|
|
);
|
|
const gisEditMoveGripMetaRef = useRef<{
|
|
changeId: string;
|
|
meta: VertexHandleMeta | null;
|
|
} | null>(null);
|
|
const waterRiskPopupWindowRef = useRef<Window | null>(null);
|
|
const waterRiskPopupCloseWatcherRef = useRef<number | null>(null);
|
|
const sinkholeLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
const sinkholeSourceRef = useRef<VectorSource | null>(null);
|
|
const sinkholeSelectedLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
const sinkholeSelectedSourceRef = useRef<VectorSource | null>(null);
|
|
const sinkholeFocusedPipeLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
const sinkholeFocusedPipeSourceRef = useRef<VectorSource | null>(null);
|
|
const exmnPplLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
const exmnPplSourceRef = useRef<VectorSource | null>(null);
|
|
const exmnMnhlLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
const exmnMnhlSourceRef = useRef<VectorSource | null>(null);
|
|
const exmnPplRequestSeqRef = useRef(0);
|
|
const exmnMnhlRequestSeqRef = useRef(0);
|
|
const exmnInitialRegionAppliedRef = useRef(false);
|
|
// 조사관리에서 지도보기로 최초 진입했을 때만 조회된 대표 시설 한 건으로 지도를 맞춘다.
|
|
const exmnInitialFeatureFitAppliedRef = useRef(false);
|
|
// 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다.
|
|
const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false);
|
|
// 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다.
|
|
const [flowDirectionLayerInfo, setFlowDirectionLayerInfo] = useState<
|
|
any | null
|
|
>(null);
|
|
// 관종 레이어 체크 변경도 유하방향 재조회 조건이므로 별도 버전 state로 effect를 다시 실행한다.
|
|
const [flowDirectionSelectionVersion, setFlowDirectionSelectionVersion] =
|
|
useState(0);
|
|
// 줌레벨 컨트롤에 현재 체크된 특수 레이어의 최소 표출 기준을 안내한다.
|
|
const zoomGuide = useMemo(() => {
|
|
const flowDirectionGuideZoom = flowDirectionLayerInfo
|
|
? getFlowDirectionMinZoom(flowDirectionLayerInfo)
|
|
: FLOW_DIRECTION_DEFAULT_MIN_ZOOM;
|
|
|
|
if (isAgedPipeChecked && isFlowDirectionChecked) {
|
|
return {
|
|
label: "노후관로/유하방향 기준",
|
|
zoom: Math.max(AGED_PIPE_GUIDE_MIN_ZOOM, flowDirectionGuideZoom),
|
|
};
|
|
}
|
|
|
|
if (isFlowDirectionChecked) {
|
|
return {
|
|
label: "유하방향 기준",
|
|
zoom: flowDirectionGuideZoom,
|
|
};
|
|
}
|
|
|
|
if (isAgedPipeChecked) {
|
|
return {
|
|
label: "노후관로 기준",
|
|
zoom: AGED_PIPE_GUIDE_MIN_ZOOM,
|
|
};
|
|
}
|
|
|
|
return undefined;
|
|
}, [flowDirectionLayerInfo, isAgedPipeChecked, isFlowDirectionChecked]);
|
|
// moveend 이벤트 핸들러는 최신 state closure를 보장하지 않으므로 현재 체크 여부를 ref로 함께 보관한다.
|
|
const flowDirectionCheckedRef = useRef(false);
|
|
// 지도 이동 후 재조회할 때 최신 유하방향 레이어 설정을 참조하기 위한 ref이다.
|
|
const flowDirectionLayerInfoRef = useRef<any | null>(null);
|
|
// 유하방향 API의 관종 필터를 만들기 위해 현재 체크된 트리 노드 목록을 ref로 보관한다.
|
|
const checkedNodesDataRef = useRef<any[]>([]);
|
|
// 유하방향 전용 vector layer/source를 유지해 지도 이동 시 기존 피처를 지우지 않고 새 BBOX 응답만 병합한다.
|
|
const flowDirectionLayerRef = useRef<any | null>(null);
|
|
const flowDirectionSourceRef = useRef<VectorSource | null>(null);
|
|
// 같은 관로가 겹치는 BBOX에서 반복 조회될 수 있으므로 고유키와 삽입 순서를 캐시로 관리한다.
|
|
const flowDirectionFeatureKeySetRef = useRef<Set<string>>(new Set());
|
|
const flowDirectionFeatureKeyQueueRef = useRef<string[]>([]);
|
|
// 관종 체크 변경 시 다른 관종 유하방향을 유지하기 위해 현재 source에 남겨둘 관종 코드를 별도로 추적한다.
|
|
const flowDirectionVisibleUsageCodesRef = useRef<Set<string>>(new Set());
|
|
// 관종 체크 변경으로 새로 켜진 usgCd만 다음 조회 대상으로 넘겨 기존 관종 재요청을 막는다.
|
|
const flowDirectionSelectionRequestUsageCodesRef = useRef<string[] | null>(
|
|
null,
|
|
);
|
|
// 체크 해제나 재조회 중 늦게 도착한 이전 API 응답이 레이어를 다시 그리지 못하도록 요청 순번을 관리한다.
|
|
const flowDirectionRequestSeqRef = useRef(0);
|
|
// 지도 이동이 연속으로 발생하면 이전 유하방향 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다.
|
|
const flowDirectionAbortControllerRef = useRef<AbortController | null>(null);
|
|
// 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다.
|
|
const flowDirectionFailureAlertedRef = useRef(false);
|
|
const gisEditPreviewLayerRef = useRef<any | null>(null);
|
|
const gisEditPreviewSourceRef = useRef<VectorSource | null>(null);
|
|
const gisEditPipeSnapSourceRef = useRef<VectorSource | null>(null);
|
|
const gisEditPipeSnapAbortControllerRef = useRef<AbortController | null>(
|
|
null,
|
|
);
|
|
const gisEditPipeSnapRequestSeqRef = useRef(0);
|
|
const gisEditOriginalLayerFilterRef = useRef<
|
|
globalThis.Map<any, string | null>
|
|
>(new globalThis.Map());
|
|
const gisEditOriginalFeatureStyleRef = useRef<globalThis.Map<any, any>>(
|
|
new globalThis.Map(),
|
|
);
|
|
const gisEditVertexLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
const gisEditVertexSourceRef = useRef<VectorSource | null>(null);
|
|
const waterRiskAreaLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
const waterRiskAreaSourceRef = useRef<VectorSource | null>(null);
|
|
const waterRiskPipeLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
const waterRiskPipeSourceRef = useRef<VectorSource | null>(null);
|
|
const waterRiskManholeLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
const waterRiskManholeSourceRef = useRef<VectorSource | null>(null);
|
|
const gisEditDrawRef = useRef<any | null>(null);
|
|
const gisEditDrawSnapRef = useRef<any | null>(null);
|
|
const gisEditTranslateRef = useRef<any | null>(null);
|
|
const gisEditModifyRef = useRef<any | null>(null);
|
|
// 스냅 interaction은 MOVE/VERTEX_EDIT 진입마다 다시 붙이므로 별도 ref로 정리한다.
|
|
const gisEditSnapRef = useRef<any | null>(null);
|
|
const [waterRiskSession, setWaterRiskSession] =
|
|
useState<WaterRiskSessionSnapshot>(EMPTY_WATER_RISK_SESSION_SNAPSHOT);
|
|
// 팝업에서 실제 API로 조회한 결과 행과 GeoJSON을 지도·정보카드의 단일 원본으로 보관한다.
|
|
const [selectedWaterRiskApiResult, setSelectedWaterRiskApiResult] =
|
|
useState<WaterRiskResultRow | null>(null);
|
|
const [selectedWaterRiskMapView, setSelectedWaterRiskMapView] =
|
|
useState<WaterRiskMapView | null>(null);
|
|
const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<
|
|
[number, number] | null
|
|
>(null);
|
|
const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState<Record<
|
|
string,
|
|
number
|
|
> | null>(null);
|
|
const gisEditVisibleSessionChanges = useMemo(
|
|
() => gisEdit.changes.filter(isVisibleGisEditSessionChange),
|
|
[gisEdit.changes],
|
|
);
|
|
const shouldShowGisEditSession = gisEdit.isEditMode;
|
|
const isGisEditSessionInlineVisible =
|
|
shouldShowGisEditSession && gisEditSessionView === "inline";
|
|
const gisEditSessionSnapshot = useMemo<GisEditSessionSnapshot>(
|
|
() => ({
|
|
isEditMode: gisEdit.isEditMode,
|
|
selectedLayerName: gisEdit.selectedLayer?.layerName ?? "",
|
|
activeTool: gisEdit.activeTool,
|
|
isSnapEnabled: gisEdit.isSnapEnabled,
|
|
changes: gisEditVisibleSessionChanges,
|
|
lastSavedPayload: gisEdit.lastSavedPayload,
|
|
canUndo: gisEdit.canUndo,
|
|
focusedChangeId: gisEdit.focusedChangeId,
|
|
}),
|
|
[
|
|
gisEdit.activeTool,
|
|
gisEdit.canUndo,
|
|
gisEdit.focusedChangeId,
|
|
gisEdit.isEditMode,
|
|
gisEdit.isSnapEnabled,
|
|
gisEdit.lastSavedPayload,
|
|
gisEdit.selectedLayer,
|
|
gisEditVisibleSessionChanges,
|
|
],
|
|
);
|
|
const gisEditDetailChange = useMemo(
|
|
() =>
|
|
gisEdit.changes.find(
|
|
(change) => change.changeId === gisEditDetailChangeId,
|
|
) ?? null,
|
|
[gisEdit.changes, gisEditDetailChangeId],
|
|
);
|
|
const gisEditDetailFeatureData = useMemo(() => {
|
|
if (!gisEditDetailChange) return null;
|
|
if (featureData && !isGisEditPreviewFeatureData(featureData))
|
|
return featureData;
|
|
|
|
if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl") return null;
|
|
|
|
const savedFeatureProperties = gisEditDetailChange.featureProperties ?? {};
|
|
return {
|
|
...savedFeatureProperties,
|
|
lyrTypeNm: "tb_ms_swage_ppl",
|
|
fcltMngNo:
|
|
gisEditDetailChange.featureRef?.fcltMngNo ??
|
|
savedFeatureProperties["fclt_mng_no"] ??
|
|
gisEditDetailChange.objectId ??
|
|
"",
|
|
mngInstCd:
|
|
gisEditDetailChange.featureRef?.mngInstCd ??
|
|
savedFeatureProperties["mng_inst_cd"] ??
|
|
"",
|
|
fclt_mng_no:
|
|
gisEditDetailChange.featureRef?.fcltMngNo ??
|
|
savedFeatureProperties["fclt_mng_no"] ??
|
|
gisEditDetailChange.objectId ??
|
|
"",
|
|
mng_inst_cd:
|
|
gisEditDetailChange.featureRef?.mngInstCd ??
|
|
savedFeatureProperties["mng_inst_cd"] ??
|
|
"",
|
|
};
|
|
}, [featureData, gisEditDetailChange]);
|
|
gisEditDetailChangeRef.current = gisEditDetailChange;
|
|
gisEditDetailFeatureDataRef.current = gisEditDetailFeatureData;
|
|
gisEditIsEditModeRef.current = gisEdit.isEditMode;
|
|
const waterRiskSessionSnapshot = useMemo<WaterRiskSessionSnapshot>(
|
|
() => waterRiskSession,
|
|
[waterRiskSession],
|
|
);
|
|
const syncGisEditSessionPopup = useCallback(() => {
|
|
const popup = gisEditSessionPopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
return;
|
|
}
|
|
|
|
popup.postMessage(
|
|
{
|
|
type: WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC,
|
|
payload: gisEditSessionSnapshot,
|
|
},
|
|
window.location.origin,
|
|
);
|
|
}, [gisEditSessionSnapshot]);
|
|
|
|
const closeGisEditSessionPopup = useCallback(() => {
|
|
if (gisEditSessionPopupCloseWatcherRef.current) {
|
|
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
|
|
gisEditSessionPopupCloseWatcherRef.current = null;
|
|
}
|
|
|
|
const popup = gisEditSessionPopupWindowRef.current;
|
|
if (popup && !popup.closed) {
|
|
popup.close();
|
|
}
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
}, []);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (gisEditSessionPopupCloseWatcherRef.current) {
|
|
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
|
|
gisEditSessionPopupCloseWatcherRef.current = null;
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const openGisEditPipeDetail = useCallback((changeId: string | null) => {
|
|
if (!changeId) {
|
|
setGisEditDetailChangeId(null);
|
|
return;
|
|
}
|
|
setGisEditDetailChangeId(changeId);
|
|
}, []);
|
|
|
|
const focusGisEditChangeOnMap = useCallback(
|
|
(changeId: string) => {
|
|
const map = gisMaps.current;
|
|
const change = gisEdit.changes.find((item) => item.changeId === changeId);
|
|
const geometryObject =
|
|
change?.editType === "DELETE"
|
|
? change?.beforeGeometry
|
|
: change?.afterGeometry;
|
|
if (!map || !geometryObject) return;
|
|
|
|
try {
|
|
const geometry = new GeoJSON().readGeometry(geometryObject, {
|
|
dataProjection: FLOW_DIRECTION_SRS,
|
|
featureProjection: GisApp.TO_PROJ,
|
|
});
|
|
const extent = geometry?.getExtent?.();
|
|
if (
|
|
!extent ||
|
|
extent.length < 4 ||
|
|
extent.some((value: number) => !Number.isFinite(value))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const view = map.getView?.();
|
|
const currentZoom = Number(view?.getZoom?.());
|
|
const maxZoom =
|
|
Number.isFinite(currentZoom) && currentZoom > 18 ? currentZoom : 18;
|
|
view?.fit?.(extent, {
|
|
duration: 350,
|
|
padding: [80, 420, 80, 80],
|
|
maxZoom,
|
|
});
|
|
} catch (error) {
|
|
console.warn("GIS edit change focus failed:", error);
|
|
}
|
|
},
|
|
[gisEdit.changes],
|
|
);
|
|
|
|
// 인라인 세션과 윈도우 팝업이 동일한 지도 focus/상세 열기 흐름을 공유한다.
|
|
const handleFocusGisEditSessionChange = useCallback(
|
|
(changeId: string) => {
|
|
if (!changeId) return;
|
|
gisEdit.focusChange(changeId);
|
|
focusGisEditChangeOnMap(changeId);
|
|
setFeatureData(null);
|
|
openGisEditPipeDetail(changeId);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[
|
|
focusGisEditChangeOnMap,
|
|
gisEdit,
|
|
openGisEditPipeDetail,
|
|
setIsSearchModalOpen,
|
|
],
|
|
);
|
|
|
|
const syncWaterRiskPopup = useCallback(() => {
|
|
const popup = waterRiskPopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
waterRiskPopupWindowRef.current = null;
|
|
return;
|
|
}
|
|
|
|
popup.postMessage(
|
|
{
|
|
type: WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC,
|
|
payload: waterRiskSessionSnapshot,
|
|
},
|
|
window.location.origin,
|
|
);
|
|
}, [waterRiskSessionSnapshot]);
|
|
|
|
const selectedWaterRiskResultRow = useMemo(
|
|
() => selectedWaterRiskApiResult,
|
|
[selectedWaterRiskApiResult],
|
|
);
|
|
|
|
// 지도카드는 실제 GIS geometry까지 결합된 mapView 위험상태를 목록 상태보다 우선한다.
|
|
const selectedWaterRiskDisplayStatus = useMemo<
|
|
WaterRiskResultRow["riskStatus"]
|
|
>(
|
|
() =>
|
|
selectedWaterRiskMapView?.riskStatus ??
|
|
selectedWaterRiskResultRow?.riskStatus ??
|
|
"NORMAL",
|
|
[selectedWaterRiskMapView?.riskStatus, selectedWaterRiskResultRow?.riskStatus],
|
|
);
|
|
|
|
/** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */
|
|
const syncWaterRiskInfoCardPosition = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map || !waterRiskInfoAnchor) {
|
|
setWaterRiskInfoCardStyle(null);
|
|
return;
|
|
}
|
|
|
|
const [mapWidth = 0, mapHeight = 0] = map.getSize?.() ?? [];
|
|
const [pixelX = 0, pixelY = 0] =
|
|
map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? [];
|
|
const cardWidth = 244;
|
|
const cardHeight = 180;
|
|
const left = pixelX + 22;
|
|
const top = pixelY - cardHeight / 2;
|
|
|
|
if (
|
|
pixelX < 0 ||
|
|
pixelY < 0 ||
|
|
pixelX > mapWidth ||
|
|
pixelY > mapHeight ||
|
|
left > mapWidth ||
|
|
top > mapHeight ||
|
|
left + cardWidth < 0 ||
|
|
top + cardHeight < 0
|
|
) {
|
|
setWaterRiskInfoCardStyle(null);
|
|
return;
|
|
}
|
|
|
|
setWaterRiskInfoCardStyle({ left, top });
|
|
}, [waterRiskInfoAnchor]);
|
|
|
|
const setGisEditPreviewCursor = useCallback((cursor: string) => {
|
|
const targetElement = gisMaps.current?.getTargetElement?.();
|
|
if (targetElement?.style) {
|
|
targetElement.style.cursor = cursor;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!mapReady ||
|
|
exmnInitialRegionAppliedRef.current ||
|
|
(!exmnMapEntryContext.ctpvNm && !exmnMapEntryContext.sggNm)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const { ctpvCd, sggCd } = exmnMapRegionCodes;
|
|
|
|
// 시군구가 전달된 진입에서는 시도만 먼저 적용해 버리지 않고 두 코드가 모두 준비될 때까지 기다린다.
|
|
if (
|
|
(exmnMapEntryContext.ctpvNm && ctpvCd === "0") ||
|
|
(exmnMapEntryContext.sggNm && sggCd === "0")
|
|
) {
|
|
return;
|
|
}
|
|
exmnInitialRegionAppliedRef.current = true;
|
|
window.dispatchEvent(
|
|
new CustomEvent(EXMN_PROC_SEARCH_EVENT, {
|
|
detail: {
|
|
ctpvCd,
|
|
sggCd,
|
|
},
|
|
}),
|
|
);
|
|
}, [
|
|
exmnMapEntryContext.ctpvNm,
|
|
exmnMapEntryContext.sggNm,
|
|
exmnMapRegionCodes,
|
|
mapReady,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setAppliedSinkholeFilters((currentFilters) => {
|
|
if (isSinkholePopupFilterActive) {
|
|
return normalizeSinkholeFilters(currentFilters, sinkholeScope);
|
|
}
|
|
return normalizeSinkholeFilters(
|
|
buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope),
|
|
sinkholeScope,
|
|
);
|
|
});
|
|
}, [isSinkholePopupFilterActive, sinkholeScope, sinkholeYearRange]);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => {
|
|
if (gisMaps.current) {
|
|
setMapReady(true);
|
|
window.clearInterval(timer);
|
|
}
|
|
}, 300);
|
|
|
|
const handlePopupMessage = async (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
const data = event.data;
|
|
const core = GisApp.Module.layer as any;
|
|
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE) {
|
|
const layerNm = data.payload?.layerNm;
|
|
const fid = data.payload?.fid;
|
|
if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return;
|
|
|
|
await core.showSearchResultsFromGeoJSON(layerNm, fid);
|
|
return;
|
|
}
|
|
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE) {
|
|
const layerNm = data.payload?.layerNm;
|
|
const fidsRaw = data.payload?.fids;
|
|
if (!layerNm || !fidsRaw || !core?.showMultipleSearchResultsFromGeoJSON)
|
|
return;
|
|
|
|
let fids: string[] = [];
|
|
try {
|
|
fids = JSON.parse(fidsRaw);
|
|
} catch (error) {
|
|
console.error("다중 시설 메시지 파싱 실패:", error);
|
|
return;
|
|
}
|
|
|
|
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
|
|
return;
|
|
}
|
|
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FIT_EXTENT) {
|
|
const extent = Array.isArray(data.payload?.extent)
|
|
? data.payload.extent.map(Number)
|
|
: [];
|
|
const map = gisMaps.current;
|
|
if (
|
|
extent.length !== 4 ||
|
|
extent.some((value: number) => !Number.isFinite(value)) ||
|
|
!map?.getView
|
|
) {
|
|
return;
|
|
}
|
|
map.getView().fit(extent, {
|
|
duration: 300,
|
|
padding: [50, 50, 50, 50],
|
|
maxZoom: 16,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP
|
|
) {
|
|
const extent = Array.isArray(data.payload?.extent)
|
|
? data.payload.extent.map(Number)
|
|
: [];
|
|
const sggCd = String(data.payload?.sggCd ?? "").trim();
|
|
const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim();
|
|
const map = gisMaps.current;
|
|
const select = GisApp.Module.select as any;
|
|
if (
|
|
extent.length !== 4 ||
|
|
extent.some((value: number) => !Number.isFinite(value)) ||
|
|
!/^\d{5}$/.test(sggCd) ||
|
|
!/^(030|050|080|100)$/.test(frequency) ||
|
|
!map?.getView ||
|
|
!select?.selectFocusedBoundaryFeature
|
|
) {
|
|
return;
|
|
}
|
|
|
|
await select.resetSelectedFeature?.();
|
|
const selectedFeatureIds = new Set<string>();
|
|
let floodLayerVisible = false;
|
|
const selectLoadedGroupFeatures = (source: any) => {
|
|
const features = source?.getFeatures?.() ?? [];
|
|
features.forEach((feature: any) => {
|
|
const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim();
|
|
const featureFrequency = String(
|
|
feature.get?.("sbmrg_frq_expln") ?? "",
|
|
).trim();
|
|
if (featureSggCd !== sggCd || featureFrequency !== frequency)
|
|
return;
|
|
const featureId = String(
|
|
feature.getId?.() ?? feature.get?.("fid") ?? "",
|
|
);
|
|
if (featureId && selectedFeatureIds.has(featureId)) return;
|
|
if (featureId) selectedFeatureIds.add(featureId);
|
|
select.selectFocusedBoundaryFeature(feature, true);
|
|
});
|
|
};
|
|
|
|
map
|
|
.getLayers()
|
|
.getArray()
|
|
.forEach((layer: any) => {
|
|
if (
|
|
String(layer.get?.("lyrTyNm") ?? "").trim() !==
|
|
FLOOD_DNGR_MAP_LAYER_TYPE
|
|
)
|
|
return;
|
|
if (layer.getVisible?.() !== false) floodLayerVisible = true;
|
|
const source = layer.getSource?.();
|
|
if (!source?.getFeatures) return;
|
|
selectLoadedGroupFeatures(source);
|
|
source.once?.("featuresloadend", () =>
|
|
selectLoadedGroupFeatures(source),
|
|
);
|
|
});
|
|
if (!floodLayerVisible) {
|
|
event.source?.postMessage?.(
|
|
{ type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED },
|
|
event.origin,
|
|
);
|
|
return;
|
|
}
|
|
map.getView().fit(extent, {
|
|
duration: 300,
|
|
padding: [50, 50, 50, 50],
|
|
maxZoom: 16,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED) {
|
|
const rawLayerTypeNames = data.payload?.refreshLayerTypeNames;
|
|
let layerTypeNames = ["tb_ms_swage_ppl"];
|
|
if (rawLayerTypeNames) {
|
|
try {
|
|
const parsed = JSON.parse(rawLayerTypeNames);
|
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
layerTypeNames = parsed
|
|
.map((item) => String(item ?? "").trim())
|
|
.filter(Boolean);
|
|
}
|
|
} catch (error) {
|
|
console.error("GIS 업로드 레이어 refresh 메시지 파싱 실패:", error);
|
|
}
|
|
}
|
|
|
|
const refreshTargetNames = new Set(layerTypeNames);
|
|
gisMaps.current?.getAllLayers?.().forEach((layer: any) => {
|
|
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
|
|
if (!refreshTargetNames.has(layerTypeName)) return;
|
|
|
|
const source = layer.getSource?.();
|
|
source?.updateParams?.({ _t: Date.now() });
|
|
source?.refresh?.();
|
|
source?.changed?.();
|
|
layer.changed?.();
|
|
});
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handlePopupMessage);
|
|
return () => {
|
|
window.clearInterval(timer);
|
|
window.removeEventListener("message", handlePopupMessage);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (shouldShowGisEditSession) return;
|
|
|
|
const popup = gisEditSessionPopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
return;
|
|
}
|
|
|
|
popup.close();
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
}, [shouldShowGisEditSession]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
const popup = gisEditSessionPopupWindowRef.current;
|
|
if (popup && !popup.closed) {
|
|
popup.close();
|
|
}
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!gisEdit.isEditMode) {
|
|
setGisEditDetailChangeId(null);
|
|
return;
|
|
}
|
|
|
|
if (!gisEditDetailChangeId) return;
|
|
if (!gisEditDetailChange) {
|
|
setGisEditDetailChangeId(null);
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
gisEditDetailChange.layerId !== "tb_ms_swage_ppl" ||
|
|
gisEditDetailChange.editType === "DELETE"
|
|
) {
|
|
setGisEditDetailChangeId(null);
|
|
}
|
|
}, [gisEdit.isEditMode, gisEditDetailChange, gisEditDetailChangeId]);
|
|
|
|
useEffect(() => {
|
|
if (!gisEdit.isEditMode) return;
|
|
syncGisEditSessionPopup();
|
|
}, [
|
|
gisEdit.isEditMode,
|
|
gisEditVisibleSessionChanges.length,
|
|
syncGisEditSessionPopup,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const handleGisEditSessionPopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
const data = event.data;
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_READY) {
|
|
syncGisEditSessionPopup();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SHOW_INLINE
|
|
) {
|
|
closeGisEditSessionPopup();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE
|
|
) {
|
|
const changeId = String(data.payload?.changeId ?? "").trim();
|
|
handleFocusGisEditSessionChange(changeId);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type ===
|
|
WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE
|
|
) {
|
|
gisEdit.removeFocusedChange();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type ===
|
|
WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE
|
|
) {
|
|
gisEdit.removeLastChange();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES
|
|
) {
|
|
gisEdit.clearChanges();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleGisEditSessionPopupMessage);
|
|
return () => {
|
|
window.removeEventListener("message", handleGisEditSessionPopupMessage);
|
|
};
|
|
}, [
|
|
closeGisEditSessionPopup,
|
|
gisEdit,
|
|
handleFocusGisEditSessionChange,
|
|
syncGisEditSessionPopup,
|
|
]);
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const data = await getRawJson(
|
|
"/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK",
|
|
);
|
|
setTreeData(
|
|
appendExmnResultControlNodes(
|
|
appendAgedPipeControlNode(
|
|
appendSinkholeControlNode(
|
|
normalizeTreeRowSpacing(
|
|
normalizeRainObservationLayerInteraction(
|
|
normalizeFlowDirectionLayerInfos(data),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
exmnMapEntryContext.jobType,
|
|
),
|
|
);
|
|
setLayerLoadError(null);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "레이어 설정 조회 중 알 수 없는 오류가 발생했습니다.";
|
|
console.error("레이어 설정 조회 실패:", error);
|
|
setTreeData([]);
|
|
setLayerLoadError(message);
|
|
}
|
|
};
|
|
|
|
const clearWaterRiskPopupCloseWatcher = useCallback(() => {
|
|
if (waterRiskPopupCloseWatcherRef.current !== null) {
|
|
window.clearInterval(waterRiskPopupCloseWatcherRef.current);
|
|
waterRiskPopupCloseWatcherRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const ensureWaterRiskAreaLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (waterRiskAreaLayerRef.current && waterRiskAreaSourceRef.current) {
|
|
return waterRiskAreaSourceRef.current;
|
|
}
|
|
|
|
const layers = map.getLayers?.().getArray?.() ?? [];
|
|
layers.forEach((existingLayer: any) => {
|
|
if (existingLayer?.get?.("id") === GIS_EDIT_PREVIEW_LAYER_ID) {
|
|
map.removeLayer(existingLayer);
|
|
}
|
|
});
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 9996,
|
|
});
|
|
|
|
layer.set("id", WATER_RISK_AREA_LAYER_ID);
|
|
layer.set("lyrNm", "통수위험 중점관리지역");
|
|
layer.set("lyrTyNm", WATER_RISK_AREA_LAYER_ID);
|
|
map.addLayer(layer);
|
|
waterRiskAreaLayerRef.current = layer;
|
|
waterRiskAreaSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const ensureWaterRiskPipeLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (waterRiskPipeLayerRef.current && waterRiskPipeSourceRef.current) {
|
|
return waterRiskPipeSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 9998,
|
|
});
|
|
|
|
layer.set("id", WATER_RISK_PIPE_LAYER_ID);
|
|
layer.set("lyrNm", "통수위험 유출관로");
|
|
layer.set("lyrTyNm", WATER_RISK_PIPE_LAYER_ID);
|
|
map.addLayer(layer);
|
|
waterRiskPipeLayerRef.current = layer;
|
|
waterRiskPipeSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const ensureWaterRiskManholeLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (waterRiskManholeLayerRef.current && waterRiskManholeSourceRef.current) {
|
|
return waterRiskManholeSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({ source, zIndex: 9999 });
|
|
layer.set("id", WATER_RISK_MANHOLE_LAYER_ID);
|
|
layer.set("lyrNm", "통수위험 맨홀 Flooding");
|
|
layer.set("lyrTyNm", WATER_RISK_MANHOLE_LAYER_ID);
|
|
map.addLayer(layer);
|
|
waterRiskManholeLayerRef.current = layer;
|
|
waterRiskManholeSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const clearWaterRiskLayers = useCallback(() => {
|
|
waterRiskAreaSourceRef.current?.clear();
|
|
waterRiskPipeSourceRef.current?.clear();
|
|
waterRiskManholeSourceRef.current?.clear();
|
|
}, []);
|
|
|
|
/** 통수위험 팝업 종료 시 지도 강조·범례·정보카드 선택상태를 모두 초기화한다. */
|
|
const clearWaterRiskSelection = useCallback(() => {
|
|
clearWaterRiskLayers();
|
|
setWaterRiskSession(EMPTY_WATER_RISK_SESSION_SNAPSHOT);
|
|
setSelectedWaterRiskApiResult(null);
|
|
setSelectedWaterRiskMapView(null);
|
|
setWaterRiskInfoAnchor(null);
|
|
setWaterRiskInfoCardStyle(null);
|
|
}, [clearWaterRiskLayers]);
|
|
|
|
const renderWaterRiskSelection = useCallback(
|
|
(areaId: string | null, mapView: WaterRiskMapView | null) => {
|
|
const map = gisMaps.current;
|
|
if (!map) return;
|
|
|
|
const areaSource = ensureWaterRiskAreaLayer();
|
|
if (!areaSource) return;
|
|
|
|
areaSource.clear();
|
|
const pipeSource = ensureWaterRiskPipeLayer();
|
|
pipeSource?.clear();
|
|
const manholeSource = ensureWaterRiskManholeLayer();
|
|
manholeSource?.clear();
|
|
|
|
if (!areaId || !mapView || mapView.areaId !== areaId) return;
|
|
|
|
const geoJson = new GeoJSON();
|
|
const projection = mapView.projection || "EPSG:5179";
|
|
let focusGeometry: any | null = null;
|
|
|
|
if (mapView.areaGeometry) {
|
|
const geometry = geoJson.readGeometry(mapView.areaGeometry as any, {
|
|
dataProjection: projection,
|
|
featureProjection: "EPSG:5179",
|
|
});
|
|
const areaFeature = new Feature({
|
|
geometry,
|
|
areaId: mapView.areaId,
|
|
areaName: mapView.areaName,
|
|
riskStatus: mapView.riskStatus,
|
|
});
|
|
areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature));
|
|
areaSource.addFeature(areaFeature);
|
|
focusGeometry = geometry;
|
|
}
|
|
|
|
mapView.pipes.forEach((pipe) => {
|
|
if (!pipeSource || !pipe.geometry) return;
|
|
const geometry = geoJson.readGeometry(pipe.geometry as any, {
|
|
dataProjection: projection,
|
|
featureProjection: "EPSG:5179",
|
|
});
|
|
const pipeFeature = new Feature({
|
|
geometry,
|
|
pipeId: pipe.pipeId,
|
|
displayLabel: pipe.displayLabel || pipe.pipeId,
|
|
capacityRatio: pipe.capacityRatio ?? 0,
|
|
});
|
|
pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature));
|
|
pipeSource.addFeature(pipeFeature);
|
|
if (!focusGeometry) focusGeometry = geometry;
|
|
});
|
|
|
|
mapView.manholes.forEach((manhole) => {
|
|
if (!manholeSource || !manhole.geometry) return;
|
|
const geometry = geoJson.readGeometry(manhole.geometry as any, {
|
|
dataProjection: projection,
|
|
featureProjection: "EPSG:5179",
|
|
});
|
|
const manholeFeature = new Feature({
|
|
geometry,
|
|
manholeId: manhole.manholeId,
|
|
displayLabel: manhole.displayLabel || manhole.manholeId,
|
|
floodingFlow: manhole.floodingFlow ?? 0,
|
|
});
|
|
manholeFeature.setStyle(createWaterRiskManholeStyle(manholeFeature));
|
|
manholeSource.addFeature(manholeFeature);
|
|
if (!focusGeometry) focusGeometry = geometry;
|
|
});
|
|
|
|
if (!focusGeometry) return;
|
|
const extent = focusGeometry.getExtent();
|
|
const [, minY = 0, maxX = 0, maxY = 0] = extent;
|
|
setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]);
|
|
map.getView().fit(extent, {
|
|
duration: 300,
|
|
padding: [120, 120, 120, 120],
|
|
maxZoom: 15,
|
|
constrainResolution: true,
|
|
nearest: true,
|
|
});
|
|
},
|
|
[
|
|
ensureWaterRiskAreaLayer,
|
|
ensureWaterRiskManholeLayer,
|
|
ensureWaterRiskPipeLayer,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
if (!waterRiskSession.selectedAreaId) {
|
|
clearWaterRiskLayers();
|
|
setWaterRiskInfoAnchor(null);
|
|
setWaterRiskInfoCardStyle(null);
|
|
return;
|
|
}
|
|
|
|
renderWaterRiskSelection(
|
|
waterRiskSession.selectedAreaId,
|
|
selectedWaterRiskMapView,
|
|
);
|
|
}, [
|
|
clearWaterRiskLayers,
|
|
mapReady,
|
|
renderWaterRiskSelection,
|
|
selectedWaterRiskMapView,
|
|
waterRiskSession.selectedAreaId,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedWaterRiskMapView) setWaterRiskInfoAnchor(null);
|
|
}, [selectedWaterRiskMapView]);
|
|
|
|
useEffect(() => {
|
|
const popup = waterRiskPopupWindowRef.current;
|
|
if (!popup || popup.closed) return;
|
|
syncWaterRiskPopup();
|
|
}, [syncWaterRiskPopup, waterRiskSession]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current || !waterRiskInfoAnchor) return;
|
|
|
|
const map = gisMaps.current;
|
|
const syncDuringMapRender = () => syncWaterRiskInfoCardPosition();
|
|
syncWaterRiskInfoCardPosition();
|
|
map.on?.("postrender", syncDuringMapRender);
|
|
map.on?.("moveend", syncDuringMapRender);
|
|
|
|
return () => {
|
|
map.un?.("postrender", syncDuringMapRender);
|
|
map.un?.("moveend", syncDuringMapRender);
|
|
};
|
|
}, [mapReady, syncWaterRiskInfoCardPosition, waterRiskInfoAnchor]);
|
|
|
|
useEffect(() => {
|
|
const handleWaterRiskPopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
const data = event.data;
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY) {
|
|
syncWaterRiskPopup();
|
|
return;
|
|
}
|
|
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) {
|
|
const areaId = String(data.payload?.areaId ?? "").trim();
|
|
if (!areaId) return;
|
|
const resultRow = data.payload?.resultRow as
|
|
| WaterRiskResultRow
|
|
| undefined;
|
|
const mapView = data.payload?.mapView as WaterRiskMapView | undefined;
|
|
if (!resultRow || !mapView || mapView.areaId !== areaId) return;
|
|
setSelectedWaterRiskApiResult(resultRow);
|
|
setSelectedWaterRiskMapView(mapView);
|
|
setWaterRiskSession((current) => ({
|
|
...current,
|
|
selectedAreaId: areaId,
|
|
}));
|
|
setIsOpen(false);
|
|
setFeatureData(null);
|
|
return;
|
|
}
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLOSED) {
|
|
clearWaterRiskPopupCloseWatcher();
|
|
waterRiskPopupWindowRef.current = null;
|
|
clearWaterRiskSelection();
|
|
return;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleWaterRiskPopupMessage);
|
|
return () => {
|
|
window.removeEventListener("message", handleWaterRiskPopupMessage);
|
|
};
|
|
}, [
|
|
clearWaterRiskPopupCloseWatcher,
|
|
clearWaterRiskSelection,
|
|
syncWaterRiskPopup,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearWaterRiskPopupCloseWatcher();
|
|
const popup = waterRiskPopupWindowRef.current;
|
|
if (popup && !popup.closed) {
|
|
popup.close();
|
|
}
|
|
waterRiskPopupWindowRef.current = null;
|
|
};
|
|
}, [clearWaterRiskPopupCloseWatcher]);
|
|
|
|
// 유하방향은 일반 Gis core 레이어 등록 흐름을 타지 않으므로 전용 layer/source/cache를 직접 정리한다.
|
|
const removeFlowDirectionLayer = useCallback(() => {
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(FLOW_DIRECTION_LAYER_TYPE);
|
|
flowDirectionLayerRef.current = null;
|
|
flowDirectionSourceRef.current = null;
|
|
flowDirectionFeatureKeySetRef.current.clear();
|
|
flowDirectionFeatureKeyQueueRef.current = [];
|
|
flowDirectionVisibleUsageCodesRef.current.clear();
|
|
}, []);
|
|
|
|
// 유하방향 cache/source만 비워 관종 변경처럼 조건이 달라진 경우 이전 조건의 피처가 남지 않게 한다.
|
|
const clearFlowDirectionFeatureCache = useCallback(() => {
|
|
flowDirectionSourceRef.current?.clear();
|
|
flowDirectionFeatureKeySetRef.current.clear();
|
|
flowDirectionFeatureKeyQueueRef.current = [];
|
|
flowDirectionVisibleUsageCodesRef.current.clear();
|
|
}, []);
|
|
|
|
// 체크 해제된 관종의 유하방향 feature/cache만 제거하고, 새로 체크된 관종 코드만 다음 요청 대상으로 돌려준다.
|
|
const retainFlowDirectionUsageCodes = useCallback((usageCodes: string[]) => {
|
|
const nextUsageCodes = new Set(usageCodes);
|
|
const addedUsageCodes = usageCodes.filter(
|
|
(usageCode) => !flowDirectionVisibleUsageCodesRef.current.has(usageCode),
|
|
);
|
|
const source = flowDirectionSourceRef.current;
|
|
if (source) {
|
|
source.getFeatures().forEach((feature: any) => {
|
|
const usageCode = String(
|
|
feature.get("usgCd") ?? feature.get("usg_cd") ?? "",
|
|
).trim();
|
|
if (nextUsageCodes.has(usageCode)) return;
|
|
|
|
const featureKey = feature.get("flowDirectionCacheKey");
|
|
if (featureKey) {
|
|
flowDirectionFeatureKeySetRef.current.delete(String(featureKey));
|
|
}
|
|
source.removeFeature(feature);
|
|
});
|
|
flowDirectionFeatureKeyQueueRef.current =
|
|
flowDirectionFeatureKeyQueueRef.current.filter((featureKey) =>
|
|
flowDirectionFeatureKeySetRef.current.has(featureKey),
|
|
);
|
|
}
|
|
flowDirectionVisibleUsageCodesRef.current = new Set(
|
|
Array.from(flowDirectionVisibleUsageCodesRef.current).filter(
|
|
(usageCode) => nextUsageCodes.has(usageCode),
|
|
),
|
|
);
|
|
return addedUsageCodes;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!flowDirectionCheckedRef.current) return;
|
|
clearFlowDirectionFeatureCache();
|
|
flowDirectionSelectionRequestUsageCodesRef.current = null;
|
|
setFlowDirectionSelectionVersion((version) => version + 1);
|
|
}, [clearFlowDirectionFeatureCache, flowDirectionMode]);
|
|
|
|
// 유하방향 레이어는 하나만 유지하고 source에 피처를 누적한다.
|
|
const ensureFlowDirectionLayer = useCallback((layerInfo: any) => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (flowDirectionLayerRef.current && flowDirectionSourceRef.current) {
|
|
flowDirectionLayerRef.current.setMinZoom?.(
|
|
getFlowDirectionMinZoom(layerInfo),
|
|
);
|
|
flowDirectionLayerRef.current.setZIndex?.(
|
|
getFlowDirectionZIndex(layerInfo),
|
|
);
|
|
return flowDirectionSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
minZoom: getFlowDirectionMinZoom(layerInfo),
|
|
zIndex: getFlowDirectionZIndex(layerInfo),
|
|
updateWhileAnimating: false,
|
|
updateWhileInteracting: false,
|
|
});
|
|
|
|
layer.set("id", FLOW_DIRECTION_LAYER_TYPE);
|
|
layer.set("lyrNm", FLOW_DIRECTION_LAYER_NAME);
|
|
layer.set("lyrTyNm", FLOW_DIRECTION_LAYER_TYPE);
|
|
layer.set("typeName", FLOW_DIRECTION_LAYER_TYPE);
|
|
layer.set("clickPsblYn", "N");
|
|
layer.set("chcYn", "N");
|
|
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(FLOW_DIRECTION_LAYER_TYPE);
|
|
map.addLayer(layer);
|
|
flowDirectionLayerRef.current = layer;
|
|
flowDirectionSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
// BE에서 받은 보정 GeoJSON을 OpenLayers feature로 변환해 기존 유하방향 source에 중복 없이 병합한다.
|
|
const renderFlowDirectionLayer = useCallback(
|
|
(
|
|
featureCollection: any,
|
|
layerInfo: any,
|
|
usageStyleMap: Record<string, FlowDirectionUsageStyle>,
|
|
) => {
|
|
const source = ensureFlowDirectionLayer(layerInfo);
|
|
if (!source) return;
|
|
|
|
const features = new GeoJSON().readFeatures(featureCollection, {
|
|
dataProjection: FLOW_DIRECTION_SRS,
|
|
featureProjection: GisApp.TO_PROJ,
|
|
});
|
|
|
|
const nextFeatures = features.filter((feature) => {
|
|
const featureKey = getFlowDirectionFeatureCacheKey(feature);
|
|
if (flowDirectionFeatureKeySetRef.current.has(featureKey)) return false;
|
|
|
|
feature.set("flowDirectionCacheKey", featureKey);
|
|
feature.setId(featureKey);
|
|
feature.setStyle(createFlowDirectionStyle(feature, usageStyleMap));
|
|
flowDirectionFeatureKeySetRef.current.add(featureKey);
|
|
flowDirectionFeatureKeyQueueRef.current.push(featureKey);
|
|
return true;
|
|
});
|
|
|
|
if (nextFeatures.length > 0) {
|
|
source.addFeatures(nextFeatures);
|
|
}
|
|
},
|
|
[ensureFlowDirectionLayer],
|
|
);
|
|
|
|
// 현재 지도 BBOX와 체크된 관종별 필터로 유하방향 GeoJSON을 각각 조회하고, 최신 요청만 지도에 반영한다.
|
|
const loadFlowDirectionLayer = useCallback(
|
|
async (layerInfo: any, requestOnlyUsageCodes?: string[]) => {
|
|
const map = gisMaps.current;
|
|
if (!map) return;
|
|
|
|
const requestSeq = flowDirectionRequestSeqRef.current + 1;
|
|
flowDirectionRequestSeqRef.current = requestSeq;
|
|
flowDirectionAbortControllerRef.current?.abort();
|
|
const abortController = new AbortController();
|
|
flowDirectionAbortControllerRef.current = abortController;
|
|
|
|
const view = map.getView();
|
|
const zoom = view.getZoom() ?? 0;
|
|
const minZoom = getFlowDirectionMinZoom(layerInfo);
|
|
|
|
if (zoom < minZoom) {
|
|
removeFlowDirectionLayer();
|
|
return;
|
|
}
|
|
|
|
const size = map.getSize();
|
|
if (!size) return;
|
|
|
|
const usageStyleMap = extractCheckedPipeUsageStyles(
|
|
checkedNodesDataRef.current,
|
|
zoom,
|
|
);
|
|
const usageCodes = Object.keys(usageStyleMap);
|
|
if (usageCodes.length === 0) {
|
|
removeFlowDirectionLayer();
|
|
return;
|
|
}
|
|
retainFlowDirectionUsageCodes(usageCodes);
|
|
|
|
const usageCodeSet = new Set(usageCodes);
|
|
const requestUsageCodes = requestOnlyUsageCodes
|
|
? requestOnlyUsageCodes.filter((usageCode) =>
|
|
usageCodeSet.has(usageCode),
|
|
)
|
|
: usageCodes;
|
|
const isPartialUsageRequest = Array.isArray(requestOnlyUsageCodes);
|
|
if (requestUsageCodes.length === 0) {
|
|
if (flowDirectionAbortControllerRef.current === abortController) {
|
|
flowDirectionAbortControllerRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const screenExtent = view.calculateExtent(size);
|
|
const requestExtent = expandFlowDirectionExtent(screenExtent);
|
|
const screenBbox = formatFlowDirectionBbox(screenExtent);
|
|
const requestBbox = formatFlowDirectionBbox(requestExtent);
|
|
const baseParams = new URLSearchParams({
|
|
srs: FLOW_DIRECTION_SRS,
|
|
directionMode: flowDirectionMode,
|
|
searchStrategy: "BBOX",
|
|
bbox: requestBbox,
|
|
});
|
|
|
|
try {
|
|
const settledResponses = await Promise.allSettled(
|
|
requestUsageCodes.map(async (usageCode) => {
|
|
const params = new URLSearchParams(baseParams);
|
|
params.set("usgCd", usageCode);
|
|
const payload = await getRawJson(
|
|
`/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`,
|
|
{ signal: abortController.signal },
|
|
);
|
|
return {
|
|
usageCode,
|
|
featureCollection: getFeatureCollection(payload),
|
|
};
|
|
}),
|
|
);
|
|
if (requestSeq !== flowDirectionRequestSeqRef.current) return;
|
|
if (abortController.signal.aborted) return;
|
|
|
|
const responses = settledResponses
|
|
.filter(
|
|
(
|
|
result,
|
|
): result is PromiseFulfilledResult<{
|
|
usageCode: string;
|
|
featureCollection: any;
|
|
}> => result.status === "fulfilled",
|
|
)
|
|
.map((result) => result.value);
|
|
const failedResponses = settledResponses.filter(
|
|
(result) => result.status === "rejected",
|
|
);
|
|
|
|
if (responses.length === 0) {
|
|
if (!isPartialUsageRequest) {
|
|
removeFlowDirectionLayer();
|
|
}
|
|
throw (
|
|
failedResponses[0]?.reason ??
|
|
new Error("유하방향 관종별 조회 결과가 없습니다.")
|
|
);
|
|
}
|
|
|
|
if (failedResponses.length > 0) {
|
|
console.error(
|
|
"유하방향 일부 관종 조회 실패:",
|
|
failedResponses.map((result) => result.reason),
|
|
);
|
|
if (!flowDirectionFailureAlertedRef.current) {
|
|
flowDirectionFailureAlertedRef.current = true;
|
|
}
|
|
}
|
|
|
|
responses.forEach(({ usageCode, featureCollection }) => {
|
|
renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap);
|
|
flowDirectionVisibleUsageCodesRef.current.add(usageCode);
|
|
});
|
|
const returnedFeaturesByUsage = responses.reduce<
|
|
Record<string, number>
|
|
>((acc, { usageCode, featureCollection }) => {
|
|
acc[usageCode] = featureCollection.features?.length ?? 0;
|
|
return acc;
|
|
}, {});
|
|
const networkCorrectedFeatures = responses.reduce(
|
|
(count, { featureCollection }) => {
|
|
const correctedCount =
|
|
featureCollection.features?.filter(
|
|
(feature: any) =>
|
|
String(
|
|
feature?.properties?.networkCorrectionYn ??
|
|
feature?.properties?.network_correction_yn ??
|
|
"",
|
|
).toUpperCase() === "Y",
|
|
).length ?? 0;
|
|
return count + correctedCount;
|
|
},
|
|
0,
|
|
);
|
|
(window as any).__SN_FLOW_DIRECTION_DEBUG__ = {
|
|
screenBbox,
|
|
requestBbox,
|
|
requestSeq,
|
|
zoom,
|
|
directionMode: flowDirectionMode,
|
|
searchStrategy: "BBOX",
|
|
usageCodes,
|
|
requestUsageCodes,
|
|
requestCount: responses.length,
|
|
returnedFeaturesByUsage,
|
|
returnedFeatures: Object.values(returnedFeaturesByUsage).reduce(
|
|
(sum, count) => sum + count,
|
|
0,
|
|
),
|
|
networkCorrectedFeatures,
|
|
cachedFeatures:
|
|
flowDirectionSourceRef.current?.getFeatures?.().length ?? 0,
|
|
};
|
|
if (failedResponses.length === 0) {
|
|
flowDirectionFailureAlertedRef.current = false;
|
|
}
|
|
} catch (error) {
|
|
if (requestSeq !== flowDirectionRequestSeqRef.current) return;
|
|
if (
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
"name" in error &&
|
|
error.name === "AbortError"
|
|
)
|
|
return;
|
|
|
|
console.error("유하방향 레이어 조회 실패:", error);
|
|
if (!isPartialUsageRequest) {
|
|
removeFlowDirectionLayer();
|
|
}
|
|
if (!flowDirectionFailureAlertedRef.current) {
|
|
flowDirectionFailureAlertedRef.current = true;
|
|
}
|
|
} finally {
|
|
if (flowDirectionAbortControllerRef.current === abortController) {
|
|
flowDirectionAbortControllerRef.current = null;
|
|
}
|
|
}
|
|
},
|
|
[
|
|
flowDirectionMode,
|
|
removeFlowDirectionLayer,
|
|
renderFlowDirectionLayer,
|
|
retainFlowDirectionUsageCodes,
|
|
],
|
|
);
|
|
|
|
// SearchModal, GisContent 등에서 호출
|
|
const onFeatureSelect = (data: any) => {
|
|
const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data);
|
|
if (clickedLayerTypeName === "tb_ms_swage_ppl") {
|
|
pipeParentTabIdRef.current = null;
|
|
}
|
|
|
|
// 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다.
|
|
clearSinkholeFocusedPipeLayer();
|
|
if (rdiiResultMap.handleOverlayFeatureSelect(data)) {
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
if (inftwtResultMap.handleOverlayFeatureSelect(data)) {
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
gisEdit.isEditMode &&
|
|
(gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") &&
|
|
gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" &&
|
|
clickedLayerTypeName !== "tb_ms_swage_ppl"
|
|
) {
|
|
gisEdit.announceStatus("하수관로를 선택해주세요.");
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (clickedLayerTypeName === SINKHOLE_LAYER_TYPE) {
|
|
const acdntNo = getSinkholeAccidentId(data);
|
|
if (acdntNo) {
|
|
void loadSinkholeDetail(
|
|
acdntNo,
|
|
false,
|
|
getSinkholePointFromSelection(data),
|
|
getSinkholeFallbackDetailFromSelection(data),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 편집 도구가 켜져 있을 때는 상세 팝업보다 도구별 세션 처리를 우선한다.
|
|
// ADD는 Draw interaction만 geometry를 받아야 하므로 기존 관로 선택 이벤트는 무시한다.
|
|
let editSelection: any = gisEdit.registerFeatureSelection(data);
|
|
if (
|
|
!editSelection.handled &&
|
|
gisEdit.isEditMode &&
|
|
!gisEdit.activeTool &&
|
|
clickedLayerTypeName === "tb_ms_swage_ppl"
|
|
) {
|
|
editSelection = gisEdit.registerPipeDetailSelection(data);
|
|
}
|
|
if (editSelection.handled) {
|
|
const closeGisEditDetail = () => {
|
|
const select = GisApp.Module.select as any;
|
|
select?.resetSelectedFeature?.();
|
|
setFeatureData(null);
|
|
setGisEditDetailChangeId(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
};
|
|
const loadLatestEditSn = (changeId: string) => {
|
|
const fid =
|
|
editSelection.isPreviewSelection === true
|
|
? ""
|
|
: getGisEditFeatureFid(data);
|
|
if (!fid) return;
|
|
getRawJson(
|
|
`/sewerage-service/api/v1/map/gisEdit/swagePpl/histories?fid=${encodeURIComponent(fid)}`,
|
|
)
|
|
.then((historyResponse) => {
|
|
const latestEditSn = getLatestNumericEditSn(
|
|
historyResponse?.histories ?? [],
|
|
);
|
|
if (latestEditSn !== null) {
|
|
gisEdit.updateChangeBaseEditSn(changeId, String(latestEditSn));
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("GIS 편집 관로 이력 조회 실패:", error);
|
|
gisEdit.announceStatus(
|
|
"관로 이력 정보를 확인하지 못했습니다. 저장 시 충돌 검증이 제한될 수 있습니다.",
|
|
);
|
|
});
|
|
};
|
|
|
|
if (gisEdit.activeTool === "ADD" || gisEdit.activeTool === "DELETE") {
|
|
closeGisEditDetail();
|
|
return;
|
|
}
|
|
|
|
const isGeometryEditTargetSelection =
|
|
editSelection.isPreviewSelection !== true &&
|
|
(gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT");
|
|
|
|
if (isGeometryEditTargetSelection) {
|
|
if (
|
|
typeof editSelection.changeId === "string" &&
|
|
editSelection.changeId
|
|
) {
|
|
loadLatestEditSn(String(editSelection.changeId));
|
|
}
|
|
closeGisEditDetail();
|
|
return;
|
|
}
|
|
|
|
const canOpenPreviewPipeDetail =
|
|
clickedLayerTypeName === "tb_ms_swage_ppl" &&
|
|
editSelection.isPreviewSelection === true &&
|
|
editSelection.editType !== "DELETE";
|
|
const shouldOpenPipeDetail =
|
|
clickedLayerTypeName === "tb_ms_swage_ppl" &&
|
|
typeof editSelection.changeId === "string" &&
|
|
editSelection.changeId &&
|
|
(editSelection.isPreviewSelection !== true || canOpenPreviewPipeDetail);
|
|
|
|
if (shouldOpenPipeDetail) {
|
|
openGisEditPipeDetail(String(editSelection.changeId));
|
|
loadLatestEditSn(String(editSelection.changeId));
|
|
if (
|
|
editSelection.isPreviewSelection !== true &&
|
|
!isGeometryEditTargetSelection
|
|
) {
|
|
highlightGisEditSelectedFeature(data);
|
|
}
|
|
setFeatureData(data);
|
|
window.setTimeout(() => {
|
|
setGisEditDetailChangeId(String(editSelection.changeId));
|
|
setIsOpen(true);
|
|
}, 0);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
// 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해
|
|
// 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다.
|
|
closeGisEditDetail();
|
|
return;
|
|
}
|
|
|
|
let map = gisMaps.current;
|
|
if (!map) {
|
|
console.warn("지도가 초기화되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
// 항상 같은 select 객체 사용
|
|
const select = GisApp.Module.select as any;
|
|
if (!select) return;
|
|
|
|
// 1. 기존 선택 초기화
|
|
select.resetSelectedFeature();
|
|
|
|
// 2. data가 OpenLayers Feature 인스턴스인지 확인
|
|
let foundFeature = null;
|
|
if (
|
|
data &&
|
|
typeof data.getGeometry === "function" &&
|
|
typeof data.setStyle === "function"
|
|
) {
|
|
// 이미 Feature 인스턴스라면 그대로 사용
|
|
foundFeature = data;
|
|
} else {
|
|
// 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용
|
|
const featureId = data.fid || data.ftrIdn || data.id;
|
|
map
|
|
.getLayers()
|
|
.getArray()
|
|
.forEach((layer: any) => {
|
|
const source = layer.getSource?.();
|
|
if (source?.getFeatureById) {
|
|
const feature = source.getFeatureById(String(featureId).trim());
|
|
if (feature) foundFeature = feature;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 3. 피처 선택
|
|
if (foundFeature) {
|
|
let geometry = foundFeature.getGeometry();
|
|
if (geometry) {
|
|
let extent = geometry.getExtent();
|
|
|
|
const layerTypeName =
|
|
typeof foundFeature.getId === "function"
|
|
? String(foundFeature.getId()).split(".")[0]
|
|
: data?.lyrTypeNm;
|
|
const shouldUseAgedPipeFocus =
|
|
shouldFocusAgedPipeOnNextSelectRef.current &&
|
|
layerTypeName === AGED_PIPE_LAYER_TYPE;
|
|
|
|
map.getView().fit(extent, {
|
|
duration: 300,
|
|
padding: shouldUseAgedPipeFocus
|
|
? AGED_PIPE_FOCUS_PADDING
|
|
: DEFAULT_FEATURE_FIT_PADDING,
|
|
maxZoom: shouldUseAgedPipeFocus ? 18 : 16,
|
|
constrainResolution: true,
|
|
nearest: true,
|
|
});
|
|
|
|
if (shouldUseAgedPipeFocus) {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
|
}
|
|
}
|
|
select.selectFeature(foundFeature, true);
|
|
} else {
|
|
console.warn("OpenLayers Feature를 찾을 수 없습니다:", data);
|
|
}
|
|
|
|
setFeatureData(data);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
};
|
|
|
|
const swagePrcsFcltSearchFilter = useMemo(() => {
|
|
switch (swagePrcsFcltLayerMode) {
|
|
case "gte500":
|
|
return {
|
|
label: "하수처리시설(500톤 이상)",
|
|
cqlFilter: "fclt_cpct >= 500",
|
|
};
|
|
case "lt500":
|
|
return {
|
|
label: "하수처리시설(500톤 미만)",
|
|
cqlFilter: "fclt_cpct < 500",
|
|
};
|
|
case "none":
|
|
return { label: "하수처리시설(레이어 꺼짐)", disabled: true };
|
|
case "all":
|
|
default:
|
|
return { label: "하수처리시설(전체)" };
|
|
}
|
|
}, [swagePrcsFcltLayerMode]);
|
|
|
|
const facilityTypes = [
|
|
{ text: "하수처리시설", val: SWAGE_PRCS_FCLT_LAYER_TYPE },
|
|
];
|
|
|
|
const fetchAgedPipeRows = useCallback(
|
|
async (page: number, filters: AgedPipeFilters, size: number) => {
|
|
const normalized = normalizeAgedPipeFilters(filters);
|
|
const queryParams = new URLSearchParams({
|
|
lyrTypeNm: AGED_PIPE_LAYER_TYPE,
|
|
page: String(page - 1),
|
|
size: String(size),
|
|
sort: "string",
|
|
});
|
|
|
|
const cqlFilters = buildAgedPipeCqlFilters(normalized);
|
|
if (cqlFilters.length > 0) {
|
|
queryParams.append("cqlFiter", cqlFilters.join(" and "));
|
|
}
|
|
|
|
const response = await getData(`v1/fclts?${queryParams.toString()}`);
|
|
const rows = Array.isArray(response?.data) ? response.data : [];
|
|
|
|
return {
|
|
rows: rows.map((item: Record<string, unknown>, index: number) =>
|
|
toDisplayPipeRow(item, index, page, ctpv, sgg),
|
|
),
|
|
total: Number(response?.count ?? 0),
|
|
};
|
|
},
|
|
[ctpv, sgg],
|
|
);
|
|
|
|
const loadAgedPipeList = useCallback(
|
|
async (page = 1, filters = appliedAgedPipeFilters) => {
|
|
setIsAgedPipeListLoading(true);
|
|
try {
|
|
const response = await fetchAgedPipeRows(
|
|
page,
|
|
filters,
|
|
AGED_PIPE_PAGE_SIZE,
|
|
);
|
|
setAgedPipeRows(response.rows);
|
|
setAgedPipeTotalCount(response.total);
|
|
setAgedPipeCurrentPage(page);
|
|
} catch (error) {
|
|
console.error("노후관로 목록 조회 중 오류:", error);
|
|
setAgedPipeRows([]);
|
|
setAgedPipeTotalCount(0);
|
|
} finally {
|
|
setIsAgedPipeListLoading(false);
|
|
}
|
|
},
|
|
[appliedAgedPipeFilters, fetchAgedPipeRows],
|
|
);
|
|
|
|
const syncAgedPipePopupFilters = useCallback((filters: AgedPipeFilters) => {
|
|
const popup = agedPipePopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
agedPipePopupWindowRef.current = null;
|
|
return;
|
|
}
|
|
|
|
popup.postMessage(
|
|
{
|
|
type: AGED_PIPE_FILTER_SYNC_MESSAGE,
|
|
filters: normalizeAgedPipeFilters(filters),
|
|
},
|
|
window.location.origin,
|
|
);
|
|
}, []);
|
|
|
|
const handleApplyAgedPipeFilters = useCallback(
|
|
(filters = draftAgedPipeFilters) => {
|
|
if (!validateElapsedYears(Number(filters.elapsedYears))) {
|
|
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
const normalized = normalizeAgedPipeFilters(filters);
|
|
setDraftAgedPipeFilters(normalized);
|
|
setAppliedAgedPipeFilters(normalized);
|
|
syncAgedPipePopupFilters(normalized);
|
|
|
|
if (isAgedPipeListOpen) {
|
|
loadAgedPipeList(1, normalized);
|
|
}
|
|
},
|
|
[
|
|
draftAgedPipeFilters,
|
|
isAgedPipeListOpen,
|
|
loadAgedPipeList,
|
|
syncAgedPipePopupFilters,
|
|
],
|
|
);
|
|
|
|
const openAgedPipePopupWindow = useCallback(() => {
|
|
const popup = openWindowPopup("agedPipePopup", {
|
|
elapsedYears: String(appliedAgedPipeFilters.elapsedYears),
|
|
ctpvCd: appliedAgedPipeFilters.ctpvCd,
|
|
sggCd: appliedAgedPipeFilters.sggCd,
|
|
});
|
|
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
return null;
|
|
}
|
|
|
|
agedPipePopupWindowRef.current = popup;
|
|
setIsAgedPipeListOpen(true);
|
|
setIsAgedPipeListMinimized(false);
|
|
return popup;
|
|
}, [appliedAgedPipeFilters]);
|
|
|
|
const openAgedPipeList = useCallback(() => {
|
|
openAgedPipePopupWindow();
|
|
}, [openAgedPipePopupWindow]);
|
|
|
|
const removeExmnResultLayer = useCallback((jobType: ExmnMapJobType) => {
|
|
const layerId =
|
|
jobType === "PPL" ? EXMN_RESULT_PPL_LAYER_ID : EXMN_RESULT_MNHL_LAYER_ID;
|
|
const layerRef = jobType === "PPL" ? exmnPplLayerRef : exmnMnhlLayerRef;
|
|
const sourceRef = jobType === "PPL" ? exmnPplSourceRef : exmnMnhlSourceRef;
|
|
const requestSeqRef =
|
|
jobType === "PPL" ? exmnPplRequestSeqRef : exmnMnhlRequestSeqRef;
|
|
|
|
requestSeqRef.current += 1;
|
|
if (layerRef.current) {
|
|
(GisApp.Module.select as any)?.removeLayer?.(layerRef.current);
|
|
}
|
|
(GisApp.Module.core as any)?.deleteLayer?.(layerId);
|
|
layerRef.current = null;
|
|
sourceRef.current = null;
|
|
}, []);
|
|
|
|
const ensureExmnResultLayer = useCallback((jobType: ExmnMapJobType) => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
const layerRef = jobType === "PPL" ? exmnPplLayerRef : exmnMnhlLayerRef;
|
|
const sourceRef = jobType === "PPL" ? exmnPplSourceRef : exmnMnhlSourceRef;
|
|
if (layerRef.current && sourceRef.current) return sourceRef.current;
|
|
|
|
const layerId =
|
|
jobType === "PPL" ? EXMN_RESULT_PPL_LAYER_ID : EXMN_RESULT_MNHL_LAYER_ID;
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: jobType === "PPL" ? 9988 : 9989,
|
|
style: jobType === "PPL" ? createExmnPipeStyle : createExmnManholeStyle,
|
|
updateWhileAnimating: false,
|
|
updateWhileInteracting: false,
|
|
});
|
|
layer.set("id", layerId);
|
|
layer.set("lyrNm", jobType === "PPL" ? "관로조사" : "맨홀조사");
|
|
layer.set(
|
|
"lyrTyNm",
|
|
jobType === "PPL" ? "tb_ms_swage_ppl" : "tb_ms_swage_mhl",
|
|
);
|
|
layer.set("clickPsblYn", "Y");
|
|
layer.set("chcYn", "Y");
|
|
|
|
(GisApp.Module.core as any)?.deleteLayer?.(layerId);
|
|
map.addLayer(layer);
|
|
(GisApp.Module.select as any)?.addLayer?.(layer);
|
|
layerRef.current = layer;
|
|
sourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const loadExmnResultLayer = useCallback(
|
|
async (jobType: ExmnMapJobType, filters: ExmnMapFilters) => {
|
|
const isChecked =
|
|
jobType === "PPL" ? isExmnPplChecked : isExmnMnhlChecked;
|
|
if (!mapReady || !gisMaps.current || !isChecked) return;
|
|
|
|
const source = ensureExmnResultLayer(jobType);
|
|
if (!source) return;
|
|
const sggCodePrefix = exmnMapRegionCodes.sggCd
|
|
.replace(/[^0-9]/g, "")
|
|
.slice(0, 5);
|
|
// 공통 행정구역 코드가 준비되기 전에는 전국 중복 관로를 조회하지 않고 다음 상태 갱신을 기다린다.
|
|
if (sggCodePrefix.length !== 5) {
|
|
source.clear();
|
|
return;
|
|
}
|
|
const requestSeqRef =
|
|
jobType === "PPL" ? exmnPplRequestSeqRef : exmnMnhlRequestSeqRef;
|
|
const requestSeq = requestSeqRef.current + 1;
|
|
requestSeqRef.current = requestSeq;
|
|
|
|
try {
|
|
const response = await getExmnMapLayer(jobType, filters, {
|
|
sggCd: sggCodePrefix,
|
|
ctpvNm: exmnMapEntryContext.ctpvNm,
|
|
sggNm: exmnMapEntryContext.sggNm,
|
|
});
|
|
if (requestSeqRef.current !== requestSeq) return;
|
|
const featureCollection =
|
|
response?.data?.type === "FeatureCollection"
|
|
? response.data
|
|
: response;
|
|
const features = new GeoJSON().readFeatures(featureCollection, {
|
|
dataProjection: "EPSG:5179",
|
|
featureProjection: GisApp.TO_PROJ,
|
|
});
|
|
source.clear();
|
|
source.addFeatures(features);
|
|
|
|
// 표본 데이터가 적은 현재 단계에서는 최초 조사유형의 첫 시설을 기준으로 한 번만 확대한다.
|
|
if (
|
|
!exmnInitialFeatureFitAppliedRef.current &&
|
|
exmnMapEntryContext.jobType === jobType &&
|
|
features.length > 0
|
|
) {
|
|
const geometry = features[0]?.getGeometry?.();
|
|
const extent = geometry?.getExtent?.();
|
|
if (
|
|
extent &&
|
|
extent.length === 4 &&
|
|
extent.every((coordinate: number) => Number.isFinite(coordinate))
|
|
) {
|
|
gisMaps.current?.getView?.().fit?.(extent, {
|
|
duration: 300,
|
|
padding: [80, 420, 80, 80],
|
|
maxZoom: 18,
|
|
constrainResolution: true,
|
|
nearest: true,
|
|
});
|
|
exmnInitialFeatureFitAppliedRef.current = true;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (requestSeqRef.current !== requestSeq) return;
|
|
source.clear();
|
|
console.error(
|
|
`${jobType === "PPL" ? "관로조사" : "맨홀조사"} 레이어 조회 실패:`,
|
|
error,
|
|
);
|
|
}
|
|
},
|
|
[
|
|
ensureExmnResultLayer,
|
|
exmnMapEntryContext.ctpvNm,
|
|
exmnMapEntryContext.jobType,
|
|
exmnMapEntryContext.sggNm,
|
|
exmnMapRegionCodes.ctpvCd,
|
|
exmnMapRegionCodes.sggCd,
|
|
isExmnMnhlChecked,
|
|
isExmnPplChecked,
|
|
mapReady,
|
|
],
|
|
);
|
|
|
|
const removeSinkholeLayer = useCallback(() => {
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(SINKHOLE_LAYER_ID);
|
|
sinkholeLayerRef.current = null;
|
|
sinkholeSourceRef.current = null;
|
|
}, []);
|
|
|
|
const ensureSinkholeLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (sinkholeLayerRef.current && sinkholeSourceRef.current) {
|
|
return sinkholeSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 9992,
|
|
updateWhileAnimating: false,
|
|
updateWhileInteracting: false,
|
|
});
|
|
|
|
layer.set("id", SINKHOLE_LAYER_ID);
|
|
layer.set("lyrNm", SINKHOLE_LAYER_NAME);
|
|
layer.set("lyrTyNm", SINKHOLE_LAYER_TYPE);
|
|
layer.set("clickPsblYn", "Y");
|
|
layer.set("chcYn", "N");
|
|
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(SINKHOLE_LAYER_ID);
|
|
map.addLayer(layer);
|
|
sinkholeLayerRef.current = layer;
|
|
sinkholeSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const clearSinkholeSelectionLayer = useCallback(() => {
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(SINKHOLE_SELECTED_LAYER_ID);
|
|
sinkholeSelectedLayerRef.current = null;
|
|
sinkholeSelectedSourceRef.current = null;
|
|
}, []);
|
|
|
|
const clearSinkholeFocusedPipeLayer = useCallback(() => {
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.("SN_SINKHOLE_FOCUSED_PIPE_LAYER");
|
|
sinkholeFocusedPipeLayerRef.current = null;
|
|
sinkholeFocusedPipeSourceRef.current = null;
|
|
}, []);
|
|
|
|
const ensureSinkholeSelectionLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (sinkholeSelectedLayerRef.current && sinkholeSelectedSourceRef.current) {
|
|
return sinkholeSelectedSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 9996,
|
|
updateWhileAnimating: false,
|
|
updateWhileInteracting: false,
|
|
});
|
|
|
|
layer.set("id", SINKHOLE_SELECTED_LAYER_ID);
|
|
layer.set("lyrNm", `${SINKHOLE_LAYER_NAME}-선택`);
|
|
layer.set("lyrTyNm", SINKHOLE_LAYER_TYPE);
|
|
layer.set("clickPsblYn", "N");
|
|
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(SINKHOLE_SELECTED_LAYER_ID);
|
|
map.addLayer(layer);
|
|
sinkholeSelectedLayerRef.current = layer;
|
|
sinkholeSelectedSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const ensureSinkholeFocusedPipeLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (
|
|
sinkholeFocusedPipeLayerRef.current &&
|
|
sinkholeFocusedPipeSourceRef.current
|
|
) {
|
|
return sinkholeFocusedPipeSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 9999,
|
|
updateWhileAnimating: false,
|
|
updateWhileInteracting: false,
|
|
});
|
|
|
|
layer.set("id", "SN_SINKHOLE_FOCUSED_PIPE_LAYER");
|
|
layer.set("lyrNm", "지반침하-관로강조");
|
|
layer.set("lyrTyNm", "tb_ms_swage_ppl");
|
|
layer.set("clickPsblYn", "N");
|
|
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.("SN_SINKHOLE_FOCUSED_PIPE_LAYER");
|
|
map.addLayer(layer);
|
|
sinkholeFocusedPipeLayerRef.current = layer;
|
|
sinkholeFocusedPipeSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const renderSelectedSinkhole = useCallback(
|
|
(detail: SinkholeDetail | null, radiusMeters: number) => {
|
|
if (!detail || detail.x == null || detail.y == null) {
|
|
clearSinkholeSelectionLayer();
|
|
return;
|
|
}
|
|
|
|
const source = ensureSinkholeSelectionLayer();
|
|
if (!source) return;
|
|
source.clear();
|
|
|
|
const pointFeature = new Feature({
|
|
geometry: new Point([detail.x, detail.y]),
|
|
});
|
|
const pointVariant = resolveSinkholePointVariant(detail.ocrnYmd);
|
|
pointFeature.setStyle(createSinkholePointStyle(pointVariant, true));
|
|
|
|
const radiusFeature = new Feature({
|
|
geometry: new CircleGeom([detail.x, detail.y], radiusMeters),
|
|
});
|
|
radiusFeature.setStyle(createSinkholeRadiusStyle(pointVariant));
|
|
|
|
// 원의 북쪽 경계에 라벨을 두고 같은 source에서 관리해 선택 해제 시 함께 정리한다.
|
|
const radiusLabelFeature = new Feature({
|
|
geometry: new Point([detail.x, detail.y + radiusMeters]),
|
|
});
|
|
radiusLabelFeature.set("sinkholeRadiusLabel", true);
|
|
radiusLabelFeature.setStyle(
|
|
createSinkholeRadiusLabelStyle(radiusMeters, pointVariant),
|
|
);
|
|
|
|
source.addFeatures([radiusFeature, radiusLabelFeature, pointFeature]);
|
|
},
|
|
[clearSinkholeSelectionLayer, ensureSinkholeSelectionLayer],
|
|
);
|
|
|
|
const focusSinkholeOnMap = useCallback(
|
|
(detail: SinkholeDetail, radiusMeters: number) => {
|
|
if (!gisMaps.current || detail.x == null || detail.y == null) return;
|
|
|
|
const radiusGeometry = new CircleGeom([detail.x, detail.y], radiusMeters);
|
|
gisMaps.current.getView?.().fit?.(radiusGeometry.getExtent(), {
|
|
duration: 350,
|
|
padding: [100, 380, 100, 120],
|
|
maxZoom: 17,
|
|
constrainResolution: true,
|
|
nearest: true,
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 우측 상세 탭 전환 시 패널의 활성 대상과 지도 선택/강조 상태를 맞춘다.
|
|
const syncRightPanelFeatureSelectionOnMap = useCallback(
|
|
(data: any) => {
|
|
const select = GisApp.Module.select as any;
|
|
select?.resetSelectedFeature?.();
|
|
|
|
const layerTypeName = getSelectedFeatureLayerTypeName(data);
|
|
if (layerTypeName === SINKHOLE_LAYER_TYPE) {
|
|
clearSinkholeFocusedPipeLayer();
|
|
|
|
return;
|
|
}
|
|
|
|
if (!data || typeof data.getGeometry !== "function") {
|
|
clearSinkholeFocusedPipeLayer();
|
|
|
|
return;
|
|
}
|
|
|
|
if (layerTypeName === "tb_ms_swage_ppl") {
|
|
const source = ensureSinkholeFocusedPipeLayer();
|
|
if (!source) return;
|
|
|
|
source.clear();
|
|
const focusedFeature =
|
|
typeof data.clone === "function" ? data.clone() : data;
|
|
if (
|
|
typeof focusedFeature.setId === "function" &&
|
|
typeof data.getId === "function"
|
|
) {
|
|
focusedFeature.setId(data.getId());
|
|
}
|
|
focusedFeature.setStyle(createSinkholeFocusedPipeStyle());
|
|
source.addFeature(focusedFeature);
|
|
select?.selectFeature?.(focusedFeature, true);
|
|
return;
|
|
}
|
|
|
|
clearSinkholeFocusedPipeLayer();
|
|
|
|
select?.selectFeature?.(data, true);
|
|
},
|
|
[clearSinkholeFocusedPipeLayer, ensureSinkholeFocusedPipeLayer],
|
|
);
|
|
|
|
const ensureFldngHstryAreaSource = useCallback(() => {
|
|
if (!mapReady || !gisMaps.current) return null;
|
|
|
|
if (!fldngHstryAreaLayerRef.current) {
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 9993,
|
|
});
|
|
layer.set("id", FLOOD_HISTORY_AREA_LAYER_ID);
|
|
layer.set("lyrNm", "침수흔적도");
|
|
layer.set("lyrTyNm", FLOOD_HISTORY_PREVIEW_LAYER_TYPE);
|
|
layer.set("clickPsblYn", "N");
|
|
gisMaps.current.addLayer(layer);
|
|
fldngHstryAreaLayerRef.current = layer;
|
|
}
|
|
|
|
return fldngHstryAreaLayerRef.current.getSource();
|
|
}, [mapReady]);
|
|
|
|
const ensureFldngHstryDepthSource = useCallback(() => {
|
|
if (!mapReady || !gisMaps.current) return null;
|
|
|
|
if (!fldngHstryDepthLayerRef.current) {
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 9992,
|
|
});
|
|
layer.set("id", FLOOD_HISTORY_DEPTH_LAYER_ID);
|
|
layer.set("lyrNm", "침수흔적도");
|
|
layer.set("lyrTyNm", FLOOD_HISTORY_PREVIEW_LAYER_TYPE);
|
|
layer.set("clickPsblYn", "N");
|
|
gisMaps.current.addLayer(layer);
|
|
fldngHstryDepthLayerRef.current = layer;
|
|
}
|
|
|
|
return fldngHstryDepthLayerRef.current.getSource();
|
|
}, [mapReady]);
|
|
|
|
const clearFldngHstryLayers = useCallback(() => {
|
|
fldngHstryAreaLayerRef.current?.getSource?.()?.clear?.();
|
|
fldngHstryDepthLayerRef.current?.getSource?.()?.clear?.();
|
|
}, []);
|
|
|
|
const renderSelectedFldngHstry = useCallback(
|
|
(detail: FldngHstryItem | null) => {
|
|
const areaSource = ensureFldngHstryAreaSource();
|
|
const depthSource = ensureFldngHstryDepthSource();
|
|
if (!areaSource || !depthSource) return;
|
|
|
|
areaSource.clear();
|
|
depthSource.clear();
|
|
|
|
if (!detail) return;
|
|
|
|
const areaFeature = new Feature({
|
|
geometry: new Polygon([detail.areaRing]),
|
|
});
|
|
areaFeature.setStyle(
|
|
createFldngHstryAreaStyle(
|
|
`${detail.floodYear}년 ${detail.districtName}`,
|
|
),
|
|
);
|
|
areaSource.addFeature(areaFeature);
|
|
|
|
detail.depthZones.forEach((zone) => {
|
|
const zoneFeature = new Feature({
|
|
geometry: new Polygon([zone.ring]),
|
|
});
|
|
zoneFeature.setStyle(
|
|
createFldngHstryDepthStyle(zone.depthLabel, zone.color),
|
|
);
|
|
depthSource.addFeature(zoneFeature);
|
|
});
|
|
},
|
|
[ensureFldngHstryAreaSource, ensureFldngHstryDepthSource],
|
|
);
|
|
|
|
const focusFldngHstryOnMap = useCallback((detail: FldngHstryItem) => {
|
|
if (!gisMaps.current) return;
|
|
|
|
const geometry = new Polygon([detail.areaRing]);
|
|
gisMaps.current.getView?.().fit?.(geometry.getExtent(), {
|
|
duration: 350,
|
|
padding: FLOOD_HISTORY_SELECTION_PADDING,
|
|
maxZoom: 15,
|
|
constrainResolution: true,
|
|
nearest: true,
|
|
});
|
|
}, []);
|
|
|
|
const clearFldngHstrySelection = useCallback(() => {
|
|
setSelectedFldngHstryDetail(null);
|
|
setFldngHstryDetailPopupPosition(null);
|
|
clearFldngHstryLayers();
|
|
}, [clearFldngHstryLayers]);
|
|
|
|
const closeFldngHstryDetail = useCallback(() => {
|
|
setFldngHstryDetailPopupPosition(null);
|
|
}, []);
|
|
|
|
const handleFldngHstryRowSelect = useCallback(
|
|
(detail: FldngHstryItem) => {
|
|
setSelectedFldngHstryDetail(detail);
|
|
renderSelectedFldngHstry(detail);
|
|
focusFldngHstryOnMap(detail);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[focusFldngHstryOnMap, renderSelectedFldngHstry, setIsSearchModalOpen],
|
|
);
|
|
|
|
const openFldngHstryPopupWindow = useCallback(() => {
|
|
const popup = openWindowPopup(FLOOD_HISTORY_POPUP_KEY as WindowPopupKey);
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
return null;
|
|
}
|
|
return popup;
|
|
}, []);
|
|
|
|
const buildSinkholeQueryString = useCallback(
|
|
(filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT) =>
|
|
buildSinkholeListQueryString(filters, ctpv, sgg, page, size),
|
|
[ctpv, sgg],
|
|
);
|
|
|
|
const loadSinkholeLayer = useCallback(
|
|
async (filters = appliedSinkholeFilters) => {
|
|
if (!mapReady || !gisMaps.current || !isSinkholeChecked) return;
|
|
|
|
const source = ensureSinkholeLayer();
|
|
if (!source) return;
|
|
|
|
const effectiveFilters = normalizeSinkholeFilters(filters, sinkholeScope);
|
|
try {
|
|
const params = new URLSearchParams(
|
|
buildSinkholeQueryString(
|
|
effectiveFilters,
|
|
0,
|
|
SINKHOLE_DEFAULT_POINT_LIMIT,
|
|
),
|
|
);
|
|
const rawResponse = await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes?${params.toString()}`,
|
|
);
|
|
const items = Array.isArray(rawResponse?.data?.items)
|
|
? (rawResponse.data.items as SinkholeListItem[])
|
|
: Array.isArray(rawResponse?.items)
|
|
? (rawResponse.items as SinkholeListItem[])
|
|
: [];
|
|
const featureCollection =
|
|
buildSinkholeFeatureCollectionFromItems(items);
|
|
|
|
if (!featureCollection) {
|
|
console.warn(
|
|
"지반침하 목록 응답을 feature collection으로 변환하지 못했습니다.",
|
|
rawResponse,
|
|
);
|
|
source.clear();
|
|
return;
|
|
}
|
|
|
|
const features = new GeoJSON().readFeatures(featureCollection, {
|
|
dataProjection: "EPSG:5179",
|
|
featureProjection: GisApp.TO_PROJ,
|
|
});
|
|
source.clear();
|
|
features.forEach((feature: any) => {
|
|
const variant = resolveSinkholePointVariant(
|
|
String(feature?.get?.("ocrnYmd") ?? ""),
|
|
);
|
|
feature.setStyle(createSinkholePointStyle(variant, false));
|
|
});
|
|
source.addFeatures(features);
|
|
} catch (error) {
|
|
console.error("지반침하 레이어 조회 실패:", error);
|
|
source.clear();
|
|
}
|
|
},
|
|
[
|
|
appliedSinkholeFilters,
|
|
buildSinkholeQueryString,
|
|
ensureSinkholeLayer,
|
|
isSinkholeChecked,
|
|
mapReady,
|
|
sinkholeScope,
|
|
],
|
|
);
|
|
|
|
const closeSinkholeDetail = useCallback(() => {
|
|
setSelectedSinkholeDetail(null);
|
|
setSelectedSinkholeNearbyPipes([]);
|
|
clearSinkholeSelectionLayer();
|
|
clearSinkholeFocusedPipeLayer();
|
|
|
|
if (getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE) {
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
}
|
|
}, [clearSinkholeFocusedPipeLayer, clearSinkholeSelectionLayer, featureData]);
|
|
|
|
const loadSinkholeDetail = useCallback(
|
|
async (
|
|
acdntNo: string,
|
|
focusMap = true,
|
|
fallbackPoint?: { x: number; y: number } | null,
|
|
fallbackDetail?: Partial<SinkholeDetail> | null,
|
|
) => {
|
|
if (!acdntNo) return;
|
|
|
|
try {
|
|
const hasFallbackCoordinate =
|
|
(fallbackDetail?.x != null && fallbackDetail?.y != null) ||
|
|
(fallbackPoint != null &&
|
|
Number.isFinite(fallbackPoint.x) &&
|
|
Number.isFinite(fallbackPoint.y));
|
|
const fallbackCoordStatus = String(
|
|
fallbackDetail?.coordStatus ?? fallbackDetail?.geocodeStatus ?? "",
|
|
)
|
|
.trim()
|
|
.toUpperCase();
|
|
|
|
if (
|
|
fallbackDetail &&
|
|
!hasFallbackCoordinate &&
|
|
fallbackCoordStatus &&
|
|
fallbackCoordStatus !== "SUCCESS"
|
|
) {
|
|
const noCoordDetail: SinkholeDetail = {
|
|
acdntNo,
|
|
ctpvNm: String(fallbackDetail.ctpvNm ?? ""),
|
|
sggNm: String(fallbackDetail.sggNm ?? ""),
|
|
emdNm: String(fallbackDetail.emdNm ?? ""),
|
|
daddr: String(fallbackDetail.daddr ?? ""),
|
|
fullAddress: String(
|
|
fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? "",
|
|
),
|
|
ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""),
|
|
restoreStatus: String(fallbackDetail.restoreStatus ?? ""),
|
|
restoreCompletedYmd: String(
|
|
fallbackDetail.restoreCompletedYmd ?? "",
|
|
),
|
|
coordStatus: String(
|
|
fallbackDetail.coordStatus ?? fallbackDetail.geocodeStatus ?? "",
|
|
),
|
|
geocodeStatus: String(
|
|
fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? "",
|
|
),
|
|
geocodeSource: String(fallbackDetail.geocodeSource ?? ""),
|
|
geocodeErrorMessage: String(
|
|
fallbackDetail.geocodeErrorMessage ?? "",
|
|
),
|
|
latitude: fallbackDetail.latitude ?? null,
|
|
longitude: fallbackDetail.longitude ?? null,
|
|
x: fallbackDetail.x ?? null,
|
|
y: fallbackDetail.y ?? null,
|
|
sizeWidth: fallbackDetail.sizeWidth ?? null,
|
|
sizeLength: fallbackDetail.sizeLength ?? null,
|
|
sizeDepth: fallbackDetail.sizeDepth ?? null,
|
|
detailCause: fallbackDetail.detailCause ?? null,
|
|
deathCount: fallbackDetail.deathCount ?? null,
|
|
injuryCount: fallbackDetail.injuryCount ?? null,
|
|
vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null,
|
|
};
|
|
|
|
setSelectedSinkholeDetail(noCoordDetail);
|
|
setSelectedSinkholeNearbyPipes([]);
|
|
renderSelectedSinkhole(null, sinkholeRadiusMeters);
|
|
setFeatureData({
|
|
lyrTypeNm: SINKHOLE_LAYER_TYPE,
|
|
acdntNo: noCoordDetail.acdntNo,
|
|
});
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
let detail: SinkholeDetail;
|
|
try {
|
|
const detailResponse = await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}`,
|
|
);
|
|
detail = (detailResponse?.data ?? detailResponse) as SinkholeDetail;
|
|
} catch (detailError) {
|
|
if (!fallbackDetail) {
|
|
throw detailError;
|
|
}
|
|
console.warn(
|
|
"지반침하 상세 API 실패, 목록 데이터로 대체합니다.",
|
|
detailError,
|
|
);
|
|
detail = {
|
|
acdntNo,
|
|
ctpvNm: String(fallbackDetail.ctpvNm ?? ""),
|
|
sggNm: String(fallbackDetail.sggNm ?? ""),
|
|
emdNm: String(fallbackDetail.emdNm ?? ""),
|
|
daddr: String(fallbackDetail.daddr ?? ""),
|
|
fullAddress: String(
|
|
fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? "",
|
|
),
|
|
ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""),
|
|
restoreStatus: String(fallbackDetail.restoreStatus ?? ""),
|
|
restoreCompletedYmd: String(
|
|
fallbackDetail.restoreCompletedYmd ?? "",
|
|
),
|
|
coordStatus: String(fallbackDetail.coordStatus ?? ""),
|
|
geocodeStatus: String(
|
|
fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? "",
|
|
),
|
|
geocodeSource: String(fallbackDetail.geocodeSource ?? ""),
|
|
geocodeErrorMessage: String(
|
|
fallbackDetail.geocodeErrorMessage ?? "",
|
|
),
|
|
latitude: fallbackDetail.latitude ?? null,
|
|
longitude: fallbackDetail.longitude ?? null,
|
|
x: fallbackDetail.x ?? null,
|
|
y: fallbackDetail.y ?? null,
|
|
sizeWidth: fallbackDetail.sizeWidth ?? null,
|
|
sizeLength: fallbackDetail.sizeLength ?? null,
|
|
sizeDepth: fallbackDetail.sizeDepth ?? null,
|
|
detailCause: fallbackDetail.detailCause ?? null,
|
|
deathCount: fallbackDetail.deathCount ?? null,
|
|
injuryCount: fallbackDetail.injuryCount ?? null,
|
|
vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null,
|
|
};
|
|
}
|
|
const resolvedPoint =
|
|
detail.x != null && detail.y != null
|
|
? { x: detail.x, y: detail.y }
|
|
: fallbackPoint &&
|
|
Number.isFinite(fallbackPoint.x) &&
|
|
Number.isFinite(fallbackPoint.y)
|
|
? fallbackPoint
|
|
: null;
|
|
|
|
let nearbyPipes: SinkholeNearbyPipe[] = [];
|
|
try {
|
|
const nearbyRawResponse = resolvedPoint
|
|
? await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${resolvedPoint.x}&y=${resolvedPoint.y}&radiusMeters=${sinkholeRadiusMeters}`,
|
|
)
|
|
: await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}/nearbyPipes?radiusMeters=${sinkholeRadiusMeters}`,
|
|
);
|
|
const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse;
|
|
nearbyPipes = Array.isArray(nearbyResponse?.items)
|
|
? (nearbyResponse.items as SinkholeNearbyPipe[])
|
|
: [];
|
|
} catch (nearbyError) {
|
|
console.warn(
|
|
"지반침하 반경 내 관로 조회 실패, 상세는 계속 표시합니다.",
|
|
nearbyError,
|
|
);
|
|
}
|
|
const normalizedDetail = resolvedPoint
|
|
? {
|
|
...detail,
|
|
x: resolvedPoint.x,
|
|
y: resolvedPoint.y,
|
|
coordStatus: "SUCCESS",
|
|
}
|
|
: detail;
|
|
|
|
setSelectedSinkholeDetail(normalizedDetail);
|
|
setSelectedSinkholeNearbyPipes(nearbyPipes);
|
|
renderSelectedSinkhole(normalizedDetail, sinkholeRadiusMeters);
|
|
setFeatureData({
|
|
lyrTypeNm: SINKHOLE_LAYER_TYPE,
|
|
acdntNo: normalizedDetail.acdntNo,
|
|
});
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
|
|
if (
|
|
focusMap &&
|
|
normalizedDetail.x != null &&
|
|
normalizedDetail.y != null
|
|
) {
|
|
focusSinkholeOnMap(normalizedDetail, sinkholeRadiusMeters);
|
|
}
|
|
} catch (error) {
|
|
console.error("지반침하 상세 조회 중 오류:", error);
|
|
alert("지반침하 상세 정보를 불러오지 못했습니다.");
|
|
}
|
|
},
|
|
[
|
|
focusSinkholeOnMap,
|
|
renderSelectedSinkhole,
|
|
setIsSearchModalOpen,
|
|
sinkholeRadiusMeters,
|
|
],
|
|
);
|
|
|
|
const applySinkholeRadius = useCallback(
|
|
async (radiusMeters: number) => {
|
|
setSinkholeRadiusMeters(radiusMeters);
|
|
if (!selectedSinkholeDetail?.acdntNo) {
|
|
return;
|
|
}
|
|
|
|
const selectedPoint =
|
|
selectedSinkholeDetail.x != null && selectedSinkholeDetail.y != null
|
|
? {
|
|
x: Number(selectedSinkholeDetail.x),
|
|
y: Number(selectedSinkholeDetail.y),
|
|
}
|
|
: null;
|
|
|
|
try {
|
|
const nearbyRawResponse =
|
|
selectedPoint &&
|
|
Number.isFinite(selectedPoint.x) &&
|
|
Number.isFinite(selectedPoint.y)
|
|
? await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${selectedPoint.x}&y=${selectedPoint.y}&radiusMeters=${radiusMeters}`,
|
|
)
|
|
: await getRawJson(
|
|
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(selectedSinkholeDetail.acdntNo)}/nearbyPipes?radiusMeters=${radiusMeters}`,
|
|
);
|
|
const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse;
|
|
setSelectedSinkholeNearbyPipes(
|
|
Array.isArray(nearbyResponse?.items)
|
|
? (nearbyResponse.items as SinkholeNearbyPipe[])
|
|
: [],
|
|
);
|
|
renderSelectedSinkhole(selectedSinkholeDetail, radiusMeters);
|
|
focusSinkholeOnMap(selectedSinkholeDetail, radiusMeters);
|
|
} catch (error) {
|
|
console.error("지반침하 반경 적용 중 오류:", error);
|
|
alert("반경 내 하수관로 정보를 다시 조회하지 못했습니다.");
|
|
throw error;
|
|
}
|
|
},
|
|
[focusSinkholeOnMap, renderSelectedSinkhole, selectedSinkholeDetail],
|
|
);
|
|
|
|
const focusSinkholeNearbyPipe = useCallback(
|
|
async (pipe: SinkholeNearbyPipe) => {
|
|
const fid = String(pipe?.fid ?? "").trim();
|
|
if (!fid) {
|
|
alert("표시할 관로 식별값이 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const activeSinkholeTab = rightPanelTabs.find(
|
|
(tab) =>
|
|
tab.id === activeRightPanelTabId && tab.category === "sinkhole",
|
|
);
|
|
pipeParentTabIdRef.current = activeSinkholeTab?.id ?? null;
|
|
|
|
try {
|
|
const layerTypeName = String(pipe?.lyrTypeNm ?? "tb_ms_swage_ppl");
|
|
const layerModule = GisApp.Module.layer as {
|
|
storageNm?: string;
|
|
} | null;
|
|
const storageNm = String(layerModule?.storageNm ?? "").trim();
|
|
if (!storageNm)
|
|
throw new Error("GeoServer 저장소 이름을 확인할 수 없습니다.");
|
|
const params = new URLSearchParams({
|
|
SERVICE: "WFS",
|
|
VERSION: "1.1.0",
|
|
REQUEST: "GetFeature",
|
|
TYPENAME: `${storageNm}${layerTypeName}`,
|
|
SRSNAME: GisApp.TO_PROJ,
|
|
OUTPUTFORMAT: "application/json",
|
|
FEATUREID: fid,
|
|
});
|
|
const response = await getRawJson(
|
|
`${GisApp.GEOSERVER_URL}?${params.toString()}`,
|
|
);
|
|
const featureCollection = getFeatureCollection(response);
|
|
const features = new GeoJSON().readFeatures(featureCollection, {
|
|
dataProjection: GisApp.TO_PROJ,
|
|
featureProjection: GisApp.TO_PROJ,
|
|
});
|
|
|
|
const firstFeature = features[0];
|
|
if (firstFeature) {
|
|
syncRightPanelFeatureSelectionOnMap(firstFeature);
|
|
setFeatureData(firstFeature);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
}
|
|
} catch (error) {
|
|
console.error("지반침하 반경 내 관로 지도 표시 실패:", error);
|
|
alert("선택한 관로를 지도에서 표시하지 못했습니다.");
|
|
}
|
|
},
|
|
[
|
|
activeRightPanelTabId,
|
|
rightPanelTabs,
|
|
setIsSearchModalOpen,
|
|
syncRightPanelFeatureSelectionOnMap,
|
|
],
|
|
);
|
|
|
|
const buildRightPanelTab = useCallback(
|
|
(data: any): RightPanelTab | null => {
|
|
if (!data) return null;
|
|
|
|
const layerTypeName = getSelectedFeatureLayerTypeName(data);
|
|
const now = Date.now();
|
|
|
|
if (layerTypeName === SINKHOLE_LAYER_TYPE) {
|
|
const detail = selectedSinkholeDetail;
|
|
const acdntNo = String(
|
|
detail?.acdntNo ?? getSinkholeAccidentId(data) ?? "",
|
|
).trim();
|
|
if (!acdntNo) return null;
|
|
|
|
return {
|
|
id: `sinkhole:${acdntNo}`,
|
|
kind: "sinkhole",
|
|
category: "sinkhole",
|
|
displayLabel: `지반침하(${acdntNo})`,
|
|
parentTabId: null,
|
|
featureData: {
|
|
lyrTypeNm: SINKHOLE_LAYER_TYPE,
|
|
acdntNo,
|
|
},
|
|
sinkholeDetail: detail,
|
|
nearbyPipes: selectedSinkholeNearbyPipes,
|
|
lastActiveAt: now,
|
|
};
|
|
}
|
|
|
|
if (gisEditDetailChangeId) {
|
|
return {
|
|
id: `gisEdit:${gisEditDetailChangeId}`,
|
|
kind: "gisEdit",
|
|
category: "gisEdit",
|
|
displayLabel: "관로편집",
|
|
parentTabId: null,
|
|
featureData: data,
|
|
gisEditChangeId: gisEditDetailChangeId,
|
|
lastActiveAt: now,
|
|
};
|
|
}
|
|
|
|
const category = getRightPanelTabCategory(data);
|
|
const surveyJobTypeValue = getRightPanelFeatureProperty(data, [
|
|
"jobType",
|
|
]);
|
|
const surveyJobType =
|
|
surveyJobTypeValue === "PPL" || surveyJobTypeValue === "MNHL"
|
|
? surveyJobTypeValue
|
|
: undefined;
|
|
const featureId =
|
|
getRightPanelFeatureIdentifier(data) ||
|
|
`${layerTypeName || "feature"}:${now}`;
|
|
// Direct map selections accumulate per pipe; only a pipe with a sinkhole parent replaces the linked slot.
|
|
const pipeParentTabId =
|
|
category === "pipe" ? pipeParentTabIdRef.current : null;
|
|
return {
|
|
id:
|
|
category === "exmnSurvey"
|
|
? `exmnSurvey:${getRightPanelFeatureProperty(data, ["exmnFacilityId"])}:${getRightPanelFeatureProperty(data, ["manholePosition"])}`
|
|
: category === "pipe" && pipeParentTabId
|
|
? RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID
|
|
: `feature:${layerTypeName || "unknown"}:${featureId}`,
|
|
kind: "feature",
|
|
category,
|
|
displayLabel: getRightPanelFeatureDisplayLabel(data, category),
|
|
surveyJobType,
|
|
parentTabId: pipeParentTabId,
|
|
featureData: data,
|
|
lastActiveAt: now,
|
|
};
|
|
},
|
|
[
|
|
gisEditDetailChangeId,
|
|
selectedSinkholeDetail,
|
|
selectedSinkholeNearbyPipes,
|
|
],
|
|
);
|
|
|
|
const upsertRightPanelTab = useCallback((tab: RightPanelTab) => {
|
|
setRightPanelTabs((currentTabs) => {
|
|
const nextTab = { ...tab, lastActiveAt: Date.now() };
|
|
const existingIndex = currentTabs.findIndex((item) => item.id === tab.id);
|
|
const mergedTabs =
|
|
existingIndex < 0
|
|
? [...currentTabs, nextTab]
|
|
: currentTabs.map((item, index) =>
|
|
index === existingIndex ? { ...item, ...nextTab } : item,
|
|
);
|
|
|
|
if (mergedTabs.length <= RIGHT_PANEL_TAB_LIMIT) return mergedTabs;
|
|
|
|
const overflowCandidate = findRightPanelTabOverflowCandidate(
|
|
mergedTabs,
|
|
nextTab.id,
|
|
);
|
|
if (!overflowCandidate) return mergedTabs.slice(-RIGHT_PANEL_TAB_LIMIT);
|
|
|
|
return removeRightPanelTabGroup(mergedTabs, overflowCandidate.id);
|
|
});
|
|
setActiveRightPanelTabId(tab.id);
|
|
}, []);
|
|
|
|
const activateRightPanelTab = useCallback(
|
|
(tabId: string) => {
|
|
const targetTab = rightPanelTabs.find((tab) => tab.id === tabId);
|
|
if (!targetTab) return;
|
|
|
|
setRightPanelTabs((currentTabs) =>
|
|
currentTabs.map((tab) =>
|
|
tab.id === tabId ? { ...tab, lastActiveAt: Date.now() } : tab,
|
|
),
|
|
);
|
|
setActiveRightPanelTabId(tabId);
|
|
if (targetTab.category === "pipe") {
|
|
pipeParentTabIdRef.current = targetTab.parentTabId ?? null;
|
|
}
|
|
|
|
if (targetTab.kind === "sinkhole") {
|
|
const detail = targetTab.sinkholeDetail ?? null;
|
|
setSelectedSinkholeDetail(detail);
|
|
setSelectedSinkholeNearbyPipes(targetTab.nearbyPipes ?? []);
|
|
setGisEditDetailChangeId(null);
|
|
syncRightPanelFeatureSelectionOnMap(targetTab.featureData);
|
|
if (detail) {
|
|
renderSelectedSinkhole(detail, sinkholeRadiusMeters);
|
|
}
|
|
} else if (targetTab.kind === "gisEdit") {
|
|
setGisEditDetailChangeId(targetTab.gisEditChangeId ?? null);
|
|
} else {
|
|
setGisEditDetailChangeId(null);
|
|
syncRightPanelFeatureSelectionOnMap(targetTab.featureData);
|
|
}
|
|
|
|
setFeatureData(targetTab.featureData);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[
|
|
renderSelectedSinkhole,
|
|
rightPanelTabs,
|
|
setIsSearchModalOpen,
|
|
sinkholeRadiusMeters,
|
|
syncRightPanelFeatureSelectionOnMap,
|
|
],
|
|
);
|
|
|
|
const closeRightPanelTab = useCallback(
|
|
(tabId: string | null) => {
|
|
if (!tabId) return;
|
|
const closingTab = rightPanelTabs.find((tab) => tab.id === tabId);
|
|
const remainingTabs = removeRightPanelTabGroup(rightPanelTabs, tabId);
|
|
|
|
if (closingTab?.kind === "sinkhole") {
|
|
setSelectedSinkholeDetail(null);
|
|
setSelectedSinkholeNearbyPipes([]);
|
|
clearSinkholeSelectionLayer();
|
|
}
|
|
if (closingTab?.kind === "feature") {
|
|
const select = GisApp.Module.select as any;
|
|
select?.resetSelectedFeature?.();
|
|
clearSinkholeFocusedPipeLayer();
|
|
}
|
|
if (closingTab?.kind === "gisEdit") {
|
|
setGisEditDetailChangeId(null);
|
|
}
|
|
if (
|
|
closingTab?.category === "pipe" ||
|
|
pipeParentTabIdRef.current === tabId
|
|
) {
|
|
pipeParentTabIdRef.current = null;
|
|
}
|
|
|
|
setRightPanelTabs(remainingTabs);
|
|
|
|
if (remainingTabs.length === 0) {
|
|
setActiveRightPanelTabId(null);
|
|
pipeParentTabIdRef.current = null;
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
|
|
const parentFallbackTab =
|
|
closingTab?.category === "pipe" && closingTab.parentTabId
|
|
? remainingTabs.find((tab) => tab.id === closingTab.parentTabId)
|
|
: null;
|
|
const fallbackTab =
|
|
parentFallbackTab ??
|
|
[...remainingTabs].sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0]!;
|
|
activateRightPanelTab(fallbackTab.id);
|
|
},
|
|
[
|
|
activateRightPanelTab,
|
|
clearSinkholeFocusedPipeLayer,
|
|
clearSinkholeSelectionLayer,
|
|
rightPanelTabs,
|
|
],
|
|
);
|
|
|
|
// 선택 피처가 없는 지도 본체 클릭을 기존 지반침하 상세 닫기 흐름과 연결한다.
|
|
const handleSinkholeMapBlankClick = useCallback(() => {
|
|
if (gisEdit.isEditMode || !selectedSinkholeDetail?.acdntNo) return;
|
|
|
|
const sinkholeTabId = `sinkhole:${selectedSinkholeDetail.acdntNo}`;
|
|
const hasSinkholeTab = rightPanelTabs.some(
|
|
(tab) => tab.id === sinkholeTabId,
|
|
);
|
|
|
|
if (hasSinkholeTab && activeRightPanelTabId === sinkholeTabId) {
|
|
closeRightPanelTab(sinkholeTabId);
|
|
} else {
|
|
// 비활성 지반침하 탭은 현재 활성 탭을 바꾸지 않고 종속 관로와 함께 제거한다.
|
|
setRightPanelTabs((currentTabs) =>
|
|
removeRightPanelTabGroup(currentTabs, sinkholeTabId),
|
|
);
|
|
setSelectedSinkholeDetail(null);
|
|
setSelectedSinkholeNearbyPipes([]);
|
|
clearSinkholeSelectionLayer();
|
|
if (pipeParentTabIdRef.current === sinkholeTabId) {
|
|
pipeParentTabIdRef.current = null;
|
|
}
|
|
if (
|
|
!hasSinkholeTab &&
|
|
getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE
|
|
) {
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
const select = GisApp.Module.select as any;
|
|
select?.resetSelectedFeature?.();
|
|
clearSinkholeFocusedPipeLayer();
|
|
}, [
|
|
activeRightPanelTabId,
|
|
clearSinkholeFocusedPipeLayer,
|
|
clearSinkholeSelectionLayer,
|
|
closeRightPanelTab,
|
|
featureData,
|
|
gisEdit.isEditMode,
|
|
rightPanelTabs,
|
|
selectedSinkholeDetail,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !featureData) return;
|
|
const nextTab = buildRightPanelTab(featureData);
|
|
if (nextTab) upsertRightPanelTab(nextTab);
|
|
}, [buildRightPanelTab, featureData, isOpen, upsertRightPanelTab]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) return;
|
|
pipeParentTabIdRef.current = null;
|
|
setRightPanelTabs([]);
|
|
setActiveRightPanelTabId(null);
|
|
}, [isOpen]);
|
|
|
|
const openSinkholePopupWindow = useCallback(() => {
|
|
const effectiveFilters = normalizeSinkholeFilters(
|
|
appliedSinkholeFilters,
|
|
sinkholeScope,
|
|
);
|
|
const popup = openWindowPopup(
|
|
SINKHOLE_POPUP_KEY as WindowPopupKey,
|
|
effectiveFilters as Record<string, string>,
|
|
);
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
return null;
|
|
}
|
|
|
|
setIsSinkholePopupFilterActive(true);
|
|
setAppliedSinkholeFilters(effectiveFilters);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
|
|
if (sinkholePopupCloseWatcherRef.current) {
|
|
window.clearInterval(sinkholePopupCloseWatcherRef.current);
|
|
sinkholePopupCloseWatcherRef.current = null;
|
|
}
|
|
sinkholePopupWindowRef.current = popup;
|
|
sinkholePopupCloseWatcherRef.current = window.setInterval(() => {
|
|
if (!popup.closed) return;
|
|
if (sinkholePopupCloseWatcherRef.current) {
|
|
window.clearInterval(sinkholePopupCloseWatcherRef.current);
|
|
sinkholePopupCloseWatcherRef.current = null;
|
|
}
|
|
sinkholePopupWindowRef.current = null;
|
|
setIsSinkholePopupFilterActive(false);
|
|
setAppliedSinkholeFilters(
|
|
normalizeSinkholeFilters(
|
|
buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope),
|
|
sinkholeScope,
|
|
),
|
|
);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
}, 300);
|
|
return popup;
|
|
}, [appliedSinkholeFilters, sinkholeScope, sinkholeYearRange]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (sinkholePopupCloseWatcherRef.current) {
|
|
window.clearInterval(sinkholePopupCloseWatcherRef.current);
|
|
sinkholePopupCloseWatcherRef.current = null;
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleAgedPipeRowDoubleClick = useCallback(
|
|
async (row: AgedPipeRow) => {
|
|
try {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = true;
|
|
const core = GisApp.Module.layer as any;
|
|
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
|
|
} catch (error) {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
|
console.error("노후관로 위치 이동 중 오류:", error);
|
|
}
|
|
|
|
setFeatureData(row as any);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[setIsSearchModalOpen],
|
|
);
|
|
|
|
const handlePipeAnalysisRowSelect = useCallback(
|
|
async (row: Pick<PipeAnalysisDetailRow, "fid">) => {
|
|
try {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = true;
|
|
const core = GisApp.Module.layer as any;
|
|
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
|
|
} catch (error) {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
|
console.error("관로 위치 이동 중 오류:", error);
|
|
}
|
|
|
|
setFeatureData(row as any);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[setIsSearchModalOpen],
|
|
);
|
|
|
|
const handleAgedPipeDownload = useCallback(async () => {
|
|
if (agedPipeTotalCount === 0) {
|
|
alert("다운로드할 데이터가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsAgedPipeListLoading(true);
|
|
setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
|
|
await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => {
|
|
if (job.status === "QUEUED") {
|
|
setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다.");
|
|
return;
|
|
}
|
|
|
|
if (job.status === "RUNNING") {
|
|
setAgedPipeDownloadProgressText(
|
|
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
|
|
);
|
|
}
|
|
});
|
|
setAgedPipeDownloadProgressText("");
|
|
} catch (error) {
|
|
console.error("노후관로 목록 다운로드 중 오류:", error);
|
|
setAgedPipeDownloadProgressText("");
|
|
alert(
|
|
error instanceof Error
|
|
? error.message
|
|
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
|
|
);
|
|
} finally {
|
|
setIsAgedPipeListLoading(false);
|
|
}
|
|
}, [agedPipeTotalCount, appliedAgedPipeFilters]);
|
|
|
|
useEffect(() => {
|
|
const handleAgedPipePopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
if (event.data?.type === AGED_PIPE_ROW_SELECT_MESSAGE) {
|
|
if (!event.data?.row) return;
|
|
handleAgedPipeRowDoubleClick(event.data.row as AgedPipeRow);
|
|
return;
|
|
}
|
|
|
|
if (event.data?.type === AGED_PIPE_POPUP_MINIMIZE_MESSAGE) {
|
|
agedPipePopupWindowRef.current = null;
|
|
if (event.data?.filters) {
|
|
const normalized = normalizeAgedPipeFilters(
|
|
event.data.filters as AgedPipeFilters,
|
|
);
|
|
setDraftAgedPipeFilters(normalized);
|
|
setAppliedAgedPipeFilters(normalized);
|
|
}
|
|
setAgedPipeTotalCount(Number(event.data?.total ?? agedPipeTotalCount));
|
|
setIsAgedPipeListMinimized(true);
|
|
return;
|
|
}
|
|
|
|
if (event.data?.type === PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE) {
|
|
if (!event.data?.row) return;
|
|
handlePipeAnalysisRowSelect(event.data.row as PipeAnalysisDetailRow);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleAgedPipePopupMessage);
|
|
return () =>
|
|
window.removeEventListener("message", handleAgedPipePopupMessage);
|
|
}, [
|
|
agedPipeTotalCount,
|
|
handleAgedPipeRowDoubleClick,
|
|
handlePipeAnalysisRowSelect,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const handleSinkholePopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
if (
|
|
event.data?.type === SINKHOLE_FILTER_SYNC_MESSAGE &&
|
|
event.data?.filters
|
|
) {
|
|
const normalized = normalizeSinkholeFilters(
|
|
event.data.filters as SinkholeFilters,
|
|
sinkholeScope,
|
|
);
|
|
setIsSinkholePopupFilterActive(true);
|
|
setAppliedSinkholeFilters(normalized);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
return;
|
|
}
|
|
|
|
if (event.data?.type === SINKHOLE_ROW_SELECT_MESSAGE && event.data?.row) {
|
|
const row = event.data.row as SinkholeListItem;
|
|
const fallbackPoint =
|
|
row.x != null && row.y != null
|
|
? { x: Number(row.x), y: Number(row.y) }
|
|
: null;
|
|
void loadSinkholeDetail(
|
|
String(row.acdntNo ?? ""),
|
|
true,
|
|
fallbackPoint,
|
|
row,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
event.data?.type === SINKHOLE_SYNC_COMPLETED_MESSAGE &&
|
|
event.data?.payload
|
|
) {
|
|
setLastSinkholeSyncSummary(event.data.payload as SinkholeSyncSummary);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleSinkholePopupMessage);
|
|
return () =>
|
|
window.removeEventListener("message", handleSinkholePopupMessage);
|
|
}, [loadSinkholeDetail, sinkholeScope]);
|
|
|
|
useEffect(() => {
|
|
const handleFldngHstryPopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
if (
|
|
event.data?.type !== FLDNG_HSTRY_ROW_SELECT_MESSAGE ||
|
|
!event.data?.row
|
|
)
|
|
return;
|
|
|
|
handleFldngHstryRowSelect(event.data.row as FldngHstryItem);
|
|
};
|
|
|
|
window.addEventListener("message", handleFldngHstryPopupMessage);
|
|
return () =>
|
|
window.removeEventListener("message", handleFldngHstryPopupMessage);
|
|
}, [handleFldngHstryRowSelect]);
|
|
|
|
// WaterDepth 표시 여부를 결정하는 함수
|
|
const handleWaterDepthVisibility = useCallback(
|
|
(checkedIds: string[], checkedNodesData: any[]) => {
|
|
const waterDepthLayers = checkedNodesData.filter((node) =>
|
|
WATER_DEPTH_LAYER_TYPES.includes(node.data?.lyrTyNm),
|
|
);
|
|
|
|
const hasWaterDepthLayer = waterDepthLayers.length > 0;
|
|
setIsWaterDepthLayerActive(hasWaterDepthLayer);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 체크된 레이어 변경 처리 함수
|
|
const handleCheckedLayersChange = useCallback(
|
|
(checkedIds: string[], checkedNodesData: any[]) => {
|
|
checkedNodesDataRef.current = checkedNodesData;
|
|
setCheckedLayerLegendVersion((version) => version + 1);
|
|
setSwagePrcsFcltLayerMode(getSwagePrcsFcltLayerMode(checkedNodesData));
|
|
|
|
// 유하방향은 일반 setLayerOnMap 대상이 아니므로 체크 노드 여부를 별도로 추적한다.
|
|
const flowDirectionNode = checkedNodesData.find((node) =>
|
|
isFlowDirectionLayerInfo(node?.data ?? node),
|
|
);
|
|
const nextFlowDirectionLayerInfo =
|
|
flowDirectionNode?.data ?? flowDirectionNode ?? null;
|
|
flowDirectionCheckedRef.current = Boolean(nextFlowDirectionLayerInfo);
|
|
flowDirectionLayerInfoRef.current = nextFlowDirectionLayerInfo;
|
|
setIsFlowDirectionChecked(Boolean(nextFlowDirectionLayerInfo));
|
|
setFlowDirectionLayerInfo(nextFlowDirectionLayerInfo);
|
|
if (nextFlowDirectionLayerInfo) {
|
|
const map = gisMaps.current;
|
|
const zoom = map?.getView?.()?.getZoom?.() ?? 0;
|
|
const nextUsageStyleMap = extractCheckedPipeUsageStyles(
|
|
checkedNodesData,
|
|
zoom,
|
|
);
|
|
const requestUsageCodes = retainFlowDirectionUsageCodes(
|
|
Object.keys(nextUsageStyleMap),
|
|
);
|
|
flowDirectionSelectionRequestUsageCodesRef.current = requestUsageCodes;
|
|
if (requestUsageCodes.length === 0) {
|
|
flowDirectionRequestSeqRef.current += 1;
|
|
flowDirectionAbortControllerRef.current?.abort();
|
|
flowDirectionAbortControllerRef.current = null;
|
|
}
|
|
setFlowDirectionSelectionVersion((version) => version + 1);
|
|
}
|
|
|
|
if (!nextFlowDirectionLayerInfo) {
|
|
// 해제 직후 pending API 응답이 늦게 도착해도 renderFlowDirectionLayer가 실행되지 않도록 순번을 무효화한다.
|
|
flowDirectionSelectionRequestUsageCodesRef.current = null;
|
|
flowDirectionRequestSeqRef.current += 1;
|
|
flowDirectionAbortControllerRef.current?.abort();
|
|
flowDirectionAbortControllerRef.current = null;
|
|
flowDirectionFailureAlertedRef.current = false;
|
|
removeFlowDirectionLayer();
|
|
}
|
|
|
|
handleWaterDepthVisibility(checkedIds, checkedNodesData);
|
|
const agedPipeChecked = checkedNodesData.some((node) =>
|
|
isAgedPipeLayerNode(node),
|
|
);
|
|
const waterDepthChecked = checkedNodesData.some((node) =>
|
|
WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")),
|
|
);
|
|
setIsAgedPipeChecked(agedPipeChecked);
|
|
const fldngHstryChecked = checkedNodesData.some((node) =>
|
|
isFldngHstryNode(node),
|
|
);
|
|
setIsFldngHstryChecked(fldngHstryChecked);
|
|
if (!fldngHstryChecked) {
|
|
clearFldngHstrySelection();
|
|
}
|
|
const sinkholeChecked = checkedNodesData.some((node) =>
|
|
isSinkholeLayerNode(node),
|
|
);
|
|
setIsSinkholeChecked(sinkholeChecked);
|
|
const exmnPplChecked = checkedNodesData.some((node) =>
|
|
isExmnResultPplNode(node),
|
|
);
|
|
const exmnMnhlChecked = checkedNodesData.some((node) =>
|
|
isExmnResultMnhlNode(node),
|
|
);
|
|
setIsExmnPplChecked(exmnPplChecked);
|
|
setIsExmnMnhlChecked(exmnMnhlChecked);
|
|
setActiveLayerSettingOrder((currentOrder) => {
|
|
const activeKeys = [
|
|
waterDepthChecked ? "waterDepthLegend" : null,
|
|
sinkholeChecked ? "sinkhole" : null,
|
|
agedPipeChecked ? "agedPipe" : null,
|
|
exmnPplChecked ? "exmnPpl" : null,
|
|
exmnMnhlChecked ? "exmnMnhl" : null,
|
|
].filter((key): key is string => Boolean(key));
|
|
|
|
return [
|
|
...currentOrder.filter((key) => activeKeys.includes(key)),
|
|
...activeKeys.filter((key) => !currentOrder.includes(key)),
|
|
];
|
|
});
|
|
if (sinkholeChecked) {
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
}
|
|
if (!sinkholeChecked) {
|
|
removeSinkholeLayer();
|
|
closeSinkholeDetail();
|
|
}
|
|
if (!exmnPplChecked) {
|
|
removeExmnResultLayer("PPL");
|
|
}
|
|
if (!exmnMnhlChecked) {
|
|
removeExmnResultLayer("MNHL");
|
|
}
|
|
},
|
|
[
|
|
clearFldngHstrySelection,
|
|
closeSinkholeDetail,
|
|
handleWaterDepthVisibility,
|
|
removeExmnResultLayer,
|
|
removeFlowDirectionLayer,
|
|
removeSinkholeLayer,
|
|
retainFlowDirectionUsageCodes,
|
|
],
|
|
);
|
|
|
|
const waterDepthLegendItems = useMemo(
|
|
() => extractWaterDepthLegendItems(checkedNodesDataRef.current ?? []),
|
|
[checkedLayerLegendVersion],
|
|
);
|
|
|
|
const applySinkholeYearRange = useCallback(
|
|
(nextRange: SinkholeYearRange) => {
|
|
const normalizedRange = normalizeSinkholeYearRange(
|
|
nextRange,
|
|
sinkholeYearRange,
|
|
);
|
|
setSinkholeYearRange(normalizedRange);
|
|
setIsSinkholePopupFilterActive(false);
|
|
setAppliedSinkholeFilters(
|
|
normalizeSinkholeFilters(
|
|
buildSinkholeFiltersFromYearRange(normalizedRange, sinkholeScope),
|
|
sinkholeScope,
|
|
),
|
|
);
|
|
setSinkholeSelectionVersion((version) => version + 1);
|
|
},
|
|
[sinkholeScope, sinkholeYearRange],
|
|
);
|
|
|
|
/** 침수위험지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */
|
|
const openSbmrgDngrMapPopupWindow = useCallback(() => {
|
|
const popup = openWindowPopup(SBMRG_DNGR_MAP_POPUP_KEY as WindowPopupKey);
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
}
|
|
return popup;
|
|
}, []);
|
|
|
|
/** 도시침수지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */
|
|
const openFloodDngrMapPopupWindow = useCallback(() => {
|
|
const popup = openWindowPopup(FLOOD_DNGR_MAP_POPUP_KEY as WindowPopupKey);
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
}
|
|
return popup;
|
|
}, []);
|
|
|
|
const handleOpenLayerPopup = useCallback(
|
|
(_layerNm: string, node?: any) => {
|
|
if (isAgedPipeLayerNode(node)) {
|
|
openAgedPipeList();
|
|
return false;
|
|
}
|
|
if (isSinkholeRootNode(node)) {
|
|
openSinkholePopupWindow();
|
|
return false;
|
|
}
|
|
if (isSbmrgDngrMapRootNode(node)) {
|
|
openSbmrgDngrMapPopupWindow();
|
|
return false;
|
|
}
|
|
if (isFloodDngrMapRootNode(node)) {
|
|
openFloodDngrMapPopupWindow();
|
|
return false;
|
|
}
|
|
if (isFldngHstryNode(node)) {
|
|
openFldngHstryPopupWindow();
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
[
|
|
openAgedPipeList,
|
|
openFldngHstryPopupWindow,
|
|
openFloodDngrMapPopupWindow,
|
|
openSbmrgDngrMapPopupWindow,
|
|
openSinkholePopupWindow,
|
|
],
|
|
);
|
|
|
|
// 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다.
|
|
const activeLayerSettingItems = useMemo<
|
|
{ key: string; label: string; content: ReactNode }[]
|
|
>(() => {
|
|
const nextItems: {
|
|
key: string;
|
|
label: string;
|
|
content: ReactNode;
|
|
order: number;
|
|
}[] = [];
|
|
const resolveSettingOrder = (key: string, fallback: number) => {
|
|
const index = activeLayerSettingOrder.indexOf(key);
|
|
return index >= 0 ? index : fallback;
|
|
};
|
|
|
|
if (isExmnPplChecked) {
|
|
nextItems.push({
|
|
key: "exmnPpl",
|
|
label: "관로조사",
|
|
order: resolveSettingOrder("exmnPpl", 9995),
|
|
content: (
|
|
<ExmnLayerSettingPanel
|
|
jobType="PPL"
|
|
filters={exmnPplFilters}
|
|
onFiltersChange={setExmnPplFilters}
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (isExmnMnhlChecked) {
|
|
nextItems.push({
|
|
key: "exmnMnhl",
|
|
label: "맨홀조사",
|
|
order: resolveSettingOrder("exmnMnhl", 9996),
|
|
content: (
|
|
<ExmnLayerSettingPanel
|
|
jobType="MNHL"
|
|
filters={exmnMnhlFilters}
|
|
onFiltersChange={setExmnMnhlFilters}
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (isSinkholeChecked) {
|
|
nextItems.push({
|
|
key: "sinkhole",
|
|
label: "지반침하정보",
|
|
order: resolveSettingOrder("sinkhole", 9998),
|
|
content: (
|
|
<SinkholeLayerSettingPanel
|
|
radiusMeters={sinkholeRadiusMeters}
|
|
onApplyRadius={applySinkholeRadius}
|
|
yearRange={sinkholeYearRange}
|
|
onApplyYearRange={applySinkholeYearRange}
|
|
lastSyncSummary={lastSinkholeSyncSummary}
|
|
isAdmin={userInfo?.adminRoleYn === "Y"}
|
|
compact
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (isAgedPipeChecked) {
|
|
nextItems.push({
|
|
key: "agedPipe",
|
|
label: "노후관로",
|
|
order: resolveSettingOrder("agedPipe", 9999),
|
|
content: (
|
|
<AgedPipeLayerSettingPanel
|
|
filters={draftAgedPipeFilters}
|
|
onFiltersChange={setDraftAgedPipeFilters}
|
|
onApply={handleApplyAgedPipeFilters}
|
|
compact
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (isWaterDepthLayerActive) {
|
|
nextItems.push({
|
|
key: "waterDepthLegend",
|
|
label: "침수심 범례",
|
|
order: resolveSettingOrder("waterDepthLegend", 9997),
|
|
content: (
|
|
<div className="overflow-hidden rounded-sm border border-border bg-secondary">
|
|
<div className="px-3 py-2">
|
|
<div className="rounded-sm border border-border bg-background px-3 py-3">
|
|
<div className="mb-2 bg-primary py-1 text-center text-[1.1rem] font-semibold text-primary-foreground">
|
|
침수심 범례
|
|
</div>
|
|
<div className="space-y-1">
|
|
{waterDepthLegendItems.map((item) => (
|
|
<div key={item.key} className="flex items-center gap-2">
|
|
<span
|
|
className="inline-block h-4 w-10 shrink-0"
|
|
style={{
|
|
backgroundColor: item.color,
|
|
border: `1px solid ${item.borderColor}`,
|
|
}}
|
|
/>
|
|
<span className="text-[1rem] font-medium text-label">
|
|
{item.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
),
|
|
});
|
|
}
|
|
|
|
return nextItems
|
|
.sort((a, b) => a.order - b.order)
|
|
.map(({ key, label, content }) => ({ key, label, content }));
|
|
}, [
|
|
activeLayerSettingOrder,
|
|
applySinkholeRadius,
|
|
applySinkholeYearRange,
|
|
draftAgedPipeFilters,
|
|
exmnMnhlFilters,
|
|
exmnPplFilters,
|
|
handleApplyAgedPipeFilters,
|
|
isAgedPipeChecked,
|
|
isExmnMnhlChecked,
|
|
isExmnPplChecked,
|
|
isSinkholeChecked,
|
|
isWaterDepthLayerActive,
|
|
lastSinkholeSyncSummary,
|
|
sinkholeRadiusMeters,
|
|
sinkholeYearRange,
|
|
userInfo?.adminRoleYn,
|
|
waterDepthLegendItems,
|
|
]);
|
|
|
|
// 설정값 변경으로 설정 컴포넌트가 다시 만들어져도 사용자가 보던 내부 스크롤 위치를 유지한다.
|
|
const activeLayerSettingScrollTopRef = useRef(0);
|
|
const handleActiveLayerSettingScroll = useCallback(
|
|
(event: UIEvent<HTMLDivElement>) => {
|
|
activeLayerSettingScrollTopRef.current = event.currentTarget.scrollTop;
|
|
},
|
|
[],
|
|
);
|
|
const restoreActiveLayerSettingScroll = useCallback(
|
|
(element: HTMLDivElement | null) => {
|
|
if (!element) return;
|
|
|
|
const maxScrollTop = Math.max(
|
|
0,
|
|
element.scrollHeight - element.clientHeight,
|
|
);
|
|
element.scrollTop = Math.min(
|
|
activeLayerSettingScrollTopRef.current,
|
|
maxScrollTop,
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 선택된 설정이 있을 때만 하단 전용 섹션을 노출하고, 세로 공간이 부족하면 내부 스크롤로 처리한다.
|
|
const ActiveLayerSettingView = useCallback(() => {
|
|
if (activeLayerSettingItems.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mt-3 flex h-[34vh] max-h-[32rem] min-h-[24rem] flex-col overflow-hidden rounded-sm border border-border bg-background shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
|
|
<div className="border-b border-border bg-secondary px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[1.2rem] font-semibold text-label">
|
|
선택 레이어 설정
|
|
</span>
|
|
<span className="text-[1rem] text-muted-foreground">
|
|
{activeLayerSettingItems.length}건
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
{activeLayerSettingItems.map((item) => (
|
|
<span
|
|
key={item.key}
|
|
className="rounded-full border border-border bg-background px-2 py-[1px] text-[1rem] text-label"
|
|
>
|
|
{item.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={restoreActiveLayerSettingScroll}
|
|
onScroll={handleActiveLayerSettingScroll}
|
|
className="min-h-0 flex-1 overflow-y-scroll px-1 py-2 [scrollbar-gutter:stable] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]"
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
{activeLayerSettingItems.map((item) => (
|
|
<Fragment key={item.key}>{item.content}</Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}, [
|
|
activeLayerSettingItems,
|
|
handleActiveLayerSettingScroll,
|
|
restoreActiveLayerSettingScroll,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
|
|
|
|
if (!isAgedPipeChecked) return;
|
|
|
|
const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
|
|
(item: any) =>
|
|
item?.lyrTyNm === AGED_PIPE_LAYER_TYPE && item?.actlLyrYn === "Y",
|
|
) as any;
|
|
|
|
if (!baseLayerCode) return;
|
|
|
|
const layerCode = {
|
|
...baseLayerCode,
|
|
id: AGED_PIPE_HIGHLIGHT_LAYER_ID,
|
|
lyrSn: AGED_PIPE_HIGHLIGHT_LAYER_ID,
|
|
lyrNm: "노후관로",
|
|
lyrTyNm: AGED_PIPE_LAYER_TYPE,
|
|
gobjTy: "line",
|
|
actlLyrYn: "Y",
|
|
exprsYn: "Y",
|
|
clickPsblYn: "Y",
|
|
cmncNm: "bbox",
|
|
srvcNm: "wfs",
|
|
flterSql: buildAgedPipeCqlFilters(appliedAgedPipeFilters).join(" and "),
|
|
lnWdth: "4",
|
|
lnColor: AGED_PIPE_HIGHLIGHT_COLOR,
|
|
drawOrdr: "9999",
|
|
minViewSize: String(AGED_PIPE_MIN_ZOOM),
|
|
};
|
|
|
|
const layer = core?.addWfsLayer?.(layerCode);
|
|
layer?.set?.("id", AGED_PIPE_HIGHLIGHT_LAYER_ID);
|
|
layer?.set?.("lyrNm", "노후관로");
|
|
layer?.set?.("lyrTyNm", AGED_PIPE_LAYER_TYPE);
|
|
|
|
return () => {
|
|
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
|
|
};
|
|
}, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
|
|
|
|
if (!isFldngHstryChecked) return;
|
|
|
|
const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
|
|
(item: any) =>
|
|
item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y",
|
|
) as any;
|
|
|
|
if (!baseLayerCode) return;
|
|
|
|
const layerCode = {
|
|
...baseLayerCode,
|
|
id: FLDNG_HSTRY_WFS_LAYER_ID,
|
|
lyrSn: FLDNG_HSTRY_WFS_LAYER_ID,
|
|
lyrNm: "침수흔적도",
|
|
lyrTyNm: FLOOD_HISTORY_LAYER_TYPE,
|
|
actlLyrYn: "Y",
|
|
exprsYn: "Y",
|
|
clickPsblYn: "Y",
|
|
cmncNm: "bbox",
|
|
srvcNm: "wfs",
|
|
};
|
|
|
|
const layer = core?.addWfsLayer?.(layerCode);
|
|
layer?.set?.("id", FLDNG_HSTRY_WFS_LAYER_ID);
|
|
layer?.set?.("lyrNm", "침수흔적도");
|
|
layer?.set?.("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE);
|
|
|
|
return () => {
|
|
core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
|
|
};
|
|
}, [isFldngHstryChecked, mapReady]);
|
|
|
|
const openAnalysisPopup = useCallback(
|
|
(popupKey: WindowPopupKey) => {
|
|
const popup = openWindowPopup(popupKey);
|
|
if (!popup) {
|
|
alert(
|
|
"브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.",
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (popupKey === "pipeAnalysisPopup") {
|
|
pipeAnalysisPopupWindowRef.current = popup;
|
|
}
|
|
|
|
if (popupKey === "flowRiskPopup") {
|
|
clearWaterRiskPopupCloseWatcher();
|
|
waterRiskPopupWindowRef.current = popup;
|
|
waterRiskPopupCloseWatcherRef.current = window.setInterval(() => {
|
|
if (!popup.closed) return;
|
|
clearWaterRiskPopupCloseWatcher();
|
|
waterRiskPopupWindowRef.current = null;
|
|
clearWaterRiskSelection();
|
|
}, 300);
|
|
}
|
|
|
|
return popup;
|
|
},
|
|
[clearWaterRiskPopupCloseWatcher, clearWaterRiskSelection],
|
|
);
|
|
|
|
const openGisEditSessionPopup = useCallback(() => {
|
|
const existingPopup = gisEditSessionPopupWindowRef.current;
|
|
if (existingPopup && !existingPopup.closed) {
|
|
existingPopup.focus();
|
|
window.focus?.();
|
|
return existingPopup;
|
|
}
|
|
|
|
const popup = openWindowPopup("gisEditSessionPopup");
|
|
if (!popup) return null;
|
|
|
|
gisEditSessionPopupWindowRef.current = popup;
|
|
if (gisEditSessionPopupCloseWatcherRef.current) {
|
|
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
|
|
gisEditSessionPopupCloseWatcherRef.current = null;
|
|
}
|
|
gisEditSessionPopupCloseWatcherRef.current = window.setInterval(() => {
|
|
if (!popup.closed) return;
|
|
if (gisEditSessionPopupCloseWatcherRef.current) {
|
|
window.clearInterval(gisEditSessionPopupCloseWatcherRef.current);
|
|
gisEditSessionPopupCloseWatcherRef.current = null;
|
|
}
|
|
gisEditSessionPopupWindowRef.current = null;
|
|
setGisEditSessionView("inline");
|
|
}, 300);
|
|
window.focus?.();
|
|
return popup;
|
|
}, []);
|
|
|
|
const ensureGisEditPreviewLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (gisEditPreviewLayerRef.current && gisEditPreviewSourceRef.current) {
|
|
const layers = map.getLayers?.().getArray?.() ?? [];
|
|
if (!layers.includes(gisEditPreviewLayerRef.current)) {
|
|
map.addLayer(gisEditPreviewLayerRef.current);
|
|
}
|
|
gisEditPreviewLayerRef.current.setZIndex?.(10001);
|
|
return gisEditPreviewSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
style: createGisEditPreviewStyle,
|
|
zIndex: 10001,
|
|
updateWhileAnimating: true,
|
|
updateWhileInteracting: true,
|
|
});
|
|
|
|
layer.set("id", GIS_EDIT_PREVIEW_LAYER_ID);
|
|
layer.set("lyrNm", "GIS 편집 미리보기");
|
|
layer.set("lyrTyNm", GIS_EDIT_PREVIEW_LAYER_ID);
|
|
// Preview selection is handled by map.forEachFeatureAtPixel; keep it out of the common Select styler.
|
|
layer.set("clickPsblYn", "N");
|
|
layer.setVisible?.(true);
|
|
|
|
map.addLayer(layer);
|
|
gisEditPreviewLayerRef.current = layer;
|
|
gisEditPreviewSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const clearGisEditPreviewLayer = useCallback(() => {
|
|
gisEditPreviewSourceRef.current?.clear();
|
|
setGisEditPreviewCursor("");
|
|
}, [setGisEditPreviewCursor]);
|
|
|
|
const ensureGisEditPipeSnapSource = useCallback(() => {
|
|
if (gisEditPipeSnapSourceRef.current) {
|
|
return gisEditPipeSnapSourceRef.current;
|
|
}
|
|
const source = new VectorSource();
|
|
gisEditPipeSnapSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const clearGisEditPipeSnapSource = useCallback(() => {
|
|
gisEditPipeSnapAbortControllerRef.current?.abort();
|
|
gisEditPipeSnapAbortControllerRef.current = null;
|
|
gisEditPipeSnapSourceRef.current?.clear();
|
|
}, []);
|
|
|
|
const buildGisEditPipeSnapPreviewFeatures = useCallback(
|
|
(excludedChangeId?: string | null) => {
|
|
const normalizedExcludedChangeId = String(excludedChangeId ?? "").trim();
|
|
const geoJson = new GeoJSON();
|
|
|
|
return gisEdit.changes
|
|
.filter((change) => change.layerId === "tb_ms_swage_ppl")
|
|
.filter(
|
|
(change) =>
|
|
!change.attributeOnly &&
|
|
change.editType !== "DELETE" &&
|
|
Boolean(change.afterGeometry),
|
|
)
|
|
.filter((change) => change.changeId !== normalizedExcludedChangeId)
|
|
.map((change) => {
|
|
const feature = geoJson.readFeature(
|
|
{
|
|
type: "Feature",
|
|
geometry: change.afterGeometry,
|
|
properties: {
|
|
fid:
|
|
change.fid ??
|
|
change.featureRef?.fid ??
|
|
change.objectId ??
|
|
change.changeId,
|
|
layerId: change.layerId,
|
|
changeId: change.changeId,
|
|
},
|
|
},
|
|
{
|
|
dataProjection: FLOW_DIRECTION_SRS,
|
|
featureProjection: GisApp.TO_PROJ,
|
|
},
|
|
);
|
|
feature.setId(`gis-edit-snap:${change.changeId}`);
|
|
return feature;
|
|
});
|
|
},
|
|
[gisEdit.changes],
|
|
);
|
|
|
|
const loadGisEditPipeSnapSource = useCallback(
|
|
async (options?: {
|
|
excludedFid?: string | null;
|
|
excludedChangeId?: string | null;
|
|
}) => {
|
|
const map = gisMaps.current;
|
|
const source = ensureGisEditPipeSnapSource();
|
|
if (!map || !source) return;
|
|
|
|
const size = map.getSize?.();
|
|
const view = map.getView?.();
|
|
if (!size || !view?.calculateExtent) return;
|
|
|
|
const normalizedExcludedFid = normalizeGisEditFid(
|
|
options?.excludedFid ?? "",
|
|
);
|
|
const normalizedExcludedChangeId = String(
|
|
options?.excludedChangeId ?? "",
|
|
).trim();
|
|
const hiddenPipeFidSet = new Set(
|
|
gisEdit.changes
|
|
.filter((change) => change.layerId === "tb_ms_swage_ppl")
|
|
.filter(
|
|
(change) => !change.attributeOnly && change.editType !== "DELETE",
|
|
)
|
|
.map((change) =>
|
|
normalizeGisEditFid(
|
|
change.fid ?? change.featureRef?.fid ?? change.objectId,
|
|
),
|
|
)
|
|
.filter((fid) => /^\d+$/.test(fid)),
|
|
);
|
|
if (/^\d+$/.test(normalizedExcludedFid)) {
|
|
hiddenPipeFidSet.add(normalizedExcludedFid);
|
|
}
|
|
|
|
const previewSnapFeatures = buildGisEditPipeSnapPreviewFeatures(
|
|
normalizedExcludedChangeId,
|
|
);
|
|
source.clear();
|
|
if (previewSnapFeatures.length > 0) {
|
|
source.addFeatures(previewSnapFeatures);
|
|
}
|
|
|
|
const screenExtent = view.calculateExtent(size);
|
|
const requestExtent = expandGisEditPipeSnapExtent(screenExtent);
|
|
const [minX, minY, maxX, maxY] = requestExtent;
|
|
const bboxFilter = `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${GisApp.TO_PROJ}')`;
|
|
const cqlFilter = bboxFilter;
|
|
|
|
const requestSeq = gisEditPipeSnapRequestSeqRef.current + 1;
|
|
gisEditPipeSnapRequestSeqRef.current = requestSeq;
|
|
gisEditPipeSnapAbortControllerRef.current?.abort();
|
|
const abortController = new AbortController();
|
|
gisEditPipeSnapAbortControllerRef.current = abortController;
|
|
|
|
try {
|
|
const layerModule = GisApp.Module.layer as any;
|
|
const storageName = String(layerModule?.storageNm ?? "hasudo:");
|
|
const params = new URLSearchParams({
|
|
SERVICE: "WFS",
|
|
VERSION: "1.1.0",
|
|
REQUEST: "GetFeature",
|
|
TYPENAME: `${storageName}tb_ms_swage_ppl`,
|
|
SRSNAME: GisApp.TO_PROJ,
|
|
OUTPUTFORMAT: "application/json",
|
|
CQL_FILTER: cqlFilter,
|
|
});
|
|
const response = await getRawJson(
|
|
`${GisApp.GEOSERVER_URL}?${params.toString()}`,
|
|
{
|
|
signal: abortController.signal,
|
|
},
|
|
);
|
|
if (requestSeq !== gisEditPipeSnapRequestSeqRef.current) return;
|
|
if (abortController.signal.aborted) return;
|
|
|
|
const featureCollection = getFeatureCollection(response);
|
|
const features = new GeoJSON()
|
|
.readFeatures(featureCollection, {
|
|
dataProjection: GisApp.TO_PROJ,
|
|
featureProjection: GisApp.TO_PROJ,
|
|
})
|
|
.filter(
|
|
(feature: any) =>
|
|
!hiddenPipeFidSet.has(getGisEditFeatureFid(feature)),
|
|
);
|
|
|
|
source.clear();
|
|
source.addFeatures(features);
|
|
if (previewSnapFeatures.length > 0) {
|
|
source.addFeatures(previewSnapFeatures);
|
|
}
|
|
} catch (error) {
|
|
if ((error as Error)?.name === "AbortError") return;
|
|
console.error("GIS ?? ???? ?? source ?? ??:", error);
|
|
source.clear();
|
|
if (previewSnapFeatures.length > 0) {
|
|
source.addFeatures(previewSnapFeatures);
|
|
}
|
|
} finally {
|
|
if (gisEditPipeSnapAbortControllerRef.current === abortController) {
|
|
gisEditPipeSnapAbortControllerRef.current = null;
|
|
}
|
|
}
|
|
},
|
|
[
|
|
buildGisEditPipeSnapPreviewFeatures,
|
|
ensureGisEditPipeSnapSource,
|
|
gisEdit.changes,
|
|
],
|
|
);
|
|
|
|
const restoreGisEditOriginalPipeMask = useCallback(() => {
|
|
gisEditOriginalLayerFilterRef.current.forEach((originalFilter, layer) => {
|
|
const source = layer?.getSource?.();
|
|
const params = { ...(source?.getParams?.() ?? {}) };
|
|
if (originalFilter) {
|
|
params.CQL_FILTER = originalFilter;
|
|
} else {
|
|
delete params.CQL_FILTER;
|
|
}
|
|
params._t = Date.now();
|
|
source?.updateParams?.(params);
|
|
source?.refresh?.();
|
|
source?.changed?.();
|
|
layer?.changed?.();
|
|
});
|
|
gisEditOriginalLayerFilterRef.current.clear();
|
|
|
|
gisEditOriginalFeatureStyleRef.current.forEach((originalStyle, feature) => {
|
|
feature?.setStyle?.(originalStyle);
|
|
feature?.changed?.();
|
|
});
|
|
gisEditOriginalFeatureStyleRef.current.clear();
|
|
gisMaps.current?.renderSync?.();
|
|
}, []);
|
|
|
|
const applyGisEditOriginalPipeMask = useCallback(
|
|
(hiddenFids: string[]) => {
|
|
const hiddenFidSet = new Set(
|
|
hiddenFids.map((fid) => normalizeGisEditFid(fid)).filter(Boolean),
|
|
);
|
|
|
|
if (hiddenFidSet.size === 0) {
|
|
restoreGisEditOriginalPipeMask();
|
|
return;
|
|
}
|
|
|
|
const map = gisMaps.current;
|
|
if (!map) return;
|
|
|
|
const filter = buildGisEditHiddenPipeFilter(Array.from(hiddenFidSet));
|
|
map.getAllLayers?.().forEach((layer: any) => {
|
|
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
|
|
if (layerTypeName !== "tb_ms_swage_ppl") return;
|
|
|
|
const source = layer.getSource?.();
|
|
if (source?.updateParams && filter) {
|
|
const params = { ...(source.getParams?.() ?? {}) };
|
|
if (!gisEditOriginalLayerFilterRef.current.has(layer)) {
|
|
gisEditOriginalLayerFilterRef.current.set(
|
|
layer,
|
|
String(params.CQL_FILTER ?? "").trim() || null,
|
|
);
|
|
}
|
|
const originalFilter =
|
|
gisEditOriginalLayerFilterRef.current.get(layer);
|
|
params.CQL_FILTER = originalFilter
|
|
? `(${originalFilter}) AND (${filter})`
|
|
: filter;
|
|
params._t = Date.now();
|
|
source.updateParams(params);
|
|
source.refresh?.();
|
|
source.changed?.();
|
|
layer.changed?.();
|
|
}
|
|
|
|
source?.getFeatures?.().forEach((feature: any) => {
|
|
const fid = getGisEditFeatureFid(feature);
|
|
const shouldHide = hiddenFidSet.has(fid);
|
|
if (
|
|
shouldHide &&
|
|
!gisEditOriginalFeatureStyleRef.current.has(feature)
|
|
) {
|
|
gisEditOriginalFeatureStyleRef.current.set(
|
|
feature,
|
|
feature.getStyle?.(),
|
|
);
|
|
feature.setStyle?.(() => []);
|
|
feature.changed?.();
|
|
}
|
|
if (
|
|
!shouldHide &&
|
|
gisEditOriginalFeatureStyleRef.current.has(feature)
|
|
) {
|
|
feature.setStyle?.(
|
|
gisEditOriginalFeatureStyleRef.current.get(feature),
|
|
);
|
|
gisEditOriginalFeatureStyleRef.current.delete(feature);
|
|
feature.changed?.();
|
|
}
|
|
});
|
|
});
|
|
|
|
gisMaps.current?.renderSync?.();
|
|
},
|
|
[restoreGisEditOriginalPipeMask],
|
|
);
|
|
|
|
const ensureGisEditVertexLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (gisEditVertexLayerRef.current && gisEditVertexSourceRef.current) {
|
|
return gisEditVertexSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 10005,
|
|
updateWhileAnimating: true,
|
|
updateWhileInteracting: true,
|
|
});
|
|
|
|
layer.set("id", GIS_EDIT_VERTEX_LAYER_ID);
|
|
layer.set("lyrNm", "GIS 편집 꼭지점");
|
|
layer.set("lyrTyNm", GIS_EDIT_VERTEX_LAYER_ID);
|
|
layer.set("clickPsblYn", "N");
|
|
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(GIS_EDIT_VERTEX_LAYER_ID);
|
|
map.addLayer(layer);
|
|
gisEditVertexLayerRef.current = layer;
|
|
gisEditVertexSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const clearGisEditVertexLayer = useCallback(() => {
|
|
gisEditVertexSourceRef.current?.clear();
|
|
gisEditSelectedVertexHandleKeyRef.current = "";
|
|
gisEditSelectedVertexMetaListRef.current = null;
|
|
}, []);
|
|
|
|
const applySelectedVertexHandleStyle = useCallback(() => {
|
|
const selectedHandleKey = gisEditSelectedVertexHandleKeyRef.current;
|
|
const vertexSource = gisEditVertexSourceRef.current;
|
|
if (!vertexSource) return;
|
|
|
|
vertexSource.getFeatures().forEach((feature: any) => {
|
|
const handleKind = String(
|
|
feature?.get?.("handleKind") ?? "",
|
|
) as VertexHandleMeta["kind"];
|
|
const handleSelectionKey = String(
|
|
feature?.get?.("handleSelectionKey") ?? "",
|
|
);
|
|
const isSelected =
|
|
Boolean(selectedHandleKey) && handleSelectionKey === selectedHandleKey;
|
|
feature.setStyle(getGisEditVertexHandleStyle(handleKind, isSelected));
|
|
});
|
|
vertexSource.changed?.();
|
|
}, []);
|
|
|
|
const clearSelectedVertexHandle = useCallback(() => {
|
|
gisEditSelectedVertexHandleKeyRef.current = "";
|
|
gisEditSelectedVertexMetaListRef.current = null;
|
|
applySelectedVertexHandleStyle();
|
|
}, [applySelectedVertexHandleStyle]);
|
|
|
|
const shouldUsePipeSnapSource =
|
|
gisEdit.isEditMode &&
|
|
gisEdit.isSnapEnabled &&
|
|
gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl";
|
|
|
|
const collectGisEditSnapFeatures = useCallback(
|
|
(targetFeature: any) => {
|
|
const map = gisMaps.current;
|
|
const previewSource = gisEditPreviewSourceRef.current;
|
|
const selectedLayerId = gisEdit.selectedLayer?.layerId;
|
|
if (!map || !previewSource || !selectedLayerId) return [] as any[];
|
|
|
|
const hiddenFidSet = new Set(
|
|
gisEdit.changes
|
|
.filter((change) => change.editType === "DELETE")
|
|
.map((change) =>
|
|
normalizeGisEditFid(
|
|
change.fid ?? change.featureRef?.fid ?? change.objectId,
|
|
),
|
|
)
|
|
.filter(Boolean),
|
|
);
|
|
const seen = new Set<string>();
|
|
const snapFeatures: any[] = [];
|
|
const targetChangeId = String(
|
|
targetFeature?.get?.("changeId") ?? "",
|
|
).trim();
|
|
|
|
const registerFeature = (feature: any) => {
|
|
if (!feature || feature === targetFeature) return;
|
|
if (!feature.getGeometry?.()) return;
|
|
if (getSelectedFeatureLayerTypeName(feature) !== selectedLayerId)
|
|
return;
|
|
|
|
const changeId = String(feature.get?.("changeId") ?? "").trim();
|
|
if (changeId && changeId === targetChangeId) return;
|
|
if (String(feature.get?.("editType") ?? "").trim() === "DELETE") return;
|
|
|
|
const fid = getGisEditFeatureFid(feature);
|
|
if (fid && hiddenFidSet.has(fid)) return;
|
|
|
|
const objectKey =
|
|
changeId ||
|
|
fid ||
|
|
String(feature.get?.("objectId") ?? feature.getId?.() ?? "");
|
|
const featureKey = `${selectedLayerId}:${objectKey}`;
|
|
if (!objectKey || seen.has(featureKey)) return;
|
|
|
|
seen.add(featureKey);
|
|
snapFeatures.push(feature);
|
|
};
|
|
|
|
previewSource.getFeatures().forEach(registerFeature);
|
|
map.getAllLayers?.().forEach((layer: any) => {
|
|
if (
|
|
layer === gisEditPreviewLayerRef.current ||
|
|
layer === gisEditVertexLayerRef.current
|
|
)
|
|
return;
|
|
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
|
|
if (layerTypeName !== selectedLayerId) return;
|
|
layer.getSource?.()?.getFeatures?.().forEach(registerFeature);
|
|
});
|
|
|
|
return snapFeatures;
|
|
},
|
|
[gisEdit.changes, gisEdit.selectedLayer?.layerId],
|
|
);
|
|
|
|
const removeGisEditDrawInteraction = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return;
|
|
|
|
if (gisEditDrawSnapRef.current) {
|
|
map.removeInteraction(gisEditDrawSnapRef.current);
|
|
gisEditDrawSnapRef.current = null;
|
|
}
|
|
if (gisEditDrawRef.current) {
|
|
map.removeInteraction(gisEditDrawRef.current);
|
|
gisEditDrawRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const removeGisEditInteractions = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return;
|
|
|
|
if (gisEditSnapRef.current) {
|
|
map.removeInteraction(gisEditSnapRef.current);
|
|
gisEditSnapRef.current = null;
|
|
}
|
|
if (gisEditTranslateRef.current) {
|
|
map.removeInteraction(gisEditTranslateRef.current);
|
|
gisEditTranslateRef.current = null;
|
|
}
|
|
if (gisEditModifyRef.current) {
|
|
const modifyInteractions = Array.isArray(gisEditModifyRef.current)
|
|
? gisEditModifyRef.current
|
|
: [gisEditModifyRef.current];
|
|
modifyInteractions.forEach((interaction: any) => {
|
|
if (interaction) {
|
|
map.removeInteraction(interaction);
|
|
}
|
|
});
|
|
gisEditModifyRef.current = null;
|
|
}
|
|
clearGisEditVertexLayer();
|
|
setGisEditPreviewCursor("");
|
|
}, [clearGisEditVertexLayer, setGisEditPreviewCursor]);
|
|
|
|
const refreshGisEditTargetLayers = useCallback(
|
|
(layerTypeNames: string[], focusFid?: string | null) => {
|
|
const refreshTargetNames = new Set(
|
|
(layerTypeNames.length > 0 ? layerTypeNames : ["tb_ms_swage_ppl"])
|
|
.map((layerTypeName) => String(layerTypeName ?? "").trim())
|
|
.filter(Boolean),
|
|
);
|
|
const map = gisMaps.current;
|
|
map?.getAllLayers?.().forEach((layer: any) => {
|
|
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
|
|
if (!refreshTargetNames.has(layerTypeName)) return;
|
|
|
|
const source = layer.getSource?.();
|
|
source?.updateParams?.({ _t: Date.now() });
|
|
source?.refresh?.();
|
|
source?.changed?.();
|
|
layer.changed?.();
|
|
});
|
|
|
|
if (focusFid) {
|
|
const core = GisApp.Module.layer as any;
|
|
core?.showSearchResultsFromGeoJSON?.("tb_ms_swage_ppl", focusFid);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// GIS 편집 진입 시에는 화면 안 인라인 세션을 우선 표시하고, 윈도우 팝업은 사용자가 명시적으로 연다.
|
|
const handleToggleGisEdit = useCallback(() => {
|
|
if (gisEdit.isEditMode) {
|
|
closeGisEditSessionPopup();
|
|
} else {
|
|
setGisEditSessionView("inline");
|
|
}
|
|
gisEdit.toggleEditMode();
|
|
setIsOpen(false);
|
|
}, [closeGisEditSessionPopup, gisEdit]);
|
|
|
|
const handleOpenGisEditSessionPopup = useCallback(() => {
|
|
const popup = openGisEditSessionPopup();
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
return;
|
|
}
|
|
setGisEditSessionView("popup");
|
|
syncGisEditSessionPopup();
|
|
}, [openGisEditSessionPopup, syncGisEditSessionPopup]);
|
|
|
|
// 툴바 버튼은 현재 지도 중심 또는 다음 피처 선택을 기준으로 로컬 변경분을 만든다.
|
|
const handleSelectGisEditTool = useCallback(
|
|
(tool: "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE") => {
|
|
if (gisEdit.activeTool === tool) {
|
|
gisEdit.deactivateTool();
|
|
removeGisEditDrawInteraction();
|
|
removeGisEditInteractions();
|
|
return;
|
|
}
|
|
|
|
removeGisEditDrawInteraction();
|
|
removeGisEditInteractions();
|
|
gisEdit.activateTool(tool, gisMaps.current);
|
|
setIsOpen(false);
|
|
},
|
|
[gisEdit, removeGisEditDrawInteraction, removeGisEditInteractions],
|
|
);
|
|
|
|
const handleCloseGisEdit = useCallback(() => {
|
|
removeGisEditInteractions();
|
|
removeGisEditDrawInteraction();
|
|
clearGisEditPreviewLayer();
|
|
clearGisEditVertexLayer();
|
|
closeGisEditSessionPopup();
|
|
gisEdit.discardSession();
|
|
setGisEditDetailChangeId(null);
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
}, [
|
|
clearGisEditPreviewLayer,
|
|
clearGisEditVertexLayer,
|
|
closeGisEditSessionPopup,
|
|
gisEdit,
|
|
removeGisEditDrawInteraction,
|
|
removeGisEditInteractions,
|
|
]);
|
|
|
|
const handleSaveGisEdit = useCallback(async () => {
|
|
if (isGisEditSaving) return;
|
|
|
|
const payload = gisEdit.save(gisMaps.current);
|
|
if (!payload) return;
|
|
|
|
const invalidRequiredChange = findInvalidPipeRequiredChange(
|
|
payload.changes,
|
|
);
|
|
if (invalidRequiredChange) {
|
|
const message =
|
|
"관리기관코드, 시설관리번호, 기관관리번호, 용도는 필수 입력 항목입니다.";
|
|
alert(message);
|
|
gisEdit.announceStatus(message);
|
|
setGisEditDetailChangeId(invalidRequiredChange.changeId);
|
|
setIsOpen(true);
|
|
return;
|
|
}
|
|
|
|
setIsGisEditSaving(true);
|
|
gisEdit.announceStatus("관로 편집 변경분을 저장 중입니다.");
|
|
try {
|
|
const response = await postRawJson(
|
|
"/sewerage-service/api/v1/map/gisEditSessions/save",
|
|
payload,
|
|
);
|
|
const results = Array.isArray(response?.results) ? response.results : [];
|
|
const refreshLayerTypeNames = Array.isArray(
|
|
response?.refreshLayerTypeNames,
|
|
)
|
|
? response.refreshLayerTypeNames
|
|
: results.flatMap((result: any) => result?.refreshLayerTypeNames ?? []);
|
|
const focusFid =
|
|
results.find(
|
|
(result: any) => result?.fid && result?.editType !== "DELETE",
|
|
)?.fid ?? null;
|
|
|
|
removeGisEditInteractions();
|
|
removeGisEditDrawInteraction();
|
|
clearGisEditPreviewLayer();
|
|
clearGisEditVertexLayer();
|
|
refreshGisEditTargetLayers(refreshLayerTypeNames, focusFid);
|
|
gisEdit.clearChanges();
|
|
setGisEditDetailChangeId(null);
|
|
setIsOpen(false);
|
|
closeGisEditSessionPopup();
|
|
gisEdit.announceStatus(
|
|
response?.message ?? "하수관로 편집 저장이 완료되었습니다.",
|
|
);
|
|
} catch (error) {
|
|
console.error("GIS 편집 저장 실패:", error);
|
|
const apiError = error as Error & { body?: any };
|
|
gisEdit.announceStatus(
|
|
`하수관로 편집 저장 실패: ${apiError.body?.message ?? apiError.message}`,
|
|
);
|
|
} finally {
|
|
setIsGisEditSaving(false);
|
|
}
|
|
}, [
|
|
clearGisEditPreviewLayer,
|
|
clearGisEditVertexLayer,
|
|
closeGisEditSessionPopup,
|
|
gisEdit,
|
|
isGisEditSaving,
|
|
refreshGisEditTargetLayers,
|
|
removeGisEditDrawInteraction,
|
|
removeGisEditInteractions,
|
|
]);
|
|
|
|
const handleApplyGisEditPipeBasicInfo = useCallback(
|
|
(changeId: string, basicInfo: GisEditPipeBasicInfo) => {
|
|
gisEdit.updateChangeBasicInfo(changeId, basicInfo);
|
|
},
|
|
[gisEdit.updateChangeBasicInfo],
|
|
);
|
|
|
|
const canSelectFeatureDuringGisEdit = useCallback(
|
|
(feature: any) => {
|
|
if (!gisEdit.isEditMode) return true;
|
|
if (!gisEdit.activeTool) return true;
|
|
|
|
if (
|
|
gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" &&
|
|
getSelectedFeatureLayerTypeName(feature) !== "tb_ms_swage_ppl"
|
|
) {
|
|
gisEdit.announceStatus("하수관로를 선택해주세요.");
|
|
}
|
|
return false;
|
|
},
|
|
[gisEdit],
|
|
);
|
|
|
|
const GisEditAwareDetailView = useCallback(
|
|
(props: any) => {
|
|
const activeRightPanelTab =
|
|
rightPanelTabs.find((tab) => tab.id === activeRightPanelTabId) ?? null;
|
|
const panelFeatureData =
|
|
activeRightPanelTab?.featureData ?? props.featureData;
|
|
const currentChange = gisEditDetailChangeRef.current;
|
|
const currentFeatureData = gisEditDetailFeatureDataRef.current;
|
|
const isEditMode = gisEditIsEditModeRef.current;
|
|
const selectedLayerTypeName =
|
|
getSelectedFeatureLayerTypeName(panelFeatureData);
|
|
const panelSinkholeDetail =
|
|
activeRightPanelTab?.kind === "sinkhole"
|
|
? (activeRightPanelTab.sinkholeDetail ?? selectedSinkholeDetail)
|
|
: selectedSinkholeDetail;
|
|
const panelSinkholeNearbyPipes =
|
|
activeRightPanelTab?.kind === "sinkhole"
|
|
? (activeRightPanelTab.nearbyPipes ?? selectedSinkholeNearbyPipes)
|
|
: selectedSinkholeNearbyPipes;
|
|
const tabsView = (
|
|
<RightPanelBookmarkTabs
|
|
tabs={rightPanelTabs}
|
|
activeTabId={activeRightPanelTabId}
|
|
onActivate={activateRightPanelTab}
|
|
onBack={activateRightPanelTab}
|
|
/>
|
|
);
|
|
const closeActiveTab = () => {
|
|
if (activeRightPanelTabId) {
|
|
closeRightPanelTab(activeRightPanelTabId);
|
|
return;
|
|
}
|
|
props.onClose?.();
|
|
};
|
|
|
|
if (
|
|
isEditMode &&
|
|
currentChange &&
|
|
currentChange.layerId === "tb_ms_swage_ppl" &&
|
|
currentChange.editType !== "DELETE"
|
|
) {
|
|
return (
|
|
<>
|
|
{tabsView}
|
|
<GisEditPipeSessionPopup
|
|
change={currentChange}
|
|
featureData={currentFeatureData ?? panelFeatureData}
|
|
onClose={() => {
|
|
setGisEditDetailChangeId(null);
|
|
closeActiveTab();
|
|
}}
|
|
onSave={handleApplyGisEditPipeBasicInfo}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (
|
|
selectedLayerTypeName === SINKHOLE_LAYER_TYPE &&
|
|
panelSinkholeDetail
|
|
) {
|
|
return (
|
|
<>
|
|
{tabsView}
|
|
<SinkholeDetailPanel
|
|
detail={panelSinkholeDetail}
|
|
nearbyPipes={panelSinkholeNearbyPipes}
|
|
radiusMeters={sinkholeRadiusMeters}
|
|
onSelectPipe={focusSinkholeNearbyPipe}
|
|
onClose={closeActiveTab}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{tabsView}
|
|
<FeatureClickEventPopup
|
|
{...props}
|
|
featureData={panelFeatureData}
|
|
surveyMode={activeRightPanelTab?.category === "exmnSurvey"}
|
|
onClose={() => {
|
|
setGisEditDetailChangeId(null);
|
|
closeActiveTab();
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
},
|
|
[
|
|
activateRightPanelTab,
|
|
activeRightPanelTabId,
|
|
closeRightPanelTab,
|
|
focusSinkholeNearbyPipe,
|
|
handleApplyGisEditPipeBasicInfo,
|
|
rightPanelTabs,
|
|
selectedSinkholeDetail,
|
|
selectedSinkholeNearbyPipes,
|
|
sinkholeRadiusMeters,
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return;
|
|
|
|
const map = gisMaps.current;
|
|
const clickKey = map.on("singleclick", (event: any) => {
|
|
const vertexHandleFeature =
|
|
gisEdit.pendingInteraction?.editType === "VERTEX_EDIT"
|
|
? map.forEachFeatureAtPixel(
|
|
event.pixel,
|
|
(feature: any, layer: any) =>
|
|
layer === gisEditVertexLayerRef.current ? feature : null,
|
|
{ hitTolerance: 14 },
|
|
)
|
|
: null;
|
|
if (vertexHandleFeature) {
|
|
event.preventDefault?.();
|
|
const handleKind = String(
|
|
vertexHandleFeature.get?.("handleKind") ?? "",
|
|
);
|
|
if (handleKind !== "vertex") {
|
|
clearSelectedVertexHandle();
|
|
gisEdit.announceStatus(
|
|
"흰색 midpoint는 드래그해서 새 꼭지점을 추가할 수 있습니다. Delete 삭제 대상은 주황색 실꼭지점입니다.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
gisEditSelectedVertexHandleKeyRef.current = String(
|
|
vertexHandleFeature.get?.("handleSelectionKey") ?? "",
|
|
);
|
|
gisEditSelectedVertexMetaListRef.current =
|
|
(vertexHandleFeature.get?.("handleMetaList") as
|
|
| VertexHandleMeta[]
|
|
| undefined) ?? null;
|
|
applySelectedVertexHandleStyle();
|
|
gisEdit.announceStatus(
|
|
"주황색 실꼭지점을 선택했습니다. Delete 키를 누르면 해당 꼭지점을 삭제합니다.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const previewFeature = map.forEachFeatureAtPixel(
|
|
event.pixel,
|
|
(feature: any) =>
|
|
String(feature?.get?.("changeId") ?? "").trim() ? feature : null,
|
|
{ hitTolerance: 12 },
|
|
);
|
|
// 편집 도구가 켜진 동안에는 공통 Select를 우회하므로 원본 WFS 피처도 여기서 직접 판정한다.
|
|
const editableSourceFeature = previewFeature
|
|
? null
|
|
: gisEdit.activeTool && gisEdit.activeTool !== "ADD"
|
|
? map.forEachFeatureAtPixel(
|
|
event.pixel,
|
|
(feature: any) => {
|
|
if (String(feature?.get?.("changeId") ?? "").trim())
|
|
return null;
|
|
return getSelectedFeatureLayerTypeName(feature) ===
|
|
gisEdit.selectedLayer?.layerId
|
|
? feature
|
|
: null;
|
|
},
|
|
{ hitTolerance: 12 },
|
|
)
|
|
: null;
|
|
const selectedFeature = previewFeature ?? editableSourceFeature;
|
|
if (!selectedFeature) return;
|
|
clearSelectedVertexHandle();
|
|
|
|
const editSelection = gisEdit.registerFeatureSelection(selectedFeature);
|
|
if (!editSelection.handled) return;
|
|
|
|
event.preventDefault?.();
|
|
|
|
if (editSelection.editType === "DELETE") {
|
|
const select = GisApp.Module.select as any;
|
|
select?.resetSelectedFeature?.();
|
|
setGisEditDetailChangeId(null);
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
gisEdit.activeTool === "MOVE" ||
|
|
gisEdit.activeTool === "VERTEX_EDIT"
|
|
) {
|
|
if (
|
|
gisEdit.activeTool === "MOVE" &&
|
|
typeof editSelection.changeId === "string" &&
|
|
editSelection.changeId
|
|
) {
|
|
gisEditMoveGripMetaRef.current = {
|
|
changeId: String(editSelection.changeId),
|
|
meta: getClosestGeometryVertexMeta(
|
|
selectedFeature.getGeometry?.(),
|
|
map,
|
|
event.coordinate,
|
|
),
|
|
};
|
|
}
|
|
setGisEditDetailChangeId(null);
|
|
setFeatureData(null);
|
|
setIsOpen(false);
|
|
setIsSearchModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
const selectedChangeId =
|
|
typeof editSelection.changeId === "string"
|
|
? editSelection.changeId
|
|
: "";
|
|
const selectedChange = gisEdit.changes.find(
|
|
(change) => change.changeId === selectedChangeId,
|
|
);
|
|
const canOpenPipeDetail =
|
|
selectedChangeId && editSelection.editType !== "DELETE";
|
|
|
|
if (canOpenPipeDetail) {
|
|
openGisEditPipeDetail(selectedChangeId);
|
|
setFeatureData(
|
|
selectedChange?.editType === "ADD"
|
|
? {
|
|
lyrTypeNm: "tb_ms_swage_ppl",
|
|
fcltMngNo: "",
|
|
mngInstCd: "",
|
|
fclt_mng_no: "",
|
|
inst_mng_no: "",
|
|
}
|
|
: null,
|
|
);
|
|
window.setTimeout(() => {
|
|
setGisEditDetailChangeId(selectedChangeId);
|
|
setIsOpen(true);
|
|
}, 0);
|
|
setIsSearchModalOpen(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
unByKey(clickKey);
|
|
};
|
|
}, [
|
|
applySelectedVertexHandleStyle,
|
|
clearSelectedVertexHandle,
|
|
gisEdit.activeTool,
|
|
gisEdit.changes,
|
|
gisEdit.isEditMode,
|
|
gisEdit.pendingInteraction,
|
|
gisEdit.announceStatus,
|
|
gisEdit.registerFeatureSelection,
|
|
gisEdit.selectedLayer,
|
|
mapReady,
|
|
openGisEditPipeDetail,
|
|
]);
|
|
|
|
const gisEditSelectedLayerId = gisEdit.selectedLayer?.layerId ?? "";
|
|
const gisEditSelectedLayerGeometryType =
|
|
gisEdit.selectedLayer?.geometryType ?? null;
|
|
const gisEditSelectedLayerPreviewColors =
|
|
gisEdit.selectedLayer?.previewColors ?? null;
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return;
|
|
|
|
const map = gisMaps.current;
|
|
|
|
if (
|
|
gisEdit.activeTool !== "ADD" ||
|
|
!gisEditSelectedLayerId ||
|
|
!gisEditSelectedLayerGeometryType
|
|
) {
|
|
removeGisEditDrawInteraction();
|
|
return;
|
|
}
|
|
|
|
removeGisEditInteractions();
|
|
removeGisEditDrawInteraction();
|
|
|
|
const drawSource = ensureGisEditPreviewLayer();
|
|
if (!drawSource) return;
|
|
const drawType =
|
|
gisEditSelectedLayerGeometryType === "Point"
|
|
? "Point"
|
|
: gisEditSelectedLayerGeometryType === "Polygon"
|
|
? "Polygon"
|
|
: "LineString";
|
|
|
|
const draw = new Draw({
|
|
source: drawSource,
|
|
type: drawType,
|
|
});
|
|
const pipeSnapSource = shouldUsePipeSnapSource
|
|
? ensureGisEditPipeSnapSource()
|
|
: null;
|
|
|
|
draw.on("drawstart", () => {
|
|
setGisEditPreviewCursor("crosshair");
|
|
});
|
|
|
|
draw.on("drawend", (event: any) => {
|
|
try {
|
|
const pipeSnapSource = shouldUsePipeSnapSource
|
|
? ensureGisEditPipeSnapSource()
|
|
: null;
|
|
if (pipeSnapSource?.getFeatures?.().length) {
|
|
applyGeometryVertexSnaps(
|
|
event.feature.getGeometry(),
|
|
pipeSnapSource,
|
|
map,
|
|
20,
|
|
);
|
|
}
|
|
const geometryObject = new GeoJSON().writeGeometryObject(
|
|
event.feature.getGeometry(),
|
|
{
|
|
featureProjection: GisApp.TO_PROJ,
|
|
dataProjection: FLOW_DIRECTION_SRS,
|
|
},
|
|
) as Record<string, unknown>;
|
|
const changeId = gisEdit.registerAddGeometry(geometryObject);
|
|
if (changeId) {
|
|
event.feature.setId(`gis-edit:${changeId}`);
|
|
event.feature.set("fid", changeId);
|
|
event.feature.set("editType", "ADD");
|
|
event.feature.set("objectId", changeId);
|
|
event.feature.set("layerId", gisEditSelectedLayerId);
|
|
event.feature.set("changeId", changeId);
|
|
event.feature.set(
|
|
"layerPointColor",
|
|
gisEditSelectedLayerPreviewColors?.pointColor ?? "",
|
|
);
|
|
event.feature.set(
|
|
"layerLineColor",
|
|
gisEditSelectedLayerPreviewColors?.lineColor ?? "",
|
|
);
|
|
event.feature.set(
|
|
"layerFillColor",
|
|
gisEditSelectedLayerPreviewColors?.fillColor ?? "",
|
|
);
|
|
event.feature.set("isInteractionTarget", false);
|
|
event.feature.set("isDragging", false);
|
|
event.feature.set("previewLabel", "신규 추가");
|
|
event.feature.setStyle(createGisEditPreviewStyle(event.feature));
|
|
gisMaps.current?.renderSync?.();
|
|
window.setTimeout(() => {
|
|
const source = ensureGisEditPreviewLayer();
|
|
if (!source) return;
|
|
if (!source.getFeatureById?.(`gis-edit:${changeId}`)) {
|
|
source.addFeature(event.feature);
|
|
}
|
|
event.feature.changed?.();
|
|
source.changed?.();
|
|
gisEditPreviewLayerRef.current?.changed?.();
|
|
gisMaps.current?.renderSync?.();
|
|
}, 0);
|
|
} else {
|
|
window.setTimeout(() => {
|
|
drawSource.removeFeature(event.feature);
|
|
}, 0);
|
|
}
|
|
if (changeId && gisEditSelectedLayerId === "tb_ms_swage_ppl") {
|
|
openGisEditPipeDetail(changeId);
|
|
setFeatureData({
|
|
lyrTypeNm: "tb_ms_swage_ppl",
|
|
fcltMngNo: "",
|
|
mngInstCd: "",
|
|
fclt_mng_no: "",
|
|
inst_mng_no: "",
|
|
});
|
|
window.setTimeout(() => {
|
|
setGisEditDetailChangeId(changeId);
|
|
setIsOpen(true);
|
|
}, 0);
|
|
setIsSearchModalOpen(false);
|
|
window.setTimeout(() => {
|
|
gisEdit.deactivateTool();
|
|
removeGisEditDrawInteraction();
|
|
}, 0);
|
|
gisEdit.announceStatus(
|
|
"하수관로 신규 객체를 지도에 추가했습니다. 우측 상세정보를 입력한 뒤 저장하세요.",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("GIS 편집 ADD geometry 변환 실패:", error);
|
|
gisEdit.announceStatus(
|
|
"신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요.",
|
|
);
|
|
} finally {
|
|
setGisEditPreviewCursor("crosshair");
|
|
}
|
|
});
|
|
|
|
map.addInteraction(draw);
|
|
gisEditDrawRef.current = draw;
|
|
if (pipeSnapSource) {
|
|
const drawSnap = new Snap({
|
|
source: pipeSnapSource,
|
|
edge: true,
|
|
vertex: true,
|
|
pixelTolerance: 14,
|
|
});
|
|
map.addInteraction(drawSnap);
|
|
gisEditDrawSnapRef.current = drawSnap;
|
|
}
|
|
|
|
return () => {
|
|
removeGisEditDrawInteraction();
|
|
};
|
|
}, [
|
|
ensureGisEditPreviewLayer,
|
|
ensureGisEditPipeSnapSource,
|
|
gisEdit.activeTool,
|
|
gisEdit.announceStatus,
|
|
gisEdit.deactivateTool,
|
|
gisEdit.isEditMode,
|
|
gisEdit.isSnapEnabled,
|
|
gisEdit.registerAddGeometry,
|
|
gisEditSelectedLayerGeometryType,
|
|
gisEditSelectedLayerId,
|
|
gisEditSelectedLayerPreviewColors,
|
|
mapReady,
|
|
openGisEditPipeDetail,
|
|
removeGisEditDrawInteraction,
|
|
removeGisEditInteractions,
|
|
setGisEditPreviewCursor,
|
|
shouldUsePipeSnapSource,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !flowDirectionLayerInfo) return;
|
|
|
|
// 트리 체크 상태나 관종 레이어 선택이 바뀌면 유하방향 API vector layer를 추가/갱신/제거한다.
|
|
if (isFlowDirectionChecked) {
|
|
const requestUsageCodes =
|
|
flowDirectionSelectionRequestUsageCodesRef.current;
|
|
flowDirectionSelectionRequestUsageCodesRef.current = null;
|
|
if (requestUsageCodes && requestUsageCodes.length === 0) return;
|
|
loadFlowDirectionLayer(
|
|
flowDirectionLayerInfo,
|
|
requestUsageCodes ?? undefined,
|
|
);
|
|
} else {
|
|
removeFlowDirectionLayer();
|
|
}
|
|
}, [
|
|
flowDirectionLayerInfo,
|
|
flowDirectionSelectionVersion,
|
|
isFlowDirectionChecked,
|
|
loadFlowDirectionLayer,
|
|
mapReady,
|
|
removeFlowDirectionLayer,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
|
|
// 유하방향이 켜진 상태에서 지도 이동/확대축소가 끝나면 현재 BBOX 기준으로 다시 조회한다.
|
|
const key = gisMaps.current.on("moveend", () => {
|
|
if (
|
|
!flowDirectionCheckedRef.current ||
|
|
!flowDirectionLayerInfoRef.current
|
|
)
|
|
return;
|
|
loadFlowDirectionLayer(flowDirectionLayerInfoRef.current);
|
|
});
|
|
|
|
return () => {
|
|
unByKey(key);
|
|
};
|
|
}, [loadFlowDirectionLayer, mapReady]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
|
|
const source = ensureGisEditPreviewLayer();
|
|
if (!source) return;
|
|
|
|
if (gisEdit.changes.length === 0) {
|
|
clearGisEditPreviewLayer();
|
|
return;
|
|
}
|
|
|
|
const geoJson = new GeoJSON();
|
|
const layerPreviewColorMap = new globalThis.Map<string, any>(
|
|
gisEdit.editableLayers.map(
|
|
(layer) => [layer.layerId, layer.previewColors] as const,
|
|
),
|
|
);
|
|
const existingFeatureMap = new globalThis.Map<string, any>(
|
|
source
|
|
.getFeatures()
|
|
.map(
|
|
(feature: any) =>
|
|
[String(feature.get("changeId") ?? ""), feature] as const,
|
|
),
|
|
);
|
|
const nextChangeIdSet = new Set<string>();
|
|
|
|
gisEdit.changes.forEach((change) => {
|
|
if (change.attributeOnly) return;
|
|
|
|
const geometry =
|
|
change.editType === "DELETE"
|
|
? change.beforeGeometry
|
|
: change.afterGeometry;
|
|
if (!geometry) return;
|
|
|
|
const previewEditType =
|
|
gisEdit.pendingInteraction?.changeId === change.changeId
|
|
? gisEdit.pendingInteraction.editType
|
|
: change.editType;
|
|
const previewLabel =
|
|
gisEdit.focusedChangeId === change.changeId &&
|
|
previewEditType === "MOVE"
|
|
? "드래그 이동"
|
|
: gisEdit.focusedChangeId === change.changeId &&
|
|
previewEditType === "VERTEX_EDIT"
|
|
? "꼭지점 편집"
|
|
: previewEditType === "DELETE"
|
|
? "삭제 예정"
|
|
: previewEditType === "MOVE"
|
|
? "이동 예정"
|
|
: previewEditType === "VERTEX_EDIT"
|
|
? "편집 예정"
|
|
: "신규 추가";
|
|
|
|
let feature = existingFeatureMap.get(change.changeId);
|
|
const previewColors = layerPreviewColorMap.get(change.layerId);
|
|
const originalProperties = change.featureProperties ?? {};
|
|
const previewObjectId = change.objectId ?? change.objectLabel;
|
|
const previewFid =
|
|
originalProperties["fid"] ??
|
|
change.fid ??
|
|
change.featureRef?.fid ??
|
|
previewObjectId;
|
|
if (!feature) {
|
|
feature = geoJson.readFeature(
|
|
{
|
|
type: "Feature",
|
|
geometry,
|
|
properties: {
|
|
...originalProperties,
|
|
fid: previewFid,
|
|
editType: previewEditType,
|
|
objectId: previewObjectId,
|
|
layerId: change.layerId,
|
|
changeId: change.changeId,
|
|
},
|
|
},
|
|
{
|
|
dataProjection: FLOW_DIRECTION_SRS,
|
|
featureProjection: GisApp.TO_PROJ,
|
|
},
|
|
);
|
|
feature.setId(`gis-edit:${change.changeId}`);
|
|
source.addFeature(feature);
|
|
} else {
|
|
feature.setGeometry(
|
|
geoJson.readGeometry(geometry, {
|
|
dataProjection: FLOW_DIRECTION_SRS,
|
|
featureProjection: GisApp.TO_PROJ,
|
|
}),
|
|
);
|
|
}
|
|
|
|
Object.entries(originalProperties).forEach(([key, value]) => {
|
|
if (key === "geometry") return;
|
|
feature.set(key, value);
|
|
});
|
|
feature.set("fid", previewFid);
|
|
feature.set("editType", previewEditType);
|
|
feature.set("objectId", previewObjectId);
|
|
feature.set("layerId", change.layerId);
|
|
feature.set("changeId", change.changeId);
|
|
feature.set("layerPointColor", previewColors?.pointColor ?? "");
|
|
feature.set("layerLineColor", previewColors?.lineColor ?? "");
|
|
feature.set("layerFillColor", previewColors?.fillColor ?? "");
|
|
feature.set(
|
|
"isInteractionTarget",
|
|
gisEdit.focusedChangeId === change.changeId,
|
|
);
|
|
feature.set("isDragging", Boolean(feature.get("isDragging")));
|
|
feature.set("previewLabel", previewLabel);
|
|
feature.setStyle(createGisEditPreviewStyle(feature));
|
|
nextChangeIdSet.add(change.changeId);
|
|
});
|
|
|
|
existingFeatureMap.forEach((feature: any, changeId: string) => {
|
|
if (!nextChangeIdSet.has(changeId)) {
|
|
source.removeFeature(feature);
|
|
}
|
|
});
|
|
source.changed?.();
|
|
gisEditPreviewLayerRef.current?.changed?.();
|
|
gisMaps.current?.renderSync?.();
|
|
}, [
|
|
clearGisEditPreviewLayer,
|
|
ensureGisEditPreviewLayer,
|
|
gisEdit.changes,
|
|
gisEdit.editableLayers,
|
|
gisEdit.focusedChangeId,
|
|
gisEdit.isEditMode,
|
|
gisEdit.pendingInteraction,
|
|
mapReady,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisEdit.isEditMode || gisEdit.changes.length === 0)
|
|
return;
|
|
ensureGisEditPreviewLayer();
|
|
gisEditPreviewSourceRef.current?.changed?.();
|
|
gisEditPreviewLayerRef.current?.changed?.();
|
|
gisMaps.current?.renderSync?.();
|
|
}, [
|
|
ensureGisEditPreviewLayer,
|
|
gisEdit.changes.length,
|
|
gisEdit.isEditMode,
|
|
mapReady,
|
|
]);
|
|
|
|
const gisEditMaskedPipeFidKey = useMemo(() => {
|
|
const fids = gisEdit.changes
|
|
.filter((change) => change.layerId === "tb_ms_swage_ppl")
|
|
.filter((change) => !change.attributeOnly)
|
|
.filter(
|
|
(change) => change.editType !== "ADD" && change.editType !== "DELETE",
|
|
)
|
|
.map((change) =>
|
|
normalizeGisEditFid(
|
|
change.fid ?? change.featureRef?.fid ?? change.objectId,
|
|
),
|
|
)
|
|
.filter((fid) => /^\d+$/.test(fid));
|
|
return Array.from(new Set(fids)).sort().join(",");
|
|
}, [gisEdit.changes]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisEdit.isEditMode) {
|
|
restoreGisEditOriginalPipeMask();
|
|
return;
|
|
}
|
|
|
|
applyGisEditOriginalPipeMask(
|
|
gisEditMaskedPipeFidKey ? gisEditMaskedPipeFidKey.split(",") : [],
|
|
);
|
|
}, [
|
|
applyGisEditOriginalPipeMask,
|
|
gisEdit.isEditMode,
|
|
gisEditMaskedPipeFidKey,
|
|
mapReady,
|
|
restoreGisEditOriginalPipeMask,
|
|
]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
restoreGisEditOriginalPipeMask();
|
|
},
|
|
[restoreGisEditOriginalPipeMask],
|
|
);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
clearGisEditPipeSnapSource();
|
|
},
|
|
[clearGisEditPipeSnapSource],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
if (!shouldUsePipeSnapSource) {
|
|
clearGisEditPipeSnapSource();
|
|
return;
|
|
}
|
|
|
|
const excludedFid =
|
|
gisEdit.pendingInteraction?.editType === "VERTEX_EDIT" ||
|
|
gisEdit.pendingInteraction?.editType === "MOVE"
|
|
? normalizeGisEditFid(
|
|
gisEdit.changes.find(
|
|
(change) =>
|
|
change.changeId === gisEdit.pendingInteraction?.changeId,
|
|
)?.fid ?? "",
|
|
)
|
|
: "";
|
|
|
|
void loadGisEditPipeSnapSource({
|
|
excludedFid,
|
|
excludedChangeId: gisEdit.pendingInteraction?.changeId ?? null,
|
|
});
|
|
}, [
|
|
clearGisEditPipeSnapSource,
|
|
gisEdit.changes,
|
|
gisEdit.pendingInteraction,
|
|
loadGisEditPipeSnapSource,
|
|
mapReady,
|
|
shouldUsePipeSnapSource,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
if (!shouldUsePipeSnapSource) return;
|
|
|
|
const key = gisMaps.current.on("moveend", () => {
|
|
const excludedFid =
|
|
gisEdit.pendingInteraction?.editType === "VERTEX_EDIT" ||
|
|
gisEdit.pendingInteraction?.editType === "MOVE"
|
|
? normalizeGisEditFid(
|
|
gisEdit.changes.find(
|
|
(change) =>
|
|
change.changeId === gisEdit.pendingInteraction?.changeId,
|
|
)?.fid ?? "",
|
|
)
|
|
: "";
|
|
void loadGisEditPipeSnapSource({
|
|
excludedFid,
|
|
excludedChangeId: gisEdit.pendingInteraction?.changeId ?? null,
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
unByKey(key);
|
|
};
|
|
}, [
|
|
gisEdit.changes,
|
|
gisEdit.pendingInteraction,
|
|
loadGisEditPipeSnapSource,
|
|
mapReady,
|
|
shouldUsePipeSnapSource,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!mapReady ||
|
|
!gisMaps.current ||
|
|
!gisEdit.isEditMode ||
|
|
!gisEdit.pendingInteraction
|
|
)
|
|
return;
|
|
|
|
const source = ensureGisEditPreviewLayer();
|
|
const map = gisMaps.current;
|
|
if (!source || !map) return;
|
|
|
|
const targetFeature = source
|
|
.getFeatures()
|
|
.find(
|
|
(feature: any) =>
|
|
feature.get("changeId") === gisEdit.pendingInteraction?.changeId,
|
|
);
|
|
if (!targetFeature) {
|
|
gisEdit.clearPendingInteraction();
|
|
return;
|
|
}
|
|
|
|
removeGisEditInteractions();
|
|
const features = new Collection([targetFeature]);
|
|
let cleanupPointerMove: (() => void) | undefined;
|
|
const writeGeometryObject = () => {
|
|
const geometry = targetFeature.getGeometry();
|
|
if (!geometry) return null;
|
|
return new GeoJSON().writeGeometryObject(geometry, {
|
|
featureProjection: GisApp.TO_PROJ,
|
|
dataProjection: FLOW_DIRECTION_SRS,
|
|
}) as Record<string, unknown>;
|
|
};
|
|
const usePipeSnapSource =
|
|
shouldUsePipeSnapSource &&
|
|
(gisEdit.pendingInteraction.editType === "MOVE" ||
|
|
gisEdit.pendingInteraction.editType === "VERTEX_EDIT");
|
|
const pipeSnapSource = usePipeSnapSource
|
|
? ensureGisEditPipeSnapSource()
|
|
: null;
|
|
const pipeSnapSourceHasFeatures = Boolean(
|
|
pipeSnapSource?.getFeatures?.().length,
|
|
);
|
|
const snapFeatures =
|
|
(!usePipeSnapSource || !pipeSnapSourceHasFeatures) &&
|
|
gisEdit.isSnapEnabled
|
|
? collectGisEditSnapFeatures(targetFeature)
|
|
: [];
|
|
|
|
if (gisEdit.pendingInteraction.editType === "MOVE") {
|
|
const translate = new Translate({ features, hitTolerance: 10 });
|
|
let lastTranslateCoordinate: number[] | null = null;
|
|
let translateStartCoordinate: number[] | null = null;
|
|
let originalMoveGeometrySnapshot =
|
|
targetFeature.getGeometry?.()?.clone?.() ?? null;
|
|
let moveGripCoordinate: number[] | null = null;
|
|
const moveGripMeta =
|
|
gisEditMoveGripMetaRef.current?.changeId ===
|
|
gisEdit.pendingInteraction.changeId
|
|
? gisEditMoveGripMetaRef.current.meta
|
|
: null;
|
|
translate.on("translatestart", (event: any) => {
|
|
lastTranslateCoordinate = Array.isArray(event?.coordinate)
|
|
? event.coordinate
|
|
: null;
|
|
translateStartCoordinate = Array.isArray(event?.coordinate)
|
|
? event.coordinate
|
|
: null;
|
|
originalMoveGeometrySnapshot =
|
|
targetFeature.getGeometry?.()?.clone?.() ?? null;
|
|
moveGripCoordinate =
|
|
getGeometryVertexCoordinateByMeta(
|
|
originalMoveGeometrySnapshot,
|
|
moveGripMeta,
|
|
) ??
|
|
getClosestGeometryVertexCoordinate(
|
|
originalMoveGeometrySnapshot,
|
|
map,
|
|
translateStartCoordinate,
|
|
);
|
|
targetFeature.set("isDragging", true);
|
|
targetFeature.set("previewLabel", "이동 중");
|
|
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
|
|
setGisEditPreviewCursor("grabbing");
|
|
targetFeature.changed?.();
|
|
source.changed?.();
|
|
map.renderSync?.();
|
|
});
|
|
translate.on("translating", (event: any) => {
|
|
lastTranslateCoordinate = Array.isArray(event?.coordinate)
|
|
? event.coordinate
|
|
: lastTranslateCoordinate;
|
|
if (
|
|
originalMoveGeometrySnapshot?.clone &&
|
|
Array.isArray(lastTranslateCoordinate) &&
|
|
Array.isArray(translateStartCoordinate)
|
|
) {
|
|
const geometry = originalMoveGeometrySnapshot.clone();
|
|
const deltaX =
|
|
Number(lastTranslateCoordinate[0] ?? 0) -
|
|
Number(translateStartCoordinate[0] ?? 0);
|
|
const deltaY =
|
|
Number(lastTranslateCoordinate[1] ?? 0) -
|
|
Number(translateStartCoordinate[1] ?? 0);
|
|
geometry.translate(deltaX, deltaY);
|
|
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
|
|
const movedGripCoordinate = Array.isArray(moveGripCoordinate)
|
|
? [
|
|
Number(moveGripCoordinate[0] ?? 0) + deltaX,
|
|
Number(moveGripCoordinate[1] ?? 0) + deltaY,
|
|
]
|
|
: lastTranslateCoordinate;
|
|
applyGeometryMoveSnap(
|
|
geometry,
|
|
pipeSnapSource,
|
|
map,
|
|
movedGripCoordinate,
|
|
28,
|
|
);
|
|
}
|
|
targetFeature.setGeometry(geometry);
|
|
}
|
|
targetFeature.changed?.();
|
|
source.changed?.();
|
|
map.renderSync?.();
|
|
});
|
|
translate.on("translateend", (event: any) => {
|
|
const objectId = String(targetFeature.get("objectId") ?? "");
|
|
const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어";
|
|
lastTranslateCoordinate = Array.isArray(event?.coordinate)
|
|
? event.coordinate
|
|
: lastTranslateCoordinate;
|
|
targetFeature.set("isDragging", false);
|
|
targetFeature.set("previewLabel", "드래그 이동");
|
|
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
|
|
setGisEditPreviewCursor("grab");
|
|
if (
|
|
originalMoveGeometrySnapshot?.clone &&
|
|
Array.isArray(lastTranslateCoordinate) &&
|
|
Array.isArray(translateStartCoordinate)
|
|
) {
|
|
const geometry = originalMoveGeometrySnapshot.clone();
|
|
const deltaX =
|
|
Number(lastTranslateCoordinate[0] ?? 0) -
|
|
Number(translateStartCoordinate[0] ?? 0);
|
|
const deltaY =
|
|
Number(lastTranslateCoordinate[1] ?? 0) -
|
|
Number(translateStartCoordinate[1] ?? 0);
|
|
geometry.translate(deltaX, deltaY);
|
|
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
|
|
const movedGripCoordinate = Array.isArray(moveGripCoordinate)
|
|
? [
|
|
Number(moveGripCoordinate[0] ?? 0) + deltaX,
|
|
Number(moveGripCoordinate[1] ?? 0) + deltaY,
|
|
]
|
|
: lastTranslateCoordinate;
|
|
applyGeometryMoveSnap(
|
|
geometry,
|
|
pipeSnapSource,
|
|
map,
|
|
movedGripCoordinate,
|
|
28,
|
|
);
|
|
}
|
|
targetFeature.setGeometry(geometry);
|
|
}
|
|
gisEdit.updateChangeGeometry(
|
|
gisEdit.pendingInteraction!.changeId,
|
|
writeGeometryObject(),
|
|
`${layerName} ${objectId} 객체 위치를 지도에서 이동했습니다.`,
|
|
{ keepMoveToolActive: true },
|
|
);
|
|
targetFeature.changed?.();
|
|
source.changed?.();
|
|
map.renderSync?.();
|
|
});
|
|
map.addInteraction(translate);
|
|
gisEditTranslateRef.current = translate;
|
|
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
|
|
const snap = new Snap({
|
|
source: pipeSnapSource,
|
|
edge: true,
|
|
vertex: true,
|
|
pixelTolerance: 14,
|
|
});
|
|
map.addInteraction(snap);
|
|
gisEditSnapRef.current = snap;
|
|
} else if (gisEdit.isSnapEnabled && snapFeatures.length > 0) {
|
|
const snap = new Snap({
|
|
features: new Collection(snapFeatures),
|
|
edge: true,
|
|
vertex: true,
|
|
pixelTolerance: 14,
|
|
});
|
|
map.addInteraction(snap);
|
|
gisEditSnapRef.current = snap;
|
|
}
|
|
setGisEditPreviewCursor("grab");
|
|
map.renderSync?.();
|
|
}
|
|
|
|
if (gisEdit.pendingInteraction.editType === "VERTEX_EDIT") {
|
|
const geometry = targetFeature.getGeometry?.();
|
|
const geometryType = geometry?.getType?.();
|
|
const objectId = String(targetFeature.get("objectId") ?? "");
|
|
const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어";
|
|
|
|
if (
|
|
!geometry ||
|
|
!geometryType ||
|
|
geometryType === "Point" ||
|
|
geometryType === "MultiPoint"
|
|
) {
|
|
gisEdit.announceStatus(
|
|
`${layerName} ${objectId} 객체는 꼭지점편집 대상 geometry가 아닙니다.`,
|
|
);
|
|
gisEdit.clearPendingInteraction();
|
|
return;
|
|
}
|
|
|
|
const vertexSource = ensureGisEditVertexLayer();
|
|
const vertexLayer = gisEditVertexLayerRef.current;
|
|
if (!vertexSource || !vertexLayer) {
|
|
gisEdit.announceStatus("꼭지점 편집 레이어를 준비하지 못했습니다.");
|
|
gisEdit.clearPendingInteraction();
|
|
return;
|
|
}
|
|
|
|
const renderVertexHandles = (
|
|
hoveredMidpoint: VertexHandleCandidate | null = null,
|
|
) => {
|
|
vertexSource.clear();
|
|
buildVertexHandleFeatures(targetFeature.getGeometry?.(), map).forEach(
|
|
(handleFeature: Feature) => {
|
|
vertexSource.addFeature(handleFeature);
|
|
},
|
|
);
|
|
if (hoveredMidpoint) {
|
|
vertexSource.addFeature(
|
|
createVertexHandleFeature(
|
|
hoveredMidpoint.coordinate,
|
|
hoveredMidpoint.meta,
|
|
),
|
|
);
|
|
}
|
|
vertexSource.changed?.();
|
|
applySelectedVertexHandleStyle();
|
|
};
|
|
|
|
let geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject());
|
|
let activeMidpointKey = "";
|
|
let isDraggingHandle = false;
|
|
let originalGeometrySnapshot =
|
|
targetFeature.getGeometry?.()?.clone?.() ?? null;
|
|
renderVertexHandles();
|
|
targetFeature.set("previewLabel", "꼭지점 편집");
|
|
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
|
|
setGisEditPreviewCursor("crosshair");
|
|
|
|
const pointerMoveKey = map.on("pointermove", (event: any) => {
|
|
if (isDraggingHandle || event?.dragging) return;
|
|
|
|
const hoveredMidpoint = findHoveredMidpointCandidate(
|
|
map,
|
|
targetFeature.getGeometry?.(),
|
|
event.coordinate,
|
|
);
|
|
const nextMidpointKey = hoveredMidpoint
|
|
? `${hoveredMidpoint.meta.geometryType}:${hoveredMidpoint.meta.partIndex ?? -1}:${hoveredMidpoint.meta.polygonIndex ?? -1}:${hoveredMidpoint.meta.ringIndex ?? -1}:${hoveredMidpoint.meta.coordIndex}`
|
|
: "";
|
|
|
|
if (nextMidpointKey === activeMidpointKey) return;
|
|
|
|
activeMidpointKey = nextMidpointKey;
|
|
renderVertexHandles(hoveredMidpoint);
|
|
setGisEditPreviewCursor(hoveredMidpoint ? "copy" : "crosshair");
|
|
map.renderSync?.();
|
|
});
|
|
|
|
const vertexTranslate = new Translate({
|
|
layers: [vertexLayer],
|
|
hitTolerance: 14,
|
|
});
|
|
|
|
vertexTranslate.on("translatestart", (event: any) => {
|
|
isDraggingHandle = true;
|
|
geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject());
|
|
originalGeometrySnapshot =
|
|
targetFeature.getGeometry?.()?.clone?.() ?? null;
|
|
clearSelectedVertexHandle();
|
|
const handleFeature = event.features?.item?.(0);
|
|
if (handleFeature) {
|
|
handleFeature.setStyle(GIS_EDIT_VERTEX_DRAGGING_STYLE);
|
|
}
|
|
targetFeature.set("previewLabel", "꼭지점 편집 중");
|
|
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
|
|
setGisEditPreviewCursor("grabbing");
|
|
map.renderSync?.();
|
|
});
|
|
|
|
vertexTranslate.on("translating", (event: any) => {
|
|
const handleFeature = event.features?.item?.(0);
|
|
const handleGeometry = handleFeature?.getGeometry?.();
|
|
const nextCoordinate = handleGeometry?.getCoordinates?.();
|
|
const handleMeta = handleFeature?.get?.("handleMeta") as
|
|
| VertexHandleMeta
|
|
| undefined;
|
|
const handleMetaList =
|
|
(handleFeature?.get?.("handleMetaList") as
|
|
| VertexHandleMeta[]
|
|
| undefined) ?? (handleMeta ? [handleMeta] : []);
|
|
|
|
if (
|
|
handleMetaList.length === 0 ||
|
|
!Array.isArray(nextCoordinate) ||
|
|
!originalGeometrySnapshot?.clone
|
|
) {
|
|
return;
|
|
}
|
|
|
|
targetFeature.setGeometry(originalGeometrySnapshot.clone());
|
|
handleMetaList.forEach((meta) => {
|
|
applyVertexHandleMove(
|
|
targetFeature.getGeometry?.(),
|
|
meta,
|
|
nextCoordinate,
|
|
);
|
|
});
|
|
targetFeature.changed?.();
|
|
source.changed?.();
|
|
map.renderSync?.();
|
|
});
|
|
|
|
vertexTranslate.on("translateend", (event: any) => {
|
|
isDraggingHandle = false;
|
|
const handleFeature = event.features?.item?.(0);
|
|
const handleGeometry = handleFeature?.getGeometry?.();
|
|
const nextCoordinate = handleGeometry?.getCoordinates?.();
|
|
const handleMeta = handleFeature?.get?.("handleMeta") as
|
|
| VertexHandleMeta
|
|
| undefined;
|
|
const handleMetaList =
|
|
(handleFeature?.get?.("handleMetaList") as
|
|
| VertexHandleMeta[]
|
|
| undefined) ?? (handleMeta ? [handleMeta] : []);
|
|
|
|
targetFeature.set("previewLabel", "꼭지점 편집");
|
|
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
|
|
setGisEditPreviewCursor("crosshair");
|
|
activeMidpointKey = "";
|
|
|
|
if (handleMetaList.length === 0 || !Array.isArray(nextCoordinate)) {
|
|
renderVertexHandles();
|
|
map.renderSync?.();
|
|
return;
|
|
}
|
|
|
|
handleMetaList.forEach((meta) => {
|
|
applyVertexHandleMove(
|
|
targetFeature.getGeometry?.(),
|
|
meta,
|
|
nextCoordinate,
|
|
);
|
|
});
|
|
const nextGeometry = writeGeometryObject();
|
|
const geometryChanged =
|
|
geometryBeforeModifyKey !== geometryObjectToKey(nextGeometry);
|
|
renderVertexHandles();
|
|
if (!geometryChanged) {
|
|
gisEdit.announceStatus(
|
|
`${layerName} ${objectId} 객체는 아직 형상이 바뀌지 않았습니다. 주황색 꼭지점 또는 가상 꼭지점을 직접 드래그해 주세요.`,
|
|
);
|
|
map.renderSync?.();
|
|
return;
|
|
}
|
|
|
|
gisEdit.updateChangeGeometry(
|
|
gisEdit.pendingInteraction!.changeId,
|
|
nextGeometry,
|
|
`${layerName} ${objectId} 객체 꼭지점을 지도에서 조정했습니다.`,
|
|
{ keepVertexEditActive: true },
|
|
);
|
|
targetFeature.changed?.();
|
|
source.changed?.();
|
|
map.renderSync?.();
|
|
});
|
|
|
|
map.addInteraction(vertexTranslate);
|
|
gisEditModifyRef.current = vertexTranslate;
|
|
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
|
|
const snap = new Snap({
|
|
source: pipeSnapSource,
|
|
edge: true,
|
|
vertex: true,
|
|
pixelTolerance: 14,
|
|
});
|
|
map.addInteraction(snap);
|
|
gisEditSnapRef.current = snap;
|
|
} else if (gisEdit.isSnapEnabled && snapFeatures.length > 0) {
|
|
const snap = new Snap({
|
|
features: new Collection(snapFeatures),
|
|
edge: true,
|
|
vertex: true,
|
|
pixelTolerance: 14,
|
|
});
|
|
map.addInteraction(snap);
|
|
gisEditSnapRef.current = snap;
|
|
}
|
|
gisMaps.current?.renderSync?.();
|
|
cleanupPointerMove = () => {
|
|
unByKey(pointerMoveKey);
|
|
};
|
|
}
|
|
|
|
return cleanupPointerMove;
|
|
}, [
|
|
applySelectedVertexHandleStyle,
|
|
clearGisEditVertexLayer,
|
|
clearSelectedVertexHandle,
|
|
collectGisEditSnapFeatures,
|
|
ensureGisEditPipeSnapSource,
|
|
ensureGisEditVertexLayer,
|
|
ensureGisEditPreviewLayer,
|
|
gisEdit.isSnapEnabled,
|
|
gisEdit.pendingInteraction,
|
|
gisEdit.selectedLayer,
|
|
gisEdit.updateChangeGeometry,
|
|
gisEdit.announceStatus,
|
|
gisEdit.clearPendingInteraction,
|
|
gisEdit.isEditMode,
|
|
mapReady,
|
|
removeGisEditInteractions,
|
|
shouldUsePipeSnapSource,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!mapReady ||
|
|
!gisMaps.current ||
|
|
!gisEdit.isEditMode ||
|
|
gisEdit.pendingInteraction?.editType !== "VERTEX_EDIT"
|
|
) {
|
|
clearSelectedVertexHandle();
|
|
return;
|
|
}
|
|
|
|
const pendingInteraction = gisEdit.pendingInteraction;
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== "Delete") return;
|
|
if (
|
|
isTypingElement(event.target) ||
|
|
isTypingElement(document.activeElement)
|
|
)
|
|
return;
|
|
|
|
const source = ensureGisEditPreviewLayer();
|
|
const map = gisMaps.current;
|
|
if (!source || !map) return;
|
|
|
|
const targetFeature = source
|
|
.getFeatures()
|
|
.find(
|
|
(feature: any) =>
|
|
feature.get("changeId") === gisEdit.pendingInteraction?.changeId,
|
|
);
|
|
if (!targetFeature) return;
|
|
|
|
const selectedMetaList = gisEditSelectedVertexMetaListRef.current ?? [];
|
|
if (selectedMetaList.length === 0) {
|
|
gisEdit.announceStatus(
|
|
"Delete로 삭제할 주황색 실꼭지점을 먼저 클릭하세요.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const deleteResult = deleteSelectedVertexHandles(
|
|
targetFeature.getGeometry?.(),
|
|
selectedMetaList,
|
|
);
|
|
if (!deleteResult.ok) {
|
|
gisEdit.announceStatus(
|
|
deleteResult.message ?? "꼭지점을 삭제하지 못했습니다.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
clearSelectedVertexHandle();
|
|
|
|
const objectId = String(targetFeature.get("objectId") ?? "");
|
|
const layerName = gisEdit.selectedLayer?.layerName ?? "선택 레이어";
|
|
const geometry = targetFeature.getGeometry();
|
|
if (!geometry) {
|
|
gisEdit.announceStatus(
|
|
"꼭지점 삭제 후 geometry를 확인하지 못했습니다.",
|
|
);
|
|
return;
|
|
}
|
|
targetFeature.set("previewLabel", "꼭지점 편집");
|
|
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
|
|
gisEdit.updateChangeGeometry(
|
|
pendingInteraction.changeId,
|
|
new GeoJSON().writeGeometryObject(geometry, {
|
|
featureProjection: GisApp.TO_PROJ,
|
|
dataProjection: FLOW_DIRECTION_SRS,
|
|
}) as Record<string, unknown>,
|
|
`${layerName} ${objectId} 객체 꼭지점을 삭제했습니다.`,
|
|
{ keepVertexEditActive: true },
|
|
);
|
|
targetFeature.changed?.();
|
|
source.changed?.();
|
|
map.renderSync?.();
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [
|
|
clearSelectedVertexHandle,
|
|
ensureGisEditPreviewLayer,
|
|
gisEdit.isEditMode,
|
|
gisEdit.pendingInteraction,
|
|
gisEdit.selectedLayer,
|
|
gisEdit.updateChangeGeometry,
|
|
gisEdit.announceStatus,
|
|
mapReady,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!gisEdit.isEditMode ||
|
|
(gisEdit.activeTool !== "MOVE" && gisEdit.activeTool !== "VERTEX_EDIT")
|
|
) {
|
|
removeGisEditInteractions();
|
|
}
|
|
}, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditInteractions]);
|
|
|
|
useEffect(() => {
|
|
const select = GisApp.Module.select as any;
|
|
if (!select?.setActive) return;
|
|
|
|
if (gisEdit.isEditMode && gisEdit.activeTool) {
|
|
select.resetSelectedFeature?.();
|
|
select.setActive(false);
|
|
return;
|
|
}
|
|
|
|
select.setActive(true);
|
|
}, [gisEdit.activeTool, gisEdit.isEditMode, mapReady]);
|
|
|
|
useEffect(() => {
|
|
if (!gisEdit.isEditMode || gisEdit.activeTool !== "ADD") {
|
|
removeGisEditDrawInteraction();
|
|
}
|
|
}, [gisEdit.activeTool, gisEdit.isEditMode, removeGisEditDrawInteraction]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !isExmnPplChecked) {
|
|
removeExmnResultLayer("PPL");
|
|
return;
|
|
}
|
|
void loadExmnResultLayer("PPL", exmnPplFilters);
|
|
}, [
|
|
exmnPplFilters,
|
|
isExmnPplChecked,
|
|
loadExmnResultLayer,
|
|
mapReady,
|
|
removeExmnResultLayer,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !isExmnMnhlChecked) {
|
|
removeExmnResultLayer("MNHL");
|
|
return;
|
|
}
|
|
void loadExmnResultLayer("MNHL", exmnMnhlFilters);
|
|
}, [
|
|
exmnMnhlFilters,
|
|
isExmnMnhlChecked,
|
|
loadExmnResultLayer,
|
|
mapReady,
|
|
removeExmnResultLayer,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !isSinkholeChecked) {
|
|
removeSinkholeLayer();
|
|
return;
|
|
}
|
|
|
|
void loadSinkholeLayer(appliedSinkholeFilters);
|
|
}, [
|
|
appliedSinkholeFilters,
|
|
isSinkholeChecked,
|
|
loadSinkholeLayer,
|
|
mapReady,
|
|
removeSinkholeLayer,
|
|
sinkholeSelectionVersion,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const popup = sinkholePopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
sinkholePopupWindowRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const effectiveFilters = normalizeSinkholeFilters(
|
|
appliedSinkholeFilters,
|
|
sinkholeScope,
|
|
);
|
|
popup.postMessage(
|
|
{
|
|
type: SINKHOLE_FILTER_SYNC_MESSAGE,
|
|
filters: effectiveFilters,
|
|
},
|
|
window.location.origin,
|
|
);
|
|
}, [appliedSinkholeFilters, sinkholeScope, sinkholeSelectionVersion]);
|
|
|
|
useEffect(() => {
|
|
renderSelectedSinkhole(selectedSinkholeDetail, sinkholeRadiusMeters);
|
|
}, [renderSelectedSinkhole, selectedSinkholeDetail, sinkholeRadiusMeters]);
|
|
|
|
useEffect(() => {
|
|
renderSelectedFldngHstry(selectedFldngHstryDetail);
|
|
}, [renderSelectedFldngHstry, selectedFldngHstryDetail]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
|
|
const map = gisMaps.current;
|
|
const clickKey = map.on("singleclick", (event: any) => {
|
|
if (!selectedFldngHstryDetail || !isFldngHstryChecked) return;
|
|
|
|
let clickedFldngHstry = false;
|
|
map.forEachFeatureAtPixel(
|
|
event.pixel,
|
|
(feature: any, layer: any) => {
|
|
if (
|
|
layer === fldngHstryAreaLayerRef.current ||
|
|
layer === fldngHstryDepthLayerRef.current
|
|
) {
|
|
clickedFldngHstry = true;
|
|
return feature;
|
|
}
|
|
return undefined;
|
|
},
|
|
{ hitTolerance: 4 },
|
|
);
|
|
|
|
if (!clickedFldngHstry) return;
|
|
|
|
const size = map.getSize?.() ?? [1200, 800];
|
|
const popupWidth = 220;
|
|
const popupHeight = 210;
|
|
const nextLeft = Math.min(
|
|
Math.max(event.pixel[0] + 12, 12),
|
|
size[0] - popupWidth - 12,
|
|
);
|
|
const nextTop = Math.min(
|
|
Math.max(event.pixel[1] - 12, 12),
|
|
size[1] - popupHeight - 12,
|
|
);
|
|
setFldngHstryDetailPopupPosition({
|
|
left: nextLeft,
|
|
top: nextTop,
|
|
});
|
|
setIsSearchModalOpen(false);
|
|
});
|
|
|
|
return () => {
|
|
unByKey(clickKey);
|
|
};
|
|
}, [
|
|
isFldngHstryChecked,
|
|
mapReady,
|
|
selectedFldngHstryDetail,
|
|
setIsSearchModalOpen,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearFldngHstryLayers();
|
|
};
|
|
}, [clearFldngHstryLayers]);
|
|
|
|
return (
|
|
<>
|
|
<div className="sn-map-control-layout relative h-[calc(100dvh-5rem)] overflow-hidden bg-container">
|
|
{layerLoadError ? (
|
|
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-background px-4 py-3 text-sm text-red-700 shadow">
|
|
레이어 설정 조회 실패: {layerLoadError}
|
|
</div>
|
|
) : null}
|
|
{agedPipeDownloadProgressText ? (
|
|
<div className="absolute left-4 top-20 z-[60] max-w-[520px] rounded border border-emerald-200 bg-background px-4 py-3 text-sm text-emerald-700 shadow">
|
|
{agedPipeDownloadProgressText}
|
|
</div>
|
|
) : null}
|
|
{selectedWaterRiskResultRow && waterRiskInfoCardStyle ? (
|
|
// 위험/정상 배지는 통수위험 상태를 즉시 구분하는 업무 의미색이므로 고정색을 유지한다.
|
|
<div
|
|
style={waterRiskInfoCardStyle}
|
|
className="absolute z-[61] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
|
|
>
|
|
<div className="flex items-center justify-between bg-secondary px-[9px] py-[7px]">
|
|
<div>
|
|
<div className="text-[10px] font-semibold text-muted-foreground">
|
|
중점관리지역
|
|
</div>
|
|
<div className="mt-[2px] text-[13px] font-bold text-foreground">
|
|
{selectedWaterRiskResultRow.areaName}
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={`inline-flex min-w-[48px] items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
|
|
selectedWaterRiskDisplayStatus === "DANGER"
|
|
? "bg-[#ff1c15] text-white"
|
|
: "bg-[#e8f6f4] text-[#0f766e]"
|
|
}`}
|
|
>
|
|
{buildWaterRiskStatusLabel(
|
|
selectedWaterRiskDisplayStatus,
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
|
|
<div>
|
|
GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면{" "}
|
|
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
|
|
<span className="font-bold text-[#1677ff]">정상</span>
|
|
</div>
|
|
<div className="mt-[2px]">
|
|
맨홀 침수유량은 지역 위험 판정에서 제외
|
|
</div>
|
|
</div>
|
|
<table className="w-full table-fixed border-collapse border-t border-border">
|
|
<colgroup>
|
|
<col className="w-[68%]" />
|
|
<col className="w-[32%]" />
|
|
</colgroup>
|
|
<tbody>
|
|
{[
|
|
[
|
|
"현재 누적강우량(mm)",
|
|
selectedWaterRiskMapView?.currentAccumRainQty,
|
|
1,
|
|
],
|
|
[
|
|
"예측강우량(mm)",
|
|
selectedWaterRiskMapView?.forecastRainQty,
|
|
1,
|
|
],
|
|
[
|
|
"최대 침수유량(m³/s)",
|
|
selectedWaterRiskMapView?.maxFloodingFlow,
|
|
2,
|
|
],
|
|
].map(([label, value, digits], index) => (
|
|
<tr
|
|
key={String(label)}
|
|
className={index < 2 ? "border-b border-border" : ""}
|
|
>
|
|
<td className="border-r border-border bg-secondary px-[9px] py-[8px] text-[10px] font-semibold text-label">
|
|
{label}
|
|
</td>
|
|
<td className="px-[9px] py-[7px] text-right text-[12px] font-bold text-foreground">
|
|
{typeof value === "number"
|
|
? value.toFixed(Number(digits))
|
|
: "-"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : null}
|
|
{selectedWaterRiskMapView ? (
|
|
// Capacity와 Flooding 색상은 SWMM 결과의 업무 의미색이므로 테마와 무관하게 유지한다.
|
|
<div className="absolute bottom-4 left-[270px] z-[61] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
|
|
<div className="mb-1 font-bold">Capacity</div>
|
|
<div className="space-y-1 font-semibold">
|
|
{[
|
|
["#0000ff", "≤ 0.25"],
|
|
["#00d9ff", "≤ 0.50"],
|
|
["#00d95f", "≤ 0.75"],
|
|
["#ffe100", "< 1.00"],
|
|
["#ff1c15", "≥ 1.00"],
|
|
].map(([color, label]) => (
|
|
<div key={label} className="flex items-center gap-2">
|
|
<span
|
|
className="inline-block h-[10px] w-[10px] border border-foreground"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
<span>{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-2 border-t border-border pt-2">
|
|
<div className="font-bold">Flooding(m³/s)</div>
|
|
<div className="mt-1 flex gap-2">
|
|
<span className="text-[#1677ff]">● 0</span>
|
|
<span className="text-[#ff1c15]">● > 0</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{selectedFldngHstryDetail && fldngHstryDetailPopupPosition ? (
|
|
<FldngHstryDetailPanel
|
|
detail={selectedFldngHstryDetail}
|
|
position={fldngHstryDetailPopupPosition}
|
|
onClose={closeFldngHstryDetail}
|
|
/>
|
|
) : null}
|
|
<AnalysisPopupMenu
|
|
initialActiveKey={initialAnalysisPopupKey}
|
|
onOpenPopup={openAnalysisPopup}
|
|
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
|
|
onToggleGisEdit={handleToggleGisEdit}
|
|
isGisEditActive={gisEdit.isEditMode}
|
|
isInfoSearchActive={isSearchModalOpen}
|
|
/>
|
|
{isGisEditSessionInlineVisible ? (
|
|
<div
|
|
data-testid="gis-edit-session-inline"
|
|
className="absolute left-[258px] top-[16px] z-[61] w-[265px]"
|
|
>
|
|
<GisEditSessionPanel
|
|
{...gisEditSessionSnapshot}
|
|
className="max-h-[calc(50dvh-82px)]"
|
|
variant="inline"
|
|
onOpenPopup={handleOpenGisEditSessionPopup}
|
|
onRemoveFocusedChange={gisEdit.removeFocusedChange}
|
|
onRemoveLastChange={gisEdit.removeLastChange}
|
|
onClearChanges={gisEdit.clearChanges}
|
|
onFocusChange={handleFocusGisEditSessionChange}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<GisEditToolbar
|
|
isEditMode={gisEdit.isEditMode}
|
|
activeTool={gisEdit.activeTool}
|
|
isSnapEnabled={gisEdit.isSnapEnabled}
|
|
canUseTools={gisEdit.canUseTools}
|
|
canSave={gisEdit.canSave}
|
|
isSaving={isGisEditSaving}
|
|
statusMessage={gisEdit.statusMessage}
|
|
allowedTools={gisEdit.selectedLayer?.allowedTools ?? []}
|
|
onClose={handleCloseGisEdit}
|
|
onToggleSnap={gisEdit.toggleSnapEnabled}
|
|
onSelectTool={handleSelectGisEditTool}
|
|
onSave={handleSaveGisEdit}
|
|
/>
|
|
<Gis
|
|
treeData={treeData}
|
|
isSearch={true}
|
|
gisMaps={gisMaps}
|
|
isMiniMap={false}
|
|
searchView={SearchModal}
|
|
detailView={GisEditAwareDetailView}
|
|
canSelectFeature={canSelectFeatureDuringGisEdit}
|
|
onFeatureSelect={onFeatureSelect}
|
|
onMapBlankClick={handleSinkholeMapBlankClick}
|
|
sinkholeLegendIconVisible={true}
|
|
featureData={featureData}
|
|
setFeatureData={setFeatureData}
|
|
isOpen={isOpen}
|
|
setIsOpen={setIsOpen}
|
|
isSearchModalOpen={isSearchModalOpen}
|
|
setIsSearchModalOpen={setIsSearchModalOpen}
|
|
onCheckedLayersChange={handleCheckedLayersChange}
|
|
onOpenPopup={handleOpenLayerPopup}
|
|
searchBar={
|
|
<ProcSearchBar
|
|
gisMaps={gisMaps}
|
|
facilityTypes={facilityTypes}
|
|
sidoSearchZoom={
|
|
isAgedPipeChecked ? AGED_PIPE_GUIDE_MIN_ZOOM : undefined
|
|
}
|
|
facilityLayerFilter={swagePrcsFcltSearchFilter}
|
|
/>
|
|
}
|
|
waterDepth={undefined}
|
|
layerSettingView={ActiveLayerSettingView}
|
|
treeSectionClassName="rounded-sm border border-border bg-background px-1 py-2 text-foreground"
|
|
detachedTreeToggleButton={true}
|
|
showLayerPanelTitle={false}
|
|
isPlaying={isPlaying}
|
|
setIsPlaying={setIsPlaying as any}
|
|
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
|
|
checkInterval={2500}
|
|
checkedLayerChangeDelay={0}
|
|
showZoomLevel={true}
|
|
showZoomScaleInfo={true}
|
|
showFloatingSearchButton={false}
|
|
treeTogglePosition="center"
|
|
zoomGuide={zoomGuide}
|
|
layerIconVisible={(node: any) =>
|
|
isAgedPipeLayerNode(node) ||
|
|
isSinkholeRootNode(node) ||
|
|
isSbmrgDngrMapRootNode(node) ||
|
|
isFloodDngrMapRootNode(node) ||
|
|
isFldngHstryNode(node)
|
|
}
|
|
defaultZoom={7.5}
|
|
defaultLonLat={[129, 36.3]}
|
|
viewExtent={SOUTH_KOREA_VIEW_EXTENT}
|
|
/>
|
|
{isAgedPipeListMinimized && (
|
|
<div className="fixed left-[480px] top-[68px] z-[90] flex min-w-[250px] items-center gap-3 rounded-sm border border-red-200 bg-background px-3 py-2 shadow-[0_8px_22px_rgba(15,23,42,0.22)] backdrop-blur">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-red-50">
|
|
<span className="block h-4 w-4 rounded-sm border-2 border-red-500 bg-red-500/20" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[1.25rem] font-semibold leading-tight text-red-700">
|
|
노후관로 현황
|
|
</div>
|
|
<div className="text-[1rem] leading-tight text-muted-foreground">
|
|
{appliedAgedPipeFilters.elapsedYears}년 이상 ·{" "}
|
|
{agedPipeTotalCount.toLocaleString()}건
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
size="xs"
|
|
className="h-7 px-3"
|
|
onClick={openAgedPipeList}
|
|
>
|
|
열기
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
size="xs"
|
|
className="h-7 px-3"
|
|
onClick={() => setIsAgedPipeListMinimized(false)}
|
|
>
|
|
닫기
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<style jsx global>{`
|
|
.sn-map-control-layout .mapbtn {
|
|
top: auto;
|
|
right: 15px;
|
|
bottom: 439px;
|
|
}
|
|
|
|
.sn-map-control-layout .custom-toolbar {
|
|
position: static;
|
|
}
|
|
|
|
.sn-map-control-layout .tool-bar {
|
|
top: auto;
|
|
right: 15px;
|
|
bottom: 230px;
|
|
}
|
|
|
|
.sn-map-control-layout .zoom-level-control {
|
|
right: 15px;
|
|
bottom: 36px;
|
|
}
|
|
|
|
.sn-map-control-layout .zoom-level-summary {
|
|
right: 56px;
|
|
bottom: -15px;
|
|
}
|
|
|
|
.sn-map-control-layout .ol-scale-line {
|
|
display: none;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Map;
|