Merge branch 'iipbs/sn/dev2' into iipbs/sn/dev2_kic

This commit is contained in:
kiminchan
2026-05-06 14:46:12 +09:00
4 changed files with 679 additions and 10 deletions
+3 -1
View File
@@ -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를 쓰는지 주석으로 남긴다.
---
+659 -3
View File
@@ -22,6 +22,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";
@@ -62,10 +69,400 @@ 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 === "노후관로")) {
@@ -134,7 +531,31 @@ const Map = () => {
const { ctpv, sgg } = useStore();
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef<Window | null>(null);
const pipeAnalysisPopupWindowRef = 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();
@@ -190,7 +611,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 =
@@ -201,6 +622,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<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;
@@ -481,9 +1078,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) => {
@@ -567,6 +1189,38 @@ const Map = () => {
}
}, []);
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 (
<>
@@ -601,10 +1255,12 @@ const Map = () => {
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">
+4 -2
View File
@@ -143,10 +143,12 @@ export const deleteData = async (url: string, data?: object): Promise<any> => {
return res;
};
export const getRawJson = async (url: string): Promise<any> => {
// GET 응답을 공통 data wrapper 없이 원문 JSON으로 읽는다. init은 지도 bbox 요청처럼 AbortSignal이 필요한 호출에서 사용한다.
export const getRawJson = async (url: string, init: RequestInit = {}): Promise<any> => {
const res = await fetch(resolveApiUrl(url), {
...init,
method: "GET",
headers: withSiteIdHeaders(),
headers: withSiteIdHeaders(init.headers),
});
if (res.status === 401) {
handleUnauthorizedResponse();
@@ -60,11 +60,15 @@ function Gis({
setIsPlaying = () => { },
targetLayerTypes = [],
checkInterval = 3000,
// 체크 변경 콜백 지연 시간. 기본값은 기존 동작 유지, sn 유하방향처럼 즉시 반응이 필요한 화면은 0으로 전달한다.
checkedLayerChangeDelay = 1000,
height: height = "h-[calc(100vh-5rem)]",
onOpenPopup = (data) => { },
droughtDates = { startYmd: '', endYmd: '' },
defaultZoom = 15,
defaultLonLat = [127.4875, 37.4916],
viewExtent,
viewMinZoom,
showZoomLevel = false,
}) {
const mapRef = useRef();
@@ -143,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 {
@@ -251,13 +256,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],
);
// 진행률 변경 핸들러