Compare commits

...
2 changed files with 218 additions and 61 deletions
+217 -61
View File
@@ -18,10 +18,9 @@
"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 { usePathname } from "next/navigation";
import GeoJSON from "ol/format/GeoJSON.js";
import Point from "ol/geom/Point.js";
import VectorLayer from "ol/layer/Vector.js";
@@ -29,35 +28,44 @@ 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 { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
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 useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { getData, getRawJson } from "../../components/lib/api";
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
import type { PipeAnalysisDetailRow } from "./components/pipeAnalysis/pipeAnalysisUtils";
import {
PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
type PipeAnalysisDetailRow,
} from "./components/pipeAnalysis/pipeAnalysisUtils";
import {
AGED_PIPE_DEFAULT_FILTERS,
AGED_PIPE_EXCEL_HEADER,
AGED_PIPE_MIN_ZOOM,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_MIN_ZOOM,
AGED_PIPE_PAGE_SIZE,
type AgedPipeFilters,
type AgedPipeRow,
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";
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
import SearchModal from "./searchModal/searchModal";
import { WATER_DEPTH_LAYER_TYPES, WaterDepth } from "./waterDepth/waterDepth";
import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
const AnalysisPopupMenu = dynamic(
() => import("./components/analysisMenu/AnalysisPopupMenu"),
@@ -79,18 +87,27 @@ const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
// 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다.
const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction";
const FLOW_DIRECTION_LAYER_NAME = "유하방향";
const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const FLOW_DIRECTION_SRS = "EPSG:5179";
const FLOW_DIRECTION_DEFAULT_MIN_ZOOM = 13;
const FLOW_DIRECTION_DEFAULT_LIMIT = 5000;
const FLOW_DIRECTION_DEFAULT_COLOR = "rgba(40, 90, 180, 0.84)";
const FLOW_DIRECTION_NETWORK_CORRECTION_COLOR = "rgba(0, 168, 132, 0.95)";
const FLOW_DIRECTION_BBOX_BUFFER_RATIO = 0.35;
const FLOW_DIRECTION_CACHE_MAX_FEATURES = 10000;
type FlowDirectionUsageStyle = {
color: string;
lineColor: string;
};
type FlowDirectionMode = "PIPE_START_END" | "PIPE_DEPTH";
// /map은 관로 깊이 기준, /map2는 관로 원천 geometry 시점->종점 기준만 유지한다. 두 화면 모두 BBOX 조회만 사용한다.
const getFlowDirectionModeByPathname = (pathname: string | null): FlowDirectionMode => {
const normalizedPathname = String(pathname ?? "").replace(/\/$/, "");
if (normalizedPathname.endsWith("/map2")) return "PIPE_START_END";
return "PIPE_DEPTH";
};
const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [120, 120, 120, 120];
const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [50, 50, 50, 50];
@@ -141,15 +158,19 @@ const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => {
};
// 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다.
const getFiniteNumber = (value: unknown) => {
if (value === null || value === undefined || String(value).trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const getFlowDirectionMinZoom = (layerInfo: any) => {
const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom);
return Number.isFinite(minZoom) ? minZoom : FLOW_DIRECTION_DEFAULT_MIN_ZOOM;
return getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom) ?? FLOW_DIRECTION_DEFAULT_MIN_ZOOM;
};
// 유하방향 화살표는 관로 위에 얹는 보조 레이어라 레이어 설정의 drawOrdr를 우선 사용하고 없으면 높은 zIndex를 준다.
const getFlowDirectionZIndex = (layerInfo: any) => {
const drawOrder = Number(layerInfo?.drawOrdr);
return Number.isFinite(drawOrder) ? drawOrder : 9998;
return getFiniteNumber(layerInfo?.drawOrdr) ?? 9998;
};
// BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다.
@@ -366,8 +387,19 @@ const createFlowDirectionStyle = (
const usageCode = String(feature.get("usgCd") ?? feature.get("usg_cd") ?? "").trim();
const usageStyle = usageStyleMap[usageCode];
const baseColor = usageStyle?.color ?? FLOW_DIRECTION_DEFAULT_COLOR;
const isNetworkCorrected = String(
feature.get("networkCorrectionYn") ??
feature.get("network_correction_yn") ??
"",
).toUpperCase() === "Y";
const styleByConfidence: Record<string, { color: string; lineColor: string; scale: number; width: number }> = {
const styleByConfidence: Record<string, { color: string; lineColor: string; scale: number; width: number; lineDash?: number[] }> = {
HIGH: {
color: baseColor,
lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42),
scale: 1,
width: 1.3,
},
MEDIUM: {
color: baseColor,
lineColor: usageStyle?.lineColor ?? withAlpha(baseColor, 0.42),
@@ -381,12 +413,21 @@ const createFlowDirectionStyle = (
width: 1.1,
},
};
const style = styleByConfidence[confidence] ?? styleByConfidence.LOW!;
const style = isNetworkCorrected
? {
color: FLOW_DIRECTION_NETWORK_CORRECTION_COLOR,
lineColor: withAlpha(FLOW_DIRECTION_NETWORK_CORRECTION_COLOR, 0.5),
scale: 1.08,
width: 2,
lineDash: [8, 4],
}
: styleByConfidence[confidence] ?? styleByConfidence.LOW!;
const styles = [
new Style({
stroke: new Stroke({
color: style.lineColor,
width: style.width,
lineDash: style.lineDash,
lineCap: "round",
lineJoin: "round",
}),
@@ -435,8 +476,8 @@ const extractUsageCodesFromLayerInfo = (layerInfo: any) => {
// 현재 줌에서 실제 표출 가능한 관로 레이어인지 확인해 숨겨진 관종의 유하방향 요청을 막는다.
const isLayerVisibleAtZoom = (layerInfo: any, zoom: number) => {
const minZoom = Number(layerInfo?.minViewSize ?? layerInfo?.minZoom);
return !Number.isFinite(minZoom) || zoom >= minZoom;
const minZoom = getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom);
return minZoom === null || zoom >= minZoom;
};
// 현재 체크되어 화면에 보이는 관로 레이어의 관종 코드와 선 색상을 유하방향 조회/스타일 기준으로 변환한다.
@@ -446,6 +487,7 @@ const extractCheckedPipeUsageStyles = (checkedNodesData: any[], zoom: number) =>
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);
@@ -507,6 +549,8 @@ const appendAgedPipeControlNode = (data: any[]) => {
};
const Map = () => {
const pathname = usePathname();
const flowDirectionMode = useMemo(() => getFlowDirectionModeByPathname(pathname), [pathname]);
const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore();
const [treeData, setTreeData] = useState<any[]>([]); // 초기값을 객체 또는 null로 설정
@@ -550,9 +594,13 @@ const Map = () => {
// 같은 관로가 겹치는 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);
// 지도 이동이 연속으로 발생하면 이전 bbox 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다.
// 지도 이동이 연속으로 발생하면 이전 유하방향 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다.
const flowDirectionAbortControllerRef = useRef<AbortController | null>(null);
// 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다.
const flowDirectionFailureAlertedRef = useRef(false);
@@ -630,6 +678,7 @@ const Map = () => {
flowDirectionSourceRef.current = null;
flowDirectionFeatureKeySetRef.current.clear();
flowDirectionFeatureKeyQueueRef.current = [];
flowDirectionVisibleUsageCodesRef.current.clear();
}, []);
// 유하방향 cache/source만 비워 관종 변경처럼 조건이 달라진 경우 이전 조건의 피처가 남지 않게 한다.
@@ -637,8 +686,46 @@ const Map = () => {
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;
@@ -704,23 +791,11 @@ const Map = () => {
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을 조회하고, 최신 요청만 지도에 반영한다.
// 현재 지도 BBOX와 체크된 관종별 필터로 유하방향 GeoJSON을 각각 조회하고, 최신 요청만 지도에 반영한다.
const loadFlowDirectionLayer = useCallback(
async (layerInfo: any) => {
async (layerInfo: any, requestOnlyUsageCodes?: string[]) => {
const map = gisMaps.current;
if (!map) return;
@@ -748,45 +823,113 @@ const Map = () => {
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 params = new URLSearchParams({
bbox: requestBbox,
const baseParams = new URLSearchParams({
srs: FLOW_DIRECTION_SRS,
limit: String(FLOW_DIRECTION_DEFAULT_LIMIT),
usgCd: usageCodes.join(","),
directionMode: flowDirectionMode,
searchStrategy: "BBOX",
bbox: requestBbox,
});
try {
const payload = await getRawJson(
`/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`,
{ signal: abortController.signal },
);
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 featureCollection = getFeatureCollection(payload);
renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap);
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,
returnedFeatures: featureCollection.features?.length ?? 0,
requestUsageCodes,
requestCount: responses.length,
returnedFeaturesByUsage,
returnedFeatures: Object.values(returnedFeaturesByUsage).reduce((sum, count) => sum + count, 0),
networkCorrectedFeatures,
cachedFeatures: flowDirectionSourceRef.current?.getFeatures?.().length ?? 0,
};
flowDirectionFailureAlertedRef.current = false;
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);
removeFlowDirectionLayer();
if (!isPartialUsageRequest) {
removeFlowDirectionLayer();
}
if (!flowDirectionFailureAlertedRef.current) {
alert("유하방향 정보를 불러오지 못했습니다.");
flowDirectionFailureAlertedRef.current = true;
}
} finally {
@@ -795,7 +938,7 @@ const Map = () => {
}
}
},
[removeFlowDirectionLayer, renderFlowDirectionLayer],
[flowDirectionMode, removeFlowDirectionLayer, renderFlowDirectionLayer, retainFlowDirectionUsageCodes],
);
// SearchModal, GisContent 등에서 호출
@@ -1090,12 +1233,22 @@ const Map = () => {
setIsFlowDirectionChecked(Boolean(nextFlowDirectionLayerInfo));
setFlowDirectionLayerInfo(nextFlowDirectionLayerInfo);
if (nextFlowDirectionLayerInfo) {
clearFlowDirectionFeatureCache();
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;
@@ -1105,7 +1258,7 @@ const Map = () => {
handleWaterDepthVisibility(checkedIds, checkedNodesData);
setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node)));
}, [clearFlowDirectionFeatureCache, handleWaterDepthVisibility, removeFlowDirectionLayer]);
}, [handleWaterDepthVisibility, removeFlowDirectionLayer, retainFlowDirectionUsageCodes]);
const handleOpenLayerPopup = useCallback(
(_layerNm: string, node?: any) => {
@@ -1194,7 +1347,10 @@ const Map = () => {
// 트리 체크 상태나 관종 레이어 선택이 바뀌면 유하방향 API vector layer를 추가/갱신/제거한다.
if (isFlowDirectionChecked) {
loadFlowDirectionLayer(flowDirectionLayerInfo);
const requestUsageCodes = flowDirectionSelectionRequestUsageCodesRef.current;
flowDirectionSelectionRequestUsageCodesRef.current = null;
if (requestUsageCodes && requestUsageCodes.length === 0) return;
loadFlowDirectionLayer(flowDirectionLayerInfo, requestUsageCodes ?? undefined);
} else {
removeFlowDirectionLayer();
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../map/page";