Merge branch 'iipbs/sn/dev2' into iipbs/sn/dev2_kic
This commit is contained in:
+3
-1
@@ -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를 쓰는지 주석으로 남긴다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
// 진행률 변경 핸들러
|
||||
|
||||
Reference in New Issue
Block a user