From d81952810fbd482ee28bfa9b03b39a7df2189769 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 30 Apr 2026 16:54:06 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EC=9C=A0=ED=95=98=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=ED=91=9C=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/AGENTS.md | 4 +- apps/sn/app/(map-layout)/map/page.tsx | 654 +++++++++++++++++- apps/sn/app/components/lib/api/index.ts | 6 +- .../business_components/src/ui/map/Gis.js | 12 +- 4 files changed, 668 insertions(+), 8 deletions(-) diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index de337921b..291eb61d6 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -108,7 +108,7 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 5. 불확실한 경우 먼저 계획을 제시한다. 6. 변경 전후 차이, 영향 범위, 검증 방법을 명확히 설명한다. 7. 기존 주석은 임의로 제거하지 않는다. -8. 복잡한 컴포넌트, 훅, 분기 로직, 상태 흐름, 데이터 변환 로직은 함수/메서드 단위 설명 주석을 충분히 작성한다. +8. 신규/수정 함수, 주요 상수, 주요 변수, `useState`, `useRef`, `useMemo`, `useCallback`에는 화면/API/상태 흐름에서 맡는 역할을 알 수 있는 주석을 작성한다. 9. 한글이 깨지지 않도록 반드시 UTF-8로 유지한다. 10. 작업 범위를 넓히기보다 `sn` 내부 해결을 우선한다. 11. 백엔드 API 응답과 실제 브라우저 동작까지 함께 확인하는 것을 기본으로 한다. @@ -240,6 +240,8 @@ pnpm turbo run build --filter="...[origin/main]" - 한글이 많은 문서는 셸 리다이렉션(`>`, `>>`)이나 검증 없는 `Set-Content`, `Out-File`로 바로 덮어쓰지 않는다. - 기존 주석은 정당한 이유 없이 삭제하지 않는다. - 복잡한 렌더링 분기, 폼 처리, 데이터 변환, 트리 구조, 지도 연동, 표 컴포넌트 조합 로직은 함수/메서드 단위 주석을 상세히 작성한다. +- 신규/수정 상수와 변수는 이름만으로 의도가 충분히 드러나지 않으면 선언부 근처에 역할, 단위, 백엔드 계약, 화면 상태와의 관계를 주석으로 남긴다. +- `useRef`로 비동기 요청, 팝업 창, 지도 객체, 체크 상태를 보관하는 경우에는 왜 state가 아니라 ref를 쓰는지 주석으로 남긴다. --- diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index cbbea24b5..a227e1a45 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -21,6 +21,13 @@ 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"; @@ -54,9 +61,393 @@ 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"; +// 유하방향은 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 = 2000; +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, +) => { + 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 = { + 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(); + 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 = {}; + + 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 === "노후관로")) { @@ -126,6 +517,31 @@ const Map = () => { const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); + // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. + const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false); + // 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다. + const [flowDirectionLayerInfo, setFlowDirectionLayerInfo] = useState(null); + // 관종 레이어 체크 변경도 유하방향 재조회 조건이므로 별도 버전 state로 effect를 다시 실행한다. + const [flowDirectionSelectionVersion, setFlowDirectionSelectionVersion] = useState(0); + // moveend 이벤트 핸들러는 최신 state closure를 보장하지 않으므로 현재 체크 여부를 ref로 함께 보관한다. + const flowDirectionCheckedRef = useRef(false); + // 지도 이동 후 재조회할 때 최신 유하방향 레이어 설정을 참조하기 위한 ref이다. + const flowDirectionLayerInfoRef = useRef(null); + // 유하방향 API의 관종 필터를 만들기 위해 현재 체크된 트리 노드 목록을 ref로 보관한다. + const checkedNodesDataRef = useRef([]); + // 유하방향 전용 vector layer/source를 유지해 지도 이동 시 기존 피처를 지우지 않고 새 BBOX 응답만 병합한다. + const flowDirectionLayerRef = useRef(null); + const flowDirectionSourceRef = useRef(null); + // 같은 관로가 겹치는 BBOX에서 반복 조회될 수 있으므로 고유키와 삽입 순서를 캐시로 관리한다. + const flowDirectionFeatureKeySetRef = useRef>(new Set()); + const flowDirectionFeatureKeyQueueRef = useRef([]); + // 체크 해제나 재조회 중 늦게 도착한 이전 API 응답이 레이어를 다시 그리지 못하도록 요청 순번을 관리한다. + const flowDirectionRequestSeqRef = useRef(0); + // 지도 이동이 연속으로 발생하면 이전 bbox 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다. + const flowDirectionAbortControllerRef = useRef(null); + // 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다. + const flowDirectionFailureAlertedRef = useRef(false); + useEffect(() => { fetchData(); }, []); @@ -180,7 +596,7 @@ const Map = () => { const fetchData = async () => { try { const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); - setTreeData(appendAgedPipeControlNode(data)); + setTreeData(appendAgedPipeControlNode(normalizeFlowDirectionLayerInfos(data))); setLayerLoadError(null); } catch (error) { const message = @@ -191,6 +607,182 @@ const Map = () => { } }; + // 유하방향은 일반 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, + ) => { + 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; @@ -447,9 +1039,34 @@ const Map = () => { // 체크된 레이어 변경 처리 함수 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))); - }, [handleWaterDepthVisibility]); + }, [clearFlowDirectionFeatureCache, handleWaterDepthVisibility, removeFlowDirectionLayer]); const handleOpenLayerPopup = useCallback( (_layerNm: string, node?: any) => { @@ -521,6 +1138,38 @@ const Map = () => { }; }, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]); + 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 ( <> @@ -554,6 +1203,7 @@ const Map = () => { setIsPlaying={setIsPlaying as any} targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} checkInterval={2500} + checkedLayerChangeDelay={0} showZoomLevel={true} defaultZoom={7.5} diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index aba7a3092..08d2dfbe8 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -143,10 +143,12 @@ export const deleteData = async (url: string, data?: object): Promise => { return res; }; -export const getRawJson = async (url: string): Promise => { +// GET 응답을 공통 data wrapper 없이 원문 JSON으로 읽는다. init은 지도 bbox 요청처럼 AbortSignal이 필요한 호출에서 사용한다. +export const getRawJson = async (url: string, init: RequestInit = {}): Promise => { const res = await fetch(resolveApiUrl(url), { + ...init, method: "GET", - headers: withSiteIdHeaders(), + headers: withSiteIdHeaders(init.headers), }); if (res.status === 401) { handleUnauthorizedResponse(); diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index d7bbf3fa5..cdaf096c2 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -60,6 +60,8 @@ function Gis({ setIsPlaying = () => { }, targetLayerTypes = [], checkInterval = 3000, + // 체크 변경 콜백 지연 시간. 기본값은 기존 동작 유지, sn 유하방향처럼 즉시 반응이 필요한 화면은 0으로 전달한다. + checkedLayerChangeDelay = 1000, height: height = "h-[calc(100vh-5rem)]", onOpenPopup = (data) => { }, droughtDates = { startYmd: '', endYmd: '' }, @@ -251,13 +253,17 @@ function Gis({ // 체크된 레이어 정보를 처리하는 함수 const handleCheckedLayersChange = useCallback( (checkedIds, checkedNodesData) => { - setTimeout(() => { + // 콜백 지연 옵션을 한 곳에서 처리해 기존 화면과 즉시 반응 화면의 동작 차이를 prop으로 제어한다. + const applyCheckedLayersChange = () => { setCheckedLayers(checkedIds); setCheckedLayersData(checkedNodesData); onCheckedLayersChange?.(checkedIds, checkedNodesData); - }, 1000); + }; + + const delayMs = Math.max(0, Number(checkedLayerChangeDelay) || 0); + setTimeout(applyCheckedLayersChange, delayMs); }, - [onCheckedLayersChange], + [checkedLayerChangeDelay, onCheckedLayersChange], ); // 진행률 변경 핸들러 From 5515a987494d0e4729bf225f5330dbe9aa60ba62 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 30 Apr 2026 17:06:21 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A7=9D?= =?UTF-8?q?=EB=8F=84=20=EC=98=81=EC=97=AD=20=EB=82=A8=ED=95=9C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 7 +++++++ packages/common/business_components/src/ui/map/Gis.js | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index a227e1a45..d6d0ec429 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -60,6 +60,12 @@ 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"; @@ -1208,6 +1214,7 @@ const Map = () => { defaultZoom={7.5} defaultLonLat={[127.75, 36]} + viewExtent={SOUTH_KOREA_VIEW_EXTENT} /> {isAgedPipeListMinimized && (
diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index cdaf096c2..2f0c6920b 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -67,6 +67,8 @@ function Gis({ droughtDates = { startYmd: '', endYmd: '' }, defaultZoom = 15, defaultLonLat = [127.4875, 37.4916], + viewExtent, + viewMinZoom, showZoomLevel = false, }) { const mapRef = useRef(); @@ -145,8 +147,9 @@ function Gis({ GisApp.FROM_PROJ, GisApp.TO_PROJ, ), - minZoom: gisCore.defaults.minZoom, + minZoom: viewMinZoom ?? gisCore.defaults.minZoom, maxZoom: gisCore.defaults.maxZoom, + extent: viewExtent, }), }); } else { From 52d41166becdadd3756dccf4f8d293cfc62c04ab Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Mon, 4 May 2026 15:55:42 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=EC=9C=A0=ED=95=98=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=ED=91=9C=EC=B6=9C=20=ED=94=BC=EC=B2=98=EC=88=98=202000->5000?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index d6d0ec429..c053ed707 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -72,7 +72,7 @@ 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 = 2000; +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;