1302 lines
48 KiB
TypeScript
1302 lines
48 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 Gis from "@common/business_components/ui/map/Gis";
|
|
import { getData, getRawJson } from "../../components/lib/api";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
|
import GeoJSON from "ol/format/GeoJSON.js";
|
|
import Point from "ol/geom/Point.js";
|
|
import VectorLayer from "ol/layer/Vector.js";
|
|
import { unByKey } from "ol/Observable.js";
|
|
import VectorSource from "ol/source/Vector.js";
|
|
import Stroke from "ol/style/Stroke.js";
|
|
import Style from "ol/style/Style.js";
|
|
import SearchModal from "./searchModal/searchModal";
|
|
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
|
|
import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth";
|
|
import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
|
|
import { Button } from "@common/components";
|
|
import useSearchModalStore from "@/store/useSearchModalStore";
|
|
import useStore from "@/store/useStore";
|
|
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
|
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
|
|
import type { PipeAnalysisDetailRow } from "./components/pipeAnalysis/pipeAnalysisUtils";
|
|
import {
|
|
AGED_PIPE_EXCEL_HEADER,
|
|
AGED_PIPE_MIN_ZOOM,
|
|
AGED_PIPE_LAYER_TYPE,
|
|
AGED_PIPE_PAGE_SIZE,
|
|
buildAgedPipeCqlFilters,
|
|
isAgedPipeLayerNode,
|
|
matchesAgedPipeFilters,
|
|
normalizeAgedPipeFilters,
|
|
toDisplayPipeRow,
|
|
validateElapsedYears,
|
|
type AgedPipeFilters,
|
|
type AgedPipeRow,
|
|
AGED_PIPE_DEFAULT_FILTERS,
|
|
} from "./components/agedPipe/agedPipeUtils";
|
|
import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE } from "./components/pipeAnalysis/pipeAnalysisUtils";
|
|
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
|
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
|
|
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
|
|
|
const AnalysisPopupMenu = dynamic(
|
|
() => import("./components/analysisMenu/AnalysisPopupMenu"),
|
|
{ ssr: false },
|
|
);
|
|
|
|
const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE";
|
|
const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
|
|
const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
|
|
const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
|
|
const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
|
|
const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
|
|
720000,
|
|
1445000,
|
|
1410000,
|
|
2090000,
|
|
];
|
|
|
|
// 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다.
|
|
const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction";
|
|
const FLOW_DIRECTION_LAYER_NAME = "유하방향";
|
|
const FLOW_DIRECTION_SRS = "EPSG:5179";
|
|
const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13;
|
|
const FLOW_DIRECTION_DEFAULT_LIMIT = 5000;
|
|
const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)";
|
|
const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35;
|
|
const FLOW_DIRECTION_CACHE_MAX_FEATURES = 10000;
|
|
|
|
type FlowDirectionUsageStyle = {
|
|
color: string;
|
|
lineColor: string;
|
|
};
|
|
|
|
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];
|
|
|
|
// 레이어 설정 응답이 lyrTyNm/lyrTypeNm/lyr_type_nm처럼 혼재되어 내려오는 경우를 단일 기준으로 정규화한다.
|
|
const getLayerTypeName = (layerInfo: any) =>
|
|
String(
|
|
layerInfo?.lyrTyNm ??
|
|
layerInfo?.lyrTypeNm ??
|
|
layerInfo?.lyr_type_nm ??
|
|
"",
|
|
).trim();
|
|
|
|
// 유하방향 노드는 타입명 또는 표시명으로 식별한다. DB DML 반영 전후 응답 차이를 모두 흡수하기 위한 판별 함수이다.
|
|
const isFlowDirectionLayerInfo = (layerInfo: any) => {
|
|
const layerTypeName = getLayerTypeName(layerInfo);
|
|
const layerName = String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim();
|
|
|
|
return layerTypeName === FLOW_DIRECTION_LAYER_TYPE || layerName === FLOW_DIRECTION_LAYER_NAME;
|
|
};
|
|
|
|
// 유하방향 노드를 일반 레이어 토글 대상에서 제외하고, 앱 전용 API vector layer로 처리하도록 속성을 보정한다.
|
|
const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => {
|
|
if (!Array.isArray(data)) return data;
|
|
|
|
return data.map((item) => {
|
|
const children = Array.isArray(item.children)
|
|
? normalizeFlowDirectionLayerInfos(item.children)
|
|
: item.children;
|
|
|
|
if (!isFlowDirectionLayerInfo(item)) {
|
|
return { ...item, children };
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
children,
|
|
lyrTyNm: item.lyrTyNm || item.lyrTypeNm || item.lyr_type_nm || FLOW_DIRECTION_LAYER_TYPE,
|
|
lyrNm: item.lyrNm || FLOW_DIRECTION_LAYER_NAME,
|
|
gobjTy: item.gobjTy || "line",
|
|
srvcNm: "api",
|
|
cmncNm: "bbox",
|
|
actlLyrYn: "N",
|
|
clickPsblYn: "N",
|
|
initEprssYn: "N",
|
|
minViewSize: item.minViewSize ?? item.minZoom ?? String(FLOW_DIRECTION_DEFAULT_MIN_ZOOM),
|
|
};
|
|
});
|
|
};
|
|
|
|
// 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다.
|
|
const getFlowDirectionMinZoom = (layerInfo: any) => {
|
|
const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom);
|
|
return Number.isFinite(minZoom) ? minZoom : FLOW_DIRECTION_DEFAULT_MIN_ZOOM;
|
|
};
|
|
|
|
// 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다.
|
|
const getFlowDirectionZIndex = (layerInfo: any) => {
|
|
const drawOrder = Number(layerInfo?.drawOrdr);
|
|
return Number.isFinite(drawOrder) ? drawOrder : 9998;
|
|
};
|
|
|
|
// BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다.
|
|
const getFeatureCollection = (payload: any) => {
|
|
if (payload?.type === "FeatureCollection") return payload;
|
|
if (payload?.data?.type === "FeatureCollection") return payload.data;
|
|
return { type: "FeatureCollection", features: [] };
|
|
};
|
|
|
|
// 현재 지도 화면 BBOX 주변을 함께 조회해 이동 직후 가장자리 유하방향이 비어 보이는 현상을 줄인다.
|
|
const expandFlowDirectionExtent = (extent: number[]) => {
|
|
const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;
|
|
const width = Math.max(0, maxX - minX);
|
|
const height = Math.max(0, maxY - minY);
|
|
const bufferX = width * FLOW_DIRECTION_BBOX_BUFFER_RATIO;
|
|
const bufferY = height * FLOW_DIRECTION_BBOX_BUFFER_RATIO;
|
|
return [minX - bufferX, minY - bufferY, maxX + bufferX, maxY + bufferY];
|
|
};
|
|
|
|
// API 요청과 디버깅 출력에 같은 BBOX 문자열 포맷을 사용해 FE 계산값과 네트워크 파라미터를 쉽게 대조한다.
|
|
const formatFlowDirectionBbox = (extent: number[]) =>
|
|
extent.map((value: number) => value.toFixed(3)).join(",");
|
|
|
|
// 유하방향 캐시 중복 제거 키이다. BE 응답은 같은 관로 id가 여러 geometry로 반복될 수 있어 geometry까지 포함한다.
|
|
const getFlowDirectionFeatureCacheKey = (feature: any) => {
|
|
const featureId = feature.getId?.();
|
|
const fcltMngNo = feature.get("fcltMngNo") ?? feature.get("fclt_mng_no");
|
|
const usageCode = feature.get("usgCd") ?? feature.get("usg_cd") ?? "";
|
|
const coordinates = feature.getGeometry?.()?.getCoordinates?.();
|
|
const geometryKey = JSON.stringify(coordinates);
|
|
|
|
if (featureId) return `id:${String(featureId)}:${geometryKey}`;
|
|
if (fcltMngNo) return `fclt:${String(fcltMngNo).trim()}:${geometryKey}`;
|
|
return `geom:${String(usageCode).trim()}:${geometryKey}`;
|
|
};
|
|
|
|
// 관종별 원본 레이어 색을 유하방향 선/화살표에 재사용할 때 투명도만 조정한다.
|
|
const withAlpha = (color: string | null | undefined, alpha: number) => {
|
|
const resolvedColor = String(color ?? "").trim();
|
|
if (!resolvedColor) return `rgba(40, 90, 180, ${alpha})`;
|
|
|
|
const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i);
|
|
if (rgbaMatch) {
|
|
const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "")
|
|
.split(",")
|
|
.map((part) => part.trim());
|
|
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
}
|
|
|
|
const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
if (hexMatch) {
|
|
const hex = hexMatch[1] ?? "";
|
|
const normalized = hex.length === 3
|
|
? hex.split("").map((char) => char + char).join("")
|
|
: hex;
|
|
const red = parseInt(normalized.slice(0, 2), 16);
|
|
const green = parseInt(normalized.slice(2, 4), 16);
|
|
const blue = parseInt(normalized.slice(4, 6), 16);
|
|
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
}
|
|
|
|
return resolvedColor;
|
|
};
|
|
|
|
// 화살표 위치와 대표 선분 선택을 위해 LineString 좌표열의 대략적인 길이를 계산한다.
|
|
const estimateLineLength = (coordinates: number[][]) => {
|
|
let length = 0;
|
|
for (let i = 1; i < coordinates.length; i += 1) {
|
|
const previous = coordinates[i - 1];
|
|
const current = coordinates[i];
|
|
if (!previous || !current) continue;
|
|
|
|
const [x1 = 0, y1 = 0] = previous;
|
|
const [x2 = 0, y2 = 0] = current;
|
|
length += Math.hypot(x2 - x1, y2 - y1);
|
|
}
|
|
return length;
|
|
};
|
|
|
|
// MultiLineString 관로는 가장 긴 선분을 대표 선으로 삼아 화살표 위치와 각도를 안정적으로 계산한다.
|
|
const getRepresentativeLineCoordinates = (geometry: any): number[][] => {
|
|
const type = geometry?.getType?.();
|
|
const coordinates = geometry?.getCoordinates?.();
|
|
|
|
if (type === "LineString") return coordinates ?? [];
|
|
if (type === "MultiLineString" && Array.isArray(coordinates)) {
|
|
return coordinates.reduce((longest: number[][], current: number[][]) =>
|
|
estimateLineLength(current) > estimateLineLength(longest) ? current : longest,
|
|
[],
|
|
);
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
// 관로 중앙 지점이 속한 실제 선분을 찾아 화살표 위치와 해당 선분의 방향각을 함께 계산한다.
|
|
const getArrowPlacementAtHalfLength = (coordinates: number[][]) => {
|
|
if (coordinates.length === 0) return null;
|
|
if (coordinates.length === 1) {
|
|
const coordinate = coordinates[0];
|
|
return coordinate ? { coordinate, angle: 0 } : null;
|
|
}
|
|
|
|
const totalLength = estimateLineLength(coordinates);
|
|
if (totalLength === 0) {
|
|
const coordinate = coordinates[Math.floor(coordinates.length / 2)];
|
|
return coordinate ? { coordinate, angle: 0 } : null;
|
|
}
|
|
|
|
let accumulated = 0;
|
|
const targetLength = totalLength / 2;
|
|
let lastSegmentAngle = 0;
|
|
|
|
for (let i = 1; i < coordinates.length; i += 1) {
|
|
const previous = coordinates[i - 1];
|
|
const current = coordinates[i];
|
|
if (!previous || !current) continue;
|
|
|
|
const [previousX = 0, previousY = 0] = previous;
|
|
const [currentX = 0, currentY = 0] = current;
|
|
const segmentLength = Math.hypot(currentX - previousX, currentY - previousY);
|
|
if (segmentLength > 0) {
|
|
lastSegmentAngle = Math.atan2(currentY - previousY, currentX - previousX);
|
|
}
|
|
|
|
if (accumulated + segmentLength >= targetLength) {
|
|
const ratio = segmentLength === 0 ? 0 : (targetLength - accumulated) / segmentLength;
|
|
return {
|
|
coordinate: [
|
|
previousX + (currentX - previousX) * ratio,
|
|
previousY + (currentY - previousY) * ratio,
|
|
],
|
|
angle: lastSegmentAngle,
|
|
};
|
|
}
|
|
|
|
accumulated += segmentLength;
|
|
}
|
|
|
|
const coordinate = coordinates[coordinates.length - 1];
|
|
return coordinate ? { coordinate, angle: lastSegmentAngle } : null;
|
|
};
|
|
|
|
// SVG 없이 Canvas renderer로 짧은 꼬리선과 화살촉을 직접 그려 방향성을 분명하게 만든다.
|
|
const createFlowDirectionArrowRenderer = (color: string, angle: number, scale: number) => {
|
|
return (coordinates: any, state: any) => {
|
|
const pixel = Array.isArray(coordinates?.[0]) ? coordinates[0] : coordinates;
|
|
const [x, y] = pixel ?? [];
|
|
const context = state?.context as CanvasRenderingContext2D | undefined;
|
|
if (!context || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
|
|
|
const length = 17 * scale;
|
|
const tailLength = 9 * scale;
|
|
const headLength = 7 * scale;
|
|
const headHalfWidth = 4.5 * scale;
|
|
const shaftWidth = 2.4 * scale;
|
|
|
|
context.save();
|
|
context.translate(x, y);
|
|
const viewRotation = Number(state?.rotation ?? 0);
|
|
const screenAngle = (Number.isFinite(viewRotation) ? viewRotation : 0) - angle;
|
|
|
|
context.rotate(screenAngle);
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
|
|
context.strokeStyle = "rgba(255, 255, 255, 0.9)";
|
|
context.lineWidth = shaftWidth + 2.4;
|
|
context.beginPath();
|
|
context.moveTo(-tailLength, 0);
|
|
context.lineTo(length - headLength, 0);
|
|
context.stroke();
|
|
|
|
context.fillStyle = "rgba(255, 255, 255, 0.9)";
|
|
context.beginPath();
|
|
context.moveTo(length, 0);
|
|
context.lineTo(length - headLength, -headHalfWidth);
|
|
context.lineTo(length - headLength, headHalfWidth);
|
|
context.closePath();
|
|
context.fill();
|
|
|
|
context.strokeStyle = color;
|
|
context.lineWidth = shaftWidth;
|
|
context.beginPath();
|
|
context.moveTo(-tailLength, 0);
|
|
context.lineTo(length - headLength, 0);
|
|
context.stroke();
|
|
|
|
context.fillStyle = color;
|
|
context.beginPath();
|
|
context.moveTo(length, 0);
|
|
context.lineTo(length - headLength, -headHalfWidth);
|
|
context.lineTo(length - headLength, headHalfWidth);
|
|
context.closePath();
|
|
context.fill();
|
|
|
|
context.restore();
|
|
};
|
|
};
|
|
|
|
// 유하방향 신뢰도와 관종 색상에 따라 선과 화살표 스타일을 만든다. LOW는 운영 검수 대상이라 더 흐리게 표시한다.
|
|
const createFlowDirectionStyle = (
|
|
feature: any,
|
|
usageStyleMap: Record<string, FlowDirectionUsageStyle>,
|
|
) => {
|
|
const confidence = String(
|
|
feature.get("directionConfidence") ??
|
|
feature.get("direction_confidence") ??
|
|
"",
|
|
).toUpperCase();
|
|
const geometry = feature.getGeometry();
|
|
const coordinates = getRepresentativeLineCoordinates(geometry);
|
|
const arrowPlacement = getArrowPlacementAtHalfLength(coordinates);
|
|
const usageCode = String(feature.get("usgCd") ?? feature.get("usg_cd") ?? "").trim();
|
|
const usageStyle = usageStyleMap[usageCode];
|
|
const baseColor = usageStyle?.color ?? FLOW_DIRECTION_DEFAULT_COLOR;
|
|
|
|
const styleByConfidence: Record<string, { color: string; lineColor: string; scale: number; width: number }> = {
|
|
MEDIUM: {
|
|
color: baseColor,
|
|
lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42),
|
|
scale: 1,
|
|
width: 1.3,
|
|
},
|
|
LOW: {
|
|
color: withAlpha(baseColor, 0.72),
|
|
lineColor: withAlpha(baseColor, 0.28),
|
|
scale: 0.9,
|
|
width: 1.1,
|
|
},
|
|
};
|
|
const style = styleByConfidence[confidence] ?? styleByConfidence.LOW!;
|
|
const styles = [
|
|
new Style({
|
|
stroke: new Stroke({
|
|
color: style.lineColor,
|
|
width: style.width,
|
|
lineCap: "round",
|
|
lineJoin: "round",
|
|
}),
|
|
}),
|
|
];
|
|
|
|
if (arrowPlacement) {
|
|
styles.push(
|
|
new Style({
|
|
geometry: new Point(arrowPlacement.coordinate),
|
|
renderer: createFlowDirectionArrowRenderer(style.color, arrowPlacement.angle, style.scale),
|
|
}),
|
|
);
|
|
}
|
|
|
|
return styles;
|
|
};
|
|
|
|
// 레이어 설정의 직접 코드와 filterSql 안의 usg_cd 조건을 모두 관종 코드 후보로 해석한다.
|
|
const extractUsageCodesFromLayerInfo = (layerInfo: any) => {
|
|
const usageCodes = new Set<string>();
|
|
const directCode = layerInfo?.usgCd ?? layerInfo?.usg_cd;
|
|
if (directCode) usageCodes.add(String(directCode).trim());
|
|
|
|
const filterSql = String(layerInfo?.flterSql ?? layerInfo?.filterSql ?? "");
|
|
const equalsMatches = filterSql.matchAll(/usg_cd\s*=\s*'([^']+)'/gi);
|
|
for (const match of equalsMatches) {
|
|
const code = match[1]?.trim();
|
|
if (code) usageCodes.add(code);
|
|
}
|
|
|
|
const inMatches = filterSql.matchAll(/usg_cd\s+in\s*\(([^)]+)\)/gi);
|
|
for (const match of inMatches) {
|
|
const codeList = match[1];
|
|
if (!codeList) continue;
|
|
|
|
codeList
|
|
.split(",")
|
|
.map((item) => item.replace(/'/g, "").trim())
|
|
.filter(Boolean)
|
|
.forEach((code) => usageCodes.add(code));
|
|
}
|
|
|
|
return Array.from(usageCodes);
|
|
};
|
|
|
|
// 현재 줌에서 실제 표출 가능한 관로 레이어인지 확인해 숨겨진 관종의 유하방향 요청을 막는다.
|
|
const isLayerVisibleAtZoom = (layerInfo: any, zoom: number) => {
|
|
const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom);
|
|
return !Number.isFinite(minZoom) || 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 (!isLayerVisibleAtZoom(data, zoom)) return;
|
|
|
|
const usageCodes = extractUsageCodesFromLayerInfo(data);
|
|
if (usageCodes.length === 0) return;
|
|
|
|
const layerColor = String(data.lnColor ?? data.lineColor ?? FLOW_DIRECTION_DEFAULT_COLOR).trim();
|
|
usageCodes.forEach((code) => {
|
|
usageStyleMap[code] = {
|
|
color: layerColor,
|
|
lineColor: withAlpha(layerColor, 0.28),
|
|
};
|
|
});
|
|
});
|
|
|
|
return usageStyleMap;
|
|
};
|
|
|
|
const appendAgedPipeControlNode = (data: any[]) => {
|
|
if (!Array.isArray(data)) return data;
|
|
if (data.some((item) => String(item.lyrSn) === AGED_PIPE_CONTROL_NODE_ID || item.lyrNm === "노후관로")) {
|
|
return data;
|
|
}
|
|
|
|
return [
|
|
...data,
|
|
{
|
|
lyrSn: AGED_PIPE_CONTROL_NODE_ID,
|
|
id: AGED_PIPE_CONTROL_NODE_ID,
|
|
lyrNm: "노후관로",
|
|
upperLyrSn: null,
|
|
upId: null,
|
|
sortOrdr: "4",
|
|
drawOrdr: null,
|
|
flterSql: null,
|
|
gobjTy: "",
|
|
lyrTyNm: "tb_old_pipe",
|
|
useYn: "Y",
|
|
minViewSize: null,
|
|
iconNm: null,
|
|
iconSize: null,
|
|
lblAtrbNm: null,
|
|
fontNm: null,
|
|
fontIntnColor: null,
|
|
fontExtnColor: null,
|
|
fontExtnWdth: null,
|
|
lnWdth: null,
|
|
lnColor: null,
|
|
polgnColor: null,
|
|
initEprssYn: "Y",
|
|
clickPsblYn: "N",
|
|
exprsYn: "Y",
|
|
cmncNm: "all",
|
|
srvcNm: "wfs",
|
|
actlLyrYn: "N",
|
|
clckZoom: null,
|
|
children: null,
|
|
},
|
|
];
|
|
};
|
|
|
|
const Map = () => {
|
|
const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore();
|
|
|
|
const [treeData, setTreeData] = useState<any[]>([]); // 초기값을 객체 또는 null로 설정
|
|
const [layerLoadError, setLayerLoadError] = useState<string | null>(null);
|
|
const gisMaps = useRef<any | null>(null);
|
|
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
|
|
const [featureData, setFeatureData] = useState<any>(null); // 선택된 피쳐 데이터
|
|
const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [mapReady, setMapReady] = useState(false);
|
|
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
|
|
const [draftAgedPipeFilters, setDraftAgedPipeFilters] =
|
|
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
|
const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] =
|
|
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
|
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 { ctpv, sgg } = useStore();
|
|
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
|
|
const agedPipePopupWindowRef = useRef<Window | null>(null);
|
|
const pipeAnalysisPopupWindowRef = useRef<Window | null>(null);
|
|
// 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다.
|
|
const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false);
|
|
// 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다.
|
|
const [flowDirectionLayerInfo, setFlowDirectionLayerInfo] = useState<any | null>(null);
|
|
// 관종 레이어 체크 변경도 유하방향 재조회 조건이므로 별도 버전 state로 effect를 다시 실행한다.
|
|
const [flowDirectionSelectionVersion, setFlowDirectionSelectionVersion] = useState(0);
|
|
// 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[]>([]);
|
|
// 체크 해제나 재조회 중 늦게 도착한 이전 API 응답이 레이어를 다시 그리지 못하도록 요청 순번을 관리한다.
|
|
const flowDirectionRequestSeqRef = useRef(0);
|
|
// 지도 이동이 연속으로 발생하면 이전 bbox 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다.
|
|
const flowDirectionAbortControllerRef = useRef<AbortController | null>(null);
|
|
// 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다.
|
|
const flowDirectionFailureAlertedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => {
|
|
if (gisMaps.current) {
|
|
setMapReady(true);
|
|
window.clearInterval(timer);
|
|
}
|
|
}, 300);
|
|
|
|
const handlePopupMessage = async (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
const data = event.data;
|
|
const core = GisApp.Module.layer as any;
|
|
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE) {
|
|
const layerNm = data.payload?.layerNm;
|
|
const fid = data.payload?.fid;
|
|
if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return;
|
|
|
|
await core.showSearchResultsFromGeoJSON(layerNm, fid);
|
|
return;
|
|
}
|
|
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE) {
|
|
const layerNm = data.payload?.layerNm;
|
|
const fidsRaw = data.payload?.fids;
|
|
if (!layerNm || !fidsRaw || !core?.showMultipleSearchResultsFromGeoJSON) return;
|
|
|
|
let fids: string[] = [];
|
|
try {
|
|
fids = JSON.parse(fidsRaw);
|
|
} catch (error) {
|
|
console.error("다중 시설 메시지 파싱 실패:", error);
|
|
return;
|
|
}
|
|
|
|
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handlePopupMessage);
|
|
return () => {
|
|
window.clearInterval(timer);
|
|
window.removeEventListener("message", handlePopupMessage);
|
|
};
|
|
}, []);
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
|
|
setTreeData(appendAgedPipeControlNode(normalizeFlowDirectionLayerInfos(data)));
|
|
setLayerLoadError(null);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "레이어 설정 조회 중 알 수 없는 오류가 발생했습니다.";
|
|
console.error("레이어 설정 조회 실패:", error);
|
|
setTreeData([]);
|
|
setLayerLoadError(message);
|
|
}
|
|
};
|
|
|
|
// 유하방향은 일반 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 = [];
|
|
}, []);
|
|
|
|
// 유하방향 cache/source만 비워 관종 변경처럼 조건이 달라진 경우 이전 조건의 피처가 남지 않게 한다.
|
|
const clearFlowDirectionFeatureCache = useCallback(() => {
|
|
flowDirectionSourceRef.current?.clear();
|
|
flowDirectionFeatureKeySetRef.current.clear();
|
|
flowDirectionFeatureKeyQueueRef.current = [];
|
|
}, []);
|
|
|
|
// 유하방향 레이어는 하나만 유지하고 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);
|
|
}
|
|
|
|
while (flowDirectionFeatureKeyQueueRef.current.length > FLOW_DIRECTION_CACHE_MAX_FEATURES) {
|
|
const staleKey = flowDirectionFeatureKeyQueueRef.current.shift();
|
|
if (!staleKey) break;
|
|
|
|
const staleFeature = source.getFeatures().find((feature: any) =>
|
|
feature.get("flowDirectionCacheKey") === staleKey,
|
|
);
|
|
if (staleFeature) {
|
|
source.removeFeature(staleFeature);
|
|
}
|
|
flowDirectionFeatureKeySetRef.current.delete(staleKey);
|
|
}
|
|
}, [ensureFlowDirectionLayer]);
|
|
|
|
// 현재 지도 BBOX와 체크된 관종 필터로 유하방향 GeoJSON을 조회하고, 최신 요청만 지도에 반영한다.
|
|
const loadFlowDirectionLayer = useCallback(
|
|
async (layerInfo: any) => {
|
|
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;
|
|
}
|
|
|
|
const screenExtent = view.calculateExtent(size);
|
|
const requestExtent = expandFlowDirectionExtent(screenExtent);
|
|
const screenBbox = formatFlowDirectionBbox(screenExtent);
|
|
const requestBbox = formatFlowDirectionBbox(requestExtent);
|
|
const params = new URLSearchParams({
|
|
bbox: requestBbox,
|
|
srs: FLOW_DIRECTION_SRS,
|
|
limit: String(FLOW_DIRECTION_DEFAULT_LIMIT),
|
|
usgCd: usageCodes.join(","),
|
|
});
|
|
|
|
try {
|
|
const payload = await getRawJson(
|
|
`/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`,
|
|
{ signal: abortController.signal },
|
|
);
|
|
if (requestSeq !== flowDirectionRequestSeqRef.current) return;
|
|
|
|
const featureCollection = getFeatureCollection(payload);
|
|
renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap);
|
|
(window as any).__SN_FLOW_DIRECTION_DEBUG__ = {
|
|
screenBbox,
|
|
requestBbox,
|
|
requestSeq,
|
|
zoom,
|
|
usageCodes,
|
|
returnedFeatures: featureCollection.features?.length ?? 0,
|
|
cachedFeatures: flowDirectionSourceRef.current?.getFeatures?.().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);
|
|
removeFlowDirectionLayer();
|
|
if (!flowDirectionFailureAlertedRef.current) {
|
|
alert("유하방향 정보를 불러오지 못했습니다.");
|
|
flowDirectionFailureAlertedRef.current = true;
|
|
}
|
|
} finally {
|
|
if (flowDirectionAbortControllerRef.current === abortController) {
|
|
flowDirectionAbortControllerRef.current = null;
|
|
}
|
|
}
|
|
},
|
|
[removeFlowDirectionLayer, renderFlowDirectionLayer],
|
|
);
|
|
|
|
// SearchModal, GisContent 등에서 호출
|
|
const onFeatureSelect = (data: any) => {
|
|
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 facilityTypes = [{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }];
|
|
|
|
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 handleAgedPipeRowDoubleClick = useCallback(
|
|
async (row: AgedPipeRow) => {
|
|
try {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = true;
|
|
const core = GisApp.Module.layer as any;
|
|
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
|
|
} catch (error) {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
|
console.error("노후관로 위치 이동 중 오류:", error);
|
|
}
|
|
|
|
setFeatureData(row as any);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[setIsSearchModalOpen],
|
|
);
|
|
|
|
const handlePipeAnalysisRowSelect = useCallback(
|
|
async (row: Pick<PipeAnalysisDetailRow, "fid">) => {
|
|
try {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = true;
|
|
const core = GisApp.Module.layer as any;
|
|
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
|
|
} catch (error) {
|
|
shouldFocusAgedPipeOnNextSelectRef.current = false;
|
|
console.error("관로 위치 이동 중 오류:", error);
|
|
}
|
|
|
|
setFeatureData(row as any);
|
|
setIsOpen(true);
|
|
setIsSearchModalOpen(false);
|
|
},
|
|
[setIsSearchModalOpen],
|
|
);
|
|
|
|
const handleAgedPipeDownload = useCallback(async () => {
|
|
if (agedPipeTotalCount === 0) {
|
|
alert("다운로드할 데이터가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsAgedPipeListLoading(true);
|
|
const response = await fetchAgedPipeRows(1, appliedAgedPipeFilters, 10000);
|
|
await downloadExcelWithKoreanHeaders({
|
|
data: response.rows as JSON[],
|
|
fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`,
|
|
sheetNameArray: ["노후관로 현황"],
|
|
headerRange: "A1:J1",
|
|
headerMapping: AGED_PIPE_EXCEL_HEADER,
|
|
});
|
|
} catch (error) {
|
|
console.error("노후관로 목록 다운로드 중 오류:", error);
|
|
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setIsAgedPipeListLoading(false);
|
|
}
|
|
}, [agedPipeTotalCount, appliedAgedPipeFilters, fetchAgedPipeRows]);
|
|
|
|
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]);
|
|
|
|
// 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;
|
|
setShowWaterDepth(hasWaterDepthLayer);
|
|
}, []);
|
|
|
|
// 체크된 레이어 변경 처리 함수
|
|
const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => {
|
|
checkedNodesDataRef.current = 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) {
|
|
clearFlowDirectionFeatureCache();
|
|
setFlowDirectionSelectionVersion((version) => version + 1);
|
|
}
|
|
|
|
if (!nextFlowDirectionLayerInfo) {
|
|
// 해제 직후 pending API 응답이 늦게 도착해도 renderFlowDirectionLayer가 실행되지 않도록 순번을 무효화한다.
|
|
flowDirectionRequestSeqRef.current += 1;
|
|
flowDirectionAbortControllerRef.current?.abort();
|
|
flowDirectionAbortControllerRef.current = null;
|
|
flowDirectionFailureAlertedRef.current = false;
|
|
removeFlowDirectionLayer();
|
|
}
|
|
|
|
handleWaterDepthVisibility(checkedIds, checkedNodesData);
|
|
setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node)));
|
|
}, [clearFlowDirectionFeatureCache, handleWaterDepthVisibility, removeFlowDirectionLayer]);
|
|
|
|
const handleOpenLayerPopup = useCallback(
|
|
(_layerNm: string, node?: any) => {
|
|
if (isAgedPipeLayerNode(node)) {
|
|
openAgedPipeList();
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
[openAgedPipeList],
|
|
);
|
|
|
|
const inlineLayerSettingView = useCallback(
|
|
(node: any) => {
|
|
if (!isAgedPipeChecked || !isAgedPipeLayerNode(node)) return null;
|
|
|
|
return (
|
|
<AgedPipeLayerSettingPanel
|
|
filters={draftAgedPipeFilters}
|
|
onFiltersChange={setDraftAgedPipeFilters}
|
|
onApply={handleApplyAgedPipeFilters}
|
|
/>
|
|
);
|
|
},
|
|
[draftAgedPipeFilters, handleApplyAgedPipeFilters, isAgedPipeChecked],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
|
|
const core = GisApp.Module.core as any;
|
|
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
|
|
|
|
if (!isAgedPipeChecked) return;
|
|
|
|
const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
|
|
(item: any) => item?.lyrTyNm === AGED_PIPE_LAYER_TYPE && item?.actlLyrYn === "Y",
|
|
) as any;
|
|
|
|
if (!baseLayerCode) return;
|
|
|
|
const layerCode = {
|
|
...baseLayerCode,
|
|
id: AGED_PIPE_HIGHLIGHT_LAYER_ID,
|
|
lyrSn: AGED_PIPE_HIGHLIGHT_LAYER_ID,
|
|
lyrNm: "노후관로",
|
|
lyrTyNm: AGED_PIPE_LAYER_TYPE,
|
|
gobjTy: "line",
|
|
actlLyrYn: "Y",
|
|
exprsYn: "Y",
|
|
clickPsblYn: "Y",
|
|
cmncNm: "bbox",
|
|
srvcNm: "wfs",
|
|
flterSql: buildAgedPipeCqlFilters(appliedAgedPipeFilters).join(" and "),
|
|
lnWdth: "4",
|
|
lnColor: "rgba(235, 40, 40, 0.82)",
|
|
drawOrdr: "9999",
|
|
minViewSize: String(AGED_PIPE_MIN_ZOOM),
|
|
};
|
|
|
|
const layer = core?.addWfsLayer?.(layerCode);
|
|
layer?.set?.("id", AGED_PIPE_HIGHLIGHT_LAYER_ID);
|
|
layer?.set?.("lyrNm", "노후관로");
|
|
layer?.set?.("lyrTyNm", AGED_PIPE_LAYER_TYPE);
|
|
|
|
return () => {
|
|
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
|
|
};
|
|
}, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]);
|
|
|
|
const openAnalysisPopup = useCallback((popupKey: WindowPopupKey) => {
|
|
const popup = openWindowPopup(popupKey);
|
|
if (!popup) {
|
|
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
|
return;
|
|
}
|
|
|
|
if (popupKey === "pipeAnalysisPopup") {
|
|
pipeAnalysisPopupWindowRef.current = popup;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !flowDirectionLayerInfo) return;
|
|
|
|
// 트리 체크 상태나 관종 레이어 선택이 바뀌면 유하방향 API vector layer를 추가/갱신/제거한다.
|
|
if (isFlowDirectionChecked) {
|
|
loadFlowDirectionLayer(flowDirectionLayerInfo);
|
|
} else {
|
|
removeFlowDirectionLayer();
|
|
}
|
|
}, [
|
|
flowDirectionLayerInfo,
|
|
flowDirectionSelectionVersion,
|
|
isFlowDirectionChecked,
|
|
loadFlowDirectionLayer,
|
|
mapReady,
|
|
removeFlowDirectionLayer,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
|
|
// 유하방향이 켜진 상태에서 지도 이동/확대축소가 끝나면 현재 BBOX 기준으로 다시 조회한다.
|
|
const key = gisMaps.current.on("moveend", () => {
|
|
if (!flowDirectionCheckedRef.current || !flowDirectionLayerInfoRef.current) return;
|
|
loadFlowDirectionLayer(flowDirectionLayerInfoRef.current);
|
|
});
|
|
|
|
return () => {
|
|
unByKey(key);
|
|
};
|
|
}, [loadFlowDirectionLayer, mapReady]);
|
|
|
|
|
|
return (
|
|
<>
|
|
<div className="bg-container relative h-[calc(100dvh-5rem)] overflow-hidden">
|
|
{layerLoadError ? (
|
|
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-white px-4 py-3 text-sm text-red-700 shadow">
|
|
레이어 설정 조회 실패: {layerLoadError}
|
|
</div>
|
|
) : null}
|
|
<AnalysisPopupMenu onOpenPopup={openAnalysisPopup} />
|
|
<Gis
|
|
treeData={treeData}
|
|
isSearch={true}
|
|
gisMaps={gisMaps}
|
|
isMiniMap={false}
|
|
searchView={SearchModal}
|
|
detailView={FeatureClickEventPopup}
|
|
onFeatureSelect={onFeatureSelect}
|
|
featureData={featureData}
|
|
setFeatureData={setFeatureData}
|
|
isOpen={isOpen}
|
|
setIsOpen={setIsOpen}
|
|
isSearchModalOpen={isSearchModalOpen}
|
|
setIsSearchModalOpen={setIsSearchModalOpen}
|
|
onCheckedLayersChange={handleCheckedLayersChange}
|
|
onOpenPopup={handleOpenLayerPopup}
|
|
searchBar={<ProcSearchBar gisMaps={gisMaps} facilityTypes={facilityTypes} />}
|
|
waterDepth={showWaterDepth ? WaterDepth : undefined}
|
|
inlineLayerSettingView={inlineLayerSettingView}
|
|
layerIconVisible={isAgedPipeLayerNode}
|
|
isPlaying={isPlaying}
|
|
setIsPlaying={setIsPlaying as any}
|
|
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
|
|
checkInterval={2500}
|
|
checkedLayerChangeDelay={0}
|
|
showZoomLevel={true}
|
|
|
|
defaultZoom={7.5}
|
|
defaultLonLat={[127.75, 36]}
|
|
viewExtent={SOUTH_KOREA_VIEW_EXTENT}
|
|
/>
|
|
{isAgedPipeListMinimized && (
|
|
<div className="fixed left-[480px] top-[68px] z-[90] flex min-w-[250px] items-center gap-3 rounded-sm border border-red-200 bg-white/95 px-3 py-2 shadow-[0_8px_22px_rgba(15,23,42,0.22)] backdrop-blur">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-red-50">
|
|
<span className="block h-4 w-4 rounded-sm border-2 border-red-500 bg-red-500/20" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-[1.25rem] font-semibold leading-tight text-red-700">노후관로 현황</div>
|
|
<div className="text-[1rem] leading-tight text-slate-500">
|
|
{appliedAgedPipeFilters.elapsedYears}년 이상 · {agedPipeTotalCount.toLocaleString()}건
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
size="xs"
|
|
className="h-7 px-3"
|
|
onClick={openAgedPipeList}
|
|
>
|
|
열기
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
size="xs"
|
|
className="h-7 px-3"
|
|
onClick={() => setIsAgedPipeListMinimized(false)}
|
|
>
|
|
닫기
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Map;
|