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