Compare commits

...
Author SHA1 Message Date
mind-one005 eddb1b24d6 sn: 지반침하 지도상세정보 엑셀다운로드 2026-10-22 10:19:52 +09:00
mind-one005 cf2cc11167 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-22 09:44:24 +09:00
mind-one005 9e71f529e7 sn: 통수위험 위험여부란 수정 외 2026-10-22 09:33:27 +09:00
kiminchan 36b11d6b00 맨홀 조사보고서 단독 교체 및 GIS 번호 기반 조회 수정 2026-10-19 17:01:37 +09:00
mind-one005 79f70d8b4b sn: 도시침수지도 geoserver style적용 2026-10-16 18:05:48 +09:00
mind-one005 7911cff96a sn: GIS편집,GIS관리, 통수위험 정보카드 X버튼 2026-10-16 17:36:31 +09:00
mind-one005 fe501965bf sn: 조사관리>>조사사업 2026-10-16 16:51:47 +09:00
mind-one001 fb82cadc82 준설관리 텍스트 수정 2026-10-16 11:10:47 +09:00
mind-one001 f5b6effeb3 준설관리 관로 미표출로 인한 기능수정 2026-10-16 11:00:13 +09:00
mind-one001 63c2901ae3 feat(sn): allow dredging records without a mapped pipe 2026-10-16 10:09:44 +09:00
mind-one001 0356aef7b2 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-15 11:23:53 +09:00
mind-one001 f709dc99f6 chore: stop tracking local VS Code settings 2026-10-15 11:21:44 +09:00
mind-one005 728a66bac2 sn: apigateway.onasys.co.kr 2026-10-13 15:55:31 +09:00
mind-one005 c850b9ce1e sn: 해당 도시침수지도가 없습니다. 2026-10-13 15:48:24 +09:00
mind-one005 791bc20e8e sn: 지도 위치 정보가 없는 경우 404>>위치 정보가 없습니다. 2026-10-13 15:22:16 +09:00
mind-one001 fed1383ced 시도별 노후관로현황 기본 조회기간을 당해연도로 변경 2026-10-08 10:30:51 +09:00
mind-one001 91ff80a255 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-07 15:15:32 +09:00
mind-one005 5e43d69d8e sn: null 인경우, 공백으로 보이도록 2026-10-07 15:05:40 +09:00
mind-one001 7f10e20a41 전국 지도 보이게 줌 레벨 수정 2026-10-07 15:03:03 +09:00
mind-one005 9971e512c8 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-06 13:15:48 +09:00
mind-one005 0c77ed9bba sn: 도시침수예방사업 사업명 필수 2026-10-06 13:15:32 +09:00
kiminchan e759c96ade Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-06 13:06:20 +09:00
kiminchan f602b75268 페이지별 도움말 조회를 위한 메뉴 코드 전달 누락 수정 2026-10-06 13:06:01 +09:00
mind-one005 11d0378060 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-02 21:26:43 +09:00
mind-one005 8ed4f959cb sn: 확률강우량 모달 추가 2026-10-02 18:12:03 +09:00
mind-one005 e5f7778861 sn: 확률강우량 모달 추가 2026-10-02 18:07:00 +09:00
kiminchan 2d086a05b2 조사사업·준설 등록 필수 표시 및 숫자 입력 제한 보완 2026-10-02 17:11:04 +09:00
kiminchan 6ef4603a7c 지도 대상 선택 이동 시 지역·시설 검색조건 초기화 2026-10-02 16:51:47 +09:00
mind-one001 8c614b84a7 merge(sn): preserve modular map entry with remote import cleanup 2026-10-01 16:22:02 +09:00
kiminchan 7d1a501ad8 관망도 페이지 import 병합 충돌 해소 2026-10-01 16:03:12 +09:00
mind-one001 48589274c8 merge(sn): integrate latest survey layers and upload guidance 2026-10-01 15:59:43 +09:00
kiminchan 83f7676db4 조사 레이어 표출 및 조사결과 업로드 안내 개선 2026-10-01 15:55:29 +09:00
mind-one001 c8b30810f6 style(sn): format integrated map modules 2026-10-01 15:54:43 +09:00
mind-one001 f71fa7d97a merge(sn): retain remote layer and flood updates after refactoring 2026-10-01 15:53:30 +09:00
mind-one001 4c7c4c00ab refactor(sn): modularize map and features and standardize file naming 2026-10-01 15:47:57 +09:00
mind-one005 ae341d66ba sn: 침수예측 오버레이 수정 2026-10-01 15:02:53 +09:00
kiminchan 5fcc184a88 노후관로 레이어를 DB 설정 기반으로 전환 2026-10-01 09:46:16 +09:00
mind-one005 f6d3f89b5b Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-01 00:26:53 +09:00
mind-one005 106e09a7a2 sn: 도시침수예방사업 지정일 수정 2026-10-01 00:26:36 +09:00
mind-one005 d1b4600392 sn: 도시침수예방사업 수정화면 사업순번 단계 수정 불가 2026-10-01 00:16:07 +09:00
kiminchan 4204dfa624 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-30 18:15:15 +09:00
kiminchan d7fd5be068 관로분석 및 통수위험 기본 조회조건 개선 2026-09-30 18:13:31 +09:00
mind-one005 735bb397ec sn: 침수예측현황 분석시각 조건 초기화 2026-09-30 18:09:20 +09:00
mind-one005 8b5a2b713c sn: 도시침수지도 자동선택 2026-09-30 17:53:50 +09:00
mind-one005 4ff80dd257 cm: 유역별 정보 수정 2026-09-29 11:38:47 +09:00
mind-one005 9bb1069d26 cm: 노후 하수관로 현황 수정 2026-09-29 11:17:52 +09:00
mind-one005 f5331d4c50 cm: 유역별 정보 div 수정 2026-09-28 16:32:16 +09:00
mind-one005 f32fe0ec5b cm: 유역별 정보 2026-09-28 16:07:52 +09:00
mind-one001 7b506320b4 서버페이징 변경 2026-09-28 11:22:22 +09:00
mind-one001 d45cd79422 테스트코드 삭제 2026-09-23 16:00:28 +09:00
mind-one001 7cbb58ff32 노후관로 조회 기능 보완 2026-09-23 14:58:14 +09:00
mind-one001 4f50593eac Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-23 13:50:59 +09:00
mind-one001 8818dd21c6 관망도 레이어 전체켜기 기능 추가 2026-09-23 13:50:50 +09:00
mind-one005 b73b83a49f Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-23 09:08:05 +09:00
mind-one005 549debf8bf sn: 노후하수관로정비사업 검색조건 추가 2026-09-23 09:07:56 +09:00
415 changed files with 20223 additions and 13912 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
# turbo
.turbo/
.vscode/**/*
**/.vscode/
*.code-workspace
# dependencies
node_modules/
-88
View File
@@ -1,88 +0,0 @@
{
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
// 기존 특성에 대한 설명을 보려면 가리킵니다.
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "dev:as 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:as"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node",
"request": "launch",
"name": "dev:cs 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:cs"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node",
"request": "attach",
"name": "dev:cs 서버 연결 (attach)",
"port": 9229,
"restart": true,
"skipFiles": [
"<node_internals>/**"
],
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/apps/cs/**",
"!**/node_modules/**"
]
},
{
"type": "chrome",
"request": "launch",
"name": "dev:cs 클라이언트 디버그 (Chrome)",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/apps/cs",
"sourceMaps": true
},
{
"type": "node",
"request": "launch",
"name": "dev:st 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:st"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
}
]
}
-5
View File
@@ -1,5 +0,0 @@
{
"chat.tools.terminal.autoApprove": {
"pnpm": true
}
}
+125 -39
View File
@@ -2,7 +2,7 @@
import { client } from "@/shared/api";
import { create } from "zustand";
import { ENDPOINT } from "@/shared/config";
import type { PplLengthContent, SewerageService } from "../model/sn-interface";
import type { BasinStofcbrCountContent, PplLengthContent, SewerageService } from "../model/sn-interface";
import type { OldPplChartData, mapPplOldType, mapPplOldItem } from "../model/sn-interface";
export type SnState = {
@@ -35,26 +35,7 @@ const initialUsePplChartData: OldPplChartData = {
};
const initialOldPplChartData: OldPplChartData = {
labels: ["금강", "한강", "낙동강", "영산강"],
datasets: [
{
label: "20-25년",
data: [1140, 3181, 3100, 1662],
backgroundColor: "#3B82F6",
borderRadius: 4
},
{
label: "25-30년",
data: [1391, 3615, 1814, 1411],
backgroundColor: "#EF4444",
borderRadius: 4
},
{
label: "30년이상",
data: [2014, 10303, 9831, 2000],
backgroundColor: "#F87171",
borderRadius: 4
}
]
datasets: []
};
type OldPplStoreState = {
chartData: OldPplChartData;
@@ -70,23 +51,122 @@ export const useUsePplStore = create<OldPplStoreState>((set) => ({
}));
export const initailMapOldPplData: mapPplOldType = {
colTitle: { totalLen: "총길이", pplLen2025: "20~25년", pplLen2530: "25~30년", pplLen30: "30년 이상" }, //헤더 영역
colTitle: { stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" },
data: [
{ name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30: 2014 },
{ name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30: 10303 },
{ name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30: 9831 },
{ name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30: 2000 },
{ name: "금강", totalLen: 0, stofcbrCount: 0 },
{ name: "한강", totalLen: 0, stofcbrCount: 0 },
{ name: "낙동강", totalLen: 0, stofcbrCount: 0 },
{ name: "영산강", totalLen: 0, stofcbrCount: 0 },
]
};
type mapPplOldState = {
mapData: mapPplOldItem[];
setMapData: (mapData: mapPplOldItem[]) => void;
setStofcbrCounts: (counts: BasinStofcbrCountContent[]) => void;
};
export const useMapOldPplStore = create<mapPplOldState>((set) => ({
mapData: initailMapOldPplData.data,
setMapData: (newMapData) => set({ mapData: newMapData }),
setMapData: (newMapData) => set((state) => ({
mapData: newMapData.map((item) => ({
...item,
stofcbrCount: state.mapData.find((current) => current.name === item.name)?.stofcbrCount ?? item.stofcbrCount,
})),
})),
setStofcbrCounts: (counts) => set((state) => ({
mapData: state.mapData.map((item) => ({
...item,
stofcbrCount: counts.find((count) => count.zoneNm === item.name)?.stofcbrCount ?? item.stofcbrCount,
})),
})),
}));
const DEFAULT_BASIN_ORDER = ["금강", "한강", "낙동강", "영산강"];
const YEAR_GROUP_START_COLOR = "#FACC15";
const YEAR_GROUP_END_COLOR = "#F87171";
/**
* 시작색과 종료색 사이를 RGB 기준으로 보간해 구간 수만큼 색상을 생성합니다.
* 구간이 하나면 시작색을, 둘 이상이면 첫 구간은 파란색·마지막 구간은 빨간색으로 고정합니다.
*/
function buildYearGroupColors(count: number): string[] {
if (count <= 0) return [];
if (count === 1) return [YEAR_GROUP_START_COLOR];
const parseHex = (color: string) => [
Number.parseInt(color.slice(1, 3), 16),
Number.parseInt(color.slice(3, 5), 16),
Number.parseInt(color.slice(5, 7), 16),
];
const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
const start = parseHex(YEAR_GROUP_START_COLOR);
const end = parseHex(YEAR_GROUP_END_COLOR);
return Array.from({ length: count }, (_, index) => {
const ratio = index / (count - 1);
return `#${start.map((channel, channelIndex) => {
const endChannel = end[channelIndex] ?? channel;
return toHex(channel + (endChannel - channel) * ratio);
}).join("")}`;
});
}
/**
* 별도 정렬순서가 없는 MV 계약에서 구간명의 첫 숫자를 기준으로 표시순서를 결정합니다.
* 숫자가 없는 비정형 구간은 숫자 구간 뒤에 한글 오름차순으로 배치합니다.
*/
function compareYearGroups(left: string, right: string): number {
const leftNumber = left.match(/\d+/)?.[0];
const rightNumber = right.match(/\d+/)?.[0];
if (leftNumber !== undefined && rightNumber !== undefined) {
const numberDifference = Number(leftNumber) - Number(rightNumber);
if (numberDifference !== 0) return numberDifference;
} else if (leftNumber !== undefined) {
return -1;
} else if (rightNumber !== undefined) {
return 1;
}
return left.localeCompare(right, "ko");
}
/**
* MV 응답의 yearGroup 목록으로 노후관로 차트 데이터를 동적으로 생성합니다.
* 구간명이나 구간 개수가 바뀌어도 고정 매핑 없이 동일한 로직을 사용합니다.
*/
export function buildOldPplChartData(items: PplLengthContent[]): OldPplChartData {
const responseZones = [...new Set(items.map((item) => item.zoneNm).filter(Boolean))];
const labels = [
...DEFAULT_BASIN_ORDER,
...responseZones.filter((zone) => !DEFAULT_BASIN_ORDER.includes(zone)).sort((left, right) => left.localeCompare(right, "ko")),
];
const yearGroups = [
...new Set(
items
.map((item) => item.yearGroup?.trim())
.filter((yearGroup): yearGroup is string => Boolean(yearGroup)),
),
].sort(compareYearGroups);
const colors = buildYearGroupColors(yearGroups.length);
const lengthByZoneAndGroup = new Map<string, number>();
items.forEach((item) => {
const yearGroup = item.yearGroup?.trim();
if (!item.zoneNm || !yearGroup) return;
lengthByZoneAndGroup.set(`${item.zoneNm}\u0000${yearGroup}`, Number(item.sumPrlgVl) || 0);
});
return {
labels,
datasets: yearGroups.map((yearGroup, index) => ({
label: yearGroup,
data: labels.map((zone) => lengthByZoneAndGroup.get(`${zone}\u0000${yearGroup}`) ?? 0),
backgroundColor: colors[index] ?? YEAR_GROUP_START_COLOR,
borderRadius: 4,
})),
};
}
/**
* OldPplChartData를 지도 팝업 데이터 형태인 mapPplOldItem[]으로 변환합니다.
* @param chartData 차트 데이터
@@ -96,22 +176,16 @@ export function transformChartDataToMapData(chartData: OldPplChartData): mapPplO
const mapData: mapPplOldItem[] = [];
const { labels, datasets } = chartData;
const pplLen2025Data = datasets.find(d => d.label === '20-25년')?.data || [];
const pplLen2530Data = datasets.find(d => d.label === '25-30년')?.data || [];
const pplLen30Data = datasets.find(d => d.label === '30년이상')?.data || [];
labels.forEach((label, index) => {
const pplLen2025 = pplLen2025Data[index] || 0;
const pplLen2530 = pplLen2530Data[index] || 0;
const pplLen30 = pplLen30Data[index] || 0;
const totalLen = pplLen2025 + pplLen2530 + pplLen30;
const totalLen = datasets.reduce((sum, dataset) => {
const value = dataset.data[index];
return sum + (typeof value === "number" ? value : 0);
}, 0);
mapData.push({
name: label,
totalLen,
pplLen2025,
pplLen2530,
pplLen30,
stofcbrCount: 0,
});
});
@@ -171,7 +245,7 @@ export class SewerageApiService implements SewerageService {
}));
return list;
}
// 유역별 노후도별 관로 길이(km) MV조회 : yearGroup - 20년~25년미만,25년~30년미만,30년이상
// 유역별 노후도별 관로 길이(km) MV조회: yearGroup은 MV가 제공하는 동적 구간명
public async getMvOldPplLength(): Promise<PplLengthContent[]> {
const response = await client.get<{
PplLengthList: PplLengthContent[];
@@ -184,6 +258,18 @@ export class SewerageApiService implements SewerageService {
return list;
}
// CM 대시보드 유역별 우수토실 개소수 조회
public async getBasinStofcbrCount(): Promise<BasinStofcbrCountContent[]> {
const response = await client.get<{
basinStofcbrCountList: BasinStofcbrCountContent[];
}>(ENDPOINT.SEWERAGE_SERVICE.BASIN_STOFCBR_COUNT);
return (response.data.basinStofcbrCountList ?? []).map((item) => ({
...item,
stofcbrCount: Number(item.stofcbrCount),
}));
}
}
export const SewerageApi = new SewerageApiService();
@@ -12,7 +12,7 @@ import { svcInfo } from "../model/map-data";
import type { mapOperFcltType, mapReuseMapType} from "../model/fo-interface";
import type { mapPplOldType } from "../model/sn-interface";
import { useMapOperFcltStore, OperationApi, useMapReuseMapStore } from "../api/fo-service";
import { useMapOldPplStore } from "../api/sn-service";
import { SewerageApi, useMapOldPplStore } from "../api/sn-service";
import {Button} from "@common/components";
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
@@ -31,12 +31,12 @@ const mapOperFcltData: mapOperFcltType = {
]
};
const mapPplOldData: mapPplOldType = {
colTitle: {totalLen: "총길이",pplLen2025: "20~25년",pplLen2530: "25~30년",pplLen30 : "30년 이상" }, //헤더 영역
colTitle: {stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" },
data: [
{name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30 : 2014},
{name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30 : 10303},
{name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30 : 9831},
{name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30 : 2000},
{name: "금강", totalLen: 0, stofcbrCount: 0},
{name: "한강", totalLen: 0, stofcbrCount: 0},
{name: "낙동강", totalLen: 0, stofcbrCount: 0},
{name: "영산강", totalLen: 0, stofcbrCount: 0},
]
};
const mapReuseMapData: mapReuseMapType = {
@@ -62,17 +62,18 @@ export function KoreaBasinMap() {
const { selectedBasin, setSelectedBasin } = useStore(basinStore);
const selectedBasinKey = basinKeyMap[selectedBasin];
const {mapOperFclt} = useMapOperFcltStore();
const {mapData: mapPplOld} = useMapOldPplStore();
const {mapData: mapPplOld, setStofcbrCounts} = useMapOldPplStore();
const {setMapData: setMapReuseMapData} = useMapReuseMapStore();
mapOperFcltData.data = mapOperFclt;
mapPplOldData.data = mapPplOld;
// mapReuseMapData.data = mapReuseMap;
let standardYear = '2024';
const [standardYear, setStandardYear] = useState('2024');
useEffect(() => {
const fetchData = async () => {
try {
standardYear = await OperationApi.getStandardYear();
const mapReuseMapRslt = await OperationApi.getReuseMap(standardYear);
const fetchedStandardYear = await OperationApi.getStandardYear();
setStandardYear(fetchedStandardYear);
const mapReuseMapRslt = await OperationApi.getReuseMap(fetchedStandardYear);
setMapReuseMapData(mapReuseMapRslt);
mapReuseMapData.data = mapReuseMapRslt;
} catch (e) {
@@ -80,7 +81,13 @@ export function KoreaBasinMap() {
}
};
fetchData();
}, []);
}, [setMapReuseMapData]);
useEffect(() => {
SewerageApi.getBasinStofcbrCount()
.then(setStofcbrCounts)
.catch((e) => console.error("유역별 우수토실 개소수 조회 실패", e));
}, [setStofcbrCounts]);
// 유역정보
const showFor = useCallback((g: SVGGElement) => {
const activeService = services.find(s => s.active);
@@ -104,24 +111,16 @@ export function KoreaBasinMap() {
});
} else if (activeService.svcId === 'sn') { // 하수관로
const item = mapPplOldData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 노후하수관로 현황`;
newPopupContent.title = `${standardYear}년도 ${basinName}유역 하수관로 현황`;
const colTitles = mapPplOldData.colTitle;
newPopupContent.items.push({
header: colTitles.stofcbrCount,
data: item ? `${fmt(item.stofcbrCount)}개소` : '-',
});
newPopupContent.items.push({
header: colTitles.totalLen,
data: item ? `${fmt(item.totalLen)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen2025,
data: item ? `${fmt(item.pplLen2025)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen2530,
data: item ? `${fmt(item.pplLen2530)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen30,
data: item ? `${fmt(item.pplLen30)}km` : '-',
});
} else if (activeService.svcId === 'wr') { // 물재이용
const item = mapReuseMapData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 물재이용 현황`;
@@ -160,7 +159,7 @@ export function KoreaBasinMap() {
);
popupRef.current.style.left = x + "px";
}
}, [services, mapOperFclt]);
}, [services, mapOperFclt, mapPplOld, standardYear]);
// selectedBasinKey prop이 변경될 때 지도를 업데이트하는 useEffect
useEffect(() => {
@@ -410,8 +409,14 @@ export function KoreaBasinMap() {
};
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
return (
<div className="col-span-3 !p-0 relative z-10" style={{ height: "765px", background: "rgba(255, 255, 255, 0.6)" }}>
<div className="card card-border" style={{ height: "100%" }}>
<div
className="absolute inset-0 z-10 col-span-3 h-full !p-0"
style={{ background: "rgba(255, 255, 255, 0.6)" }}
>
<div
className="card card-border"
style={{ height: "calc(100% - 1.2rem)" }}
>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
@@ -6,11 +6,17 @@ export interface PplLengthContent {
yearGroup?: string;
}
export interface BasinStofcbrCountContent {
zoneNm: string;
stofcbrCount: number;
}
export interface SewerageService {
getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
getMvUsgPplLength(): Promise<PplLengthContent[]>;
getMvOldPplLength(): Promise<PplLengthContent[]>;
getBasinStofcbrCount(): Promise<BasinStofcbrCountContent[]>;
}
export interface ChartDataSet {
@@ -37,17 +43,13 @@ type SnOldPplLength = {
export type mapPplOldItem = {
name: string;
totalLen: number;
pplLen2025: number;
pplLen2530: number;
pplLen30: number;
stofcbrCount: number;
};
export type mapPplOldType = {
colTitle: {
totalLen: string; // 총길이
pplLen2025: string; // 20~25년
pplLen2530: string; // 25~30년
pplLen30: string; // 30년 이상
stofcbrCount: string; // 우수토실개소수
totalLen: string; // 총연장
};
data: mapPplOldItem[];
};
@@ -82,4 +84,4 @@ export function buildSnOldPplLength(
});
return result;
}
}
@@ -135,7 +135,7 @@ function DashboardPage() {
<div className="p-2 sm:p-4 md:p-6">
<div className="flex flex-col gap-1">
{/* 첫번째 라인 - 좌측 6개 패널(2x3) + 우측 지도/사용자 정보 */}
<div className="flex flex-col gap-1 lg:flex-row">
<div className="flex flex-col gap-1 lg:flex-row lg:items-stretch">
{/* 좌측: 드래그 가능한 6개 패널 (2x3 그리드, 화면의 50%) */}
<div className="w-full lg:w-1/2">
{isMounted ? (
@@ -173,9 +173,9 @@ function DashboardPage() {
</div>
{/* 우측: 고정된 지도와 사용자 정보 (화면의 50%) */}
<div className="flex w-full gap-1 lg:w-1/2">
<div className="flex w-full flex-col items-stretch gap-1 lg:w-1/2 lg:flex-row">
{/* 지도영역 - 고정 (드래그 불가) */}
<div className="flex-1">
<div className="relative min-h-[765px] w-full self-stretch lg:min-h-0 lg:flex-1">
<KoreaBasinMap />
</div>
+4 -44
View File
@@ -15,8 +15,8 @@ import {
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { Button } from "@common/components";
import type { PplLengthContent, ChartDataSet, OldPplChartData } from "../model/sn-interface";
import { SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
import type { PplLengthContent } from "../model/sn-interface";
import { buildOldPplChartData, SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
import { useDragHandle } from "./sortable-panel";
@@ -41,45 +41,7 @@ export default function SnOldPpl() {
useEffect(() => {
if (oldPplLenData.length === 0) return;
// 유역 목록 추출 (중복 제거)
const allZones = [...new Set(oldPplLenData.map(d => d.zoneNm))];
// yearGroup 매핑: API 응답값 -> 차트 label
const yearGroupMap: { [key: string]: { label: string; color: string } } = {
"20년~25년미만": { label: "20-25년", color: "#3B82F6" },
"25년~30년미만": { label: "25-30년", color: "#EF4444" },
"30년이상": { label: "30년이상", color: "#F87171" },
};
// 유역별, 노후년도별 데이터 구조화
const dataByZone: { [zone: string]: { [yearGroup: string]: number } } = {};
allZones.forEach(zone => {
dataByZone[zone] = {};
});
oldPplLenData.forEach(item => {
let zoneData = dataByZone[item.zoneNm];
if (!zoneData) {
zoneData = {};
dataByZone[item.zoneNm] = zoneData;
}
if (item.yearGroup) {
zoneData[item.yearGroup] = item.sumPrlgVl;
}
});
// 데이터셋 생성
const newDatasets: ChartDataSet[] = Object.entries(yearGroupMap).map(([yearGroup, { label, color }]) => ({
label,
data: allZones.map(zone => dataByZone[zone]?.[yearGroup] ?? 0),
backgroundColor: color,
borderRadius: 4,
}));
const newChartData: OldPplChartData = {
labels: allZones,
datasets: newDatasets,
};
const newChartData = buildOldPplChartData(oldPplLenData);
setChartData(newChartData);
@@ -110,9 +72,7 @@ export default function SnOldPpl() {
y: {
beginAtZero: true,
min: 0,
max: 10000,
ticks: {
stepSize: 2000,
callback: function (value) {
return Number(value).toLocaleString() + 'km';
}
@@ -192,4 +152,4 @@ export default function SnOldPpl() {
</div>
</div>
);
}
}
+1
View File
@@ -80,6 +80,7 @@ export const ENDPOINT = {
OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts",
MV_USG_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvUsgPplLength",
MV_OLD_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvOldPplLength",
BASIN_STOFCBR_COUNT: "/sewerage-service/api/v1/ms/basinStofcbrCount",
},
OPERATION_SERVICE: {
MAIN_YEAR: "/operation-service/api/v1/dashBoard/main/year",
+13
View File
@@ -1,6 +1,19 @@
## Project
This repository contains a Next.js web application.
## Portable paths
Resolve paths from this `apps/dm/AGENTS.md` file, not from a developer-specific drive or the current shell directory.
- `FE_APP_ROOT`: directory containing this file (`apps/dm`)
- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`)
- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..`
- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md`
- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root)
- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service`
Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it.
Main technologies:
- Next.js
- TypeScript
+13
View File
@@ -1,6 +1,19 @@
## Project
This repository contains a Next.js web application.
## Portable paths
Resolve paths from this `apps/fo/AGENTS.md` file, not from a developer-specific drive or the current shell directory.
- `FE_APP_ROOT`: directory containing this file (`apps/fo`)
- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`)
- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..`
- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md`
- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root)
- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service`
Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it.
Main technologies:
- Next.js
- TypeScript
+1 -1
View File
@@ -11,7 +11,7 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
#NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y}
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
SERVER_API_URL = http://dev.onasys.co.kr:8000
SERVER_API_URL = https://apigateway.onasys.co.kr
SEWERAGE_API_URL = http://localhost:8883
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
DC_URL = https://apigateway.apps.rsonesoft.com
+1 -1
View File
@@ -6,7 +6,7 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
SERVER_API_URL = http://dev.onasys.co.kr:8000
SERVER_API_URL = https://apigateway.onasys.co.kr
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
+38 -3
View File
@@ -43,14 +43,46 @@
## 공통 시작 기준
Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 기준은 `AGENTS.md`를 따른다.
개발자마다 드라이브와 상위 작업 폴더 위치가 다르므로 특정 드라이브·사용자 디렉터리의 절대경로를 기준으로 사용하지 않는다.
경로 별칭은 `apps/sn/AGENTS.md`가 있는 디렉터리(`FE_APP_ROOT`)를 기준으로 다음과 같이 해석한다. 상대경로는 현재 셸의 실행 위치가 아니라 이 파일의 위치를 기준으로 계산한다.
- `FE_APP_ROOT`: 이 파일이 있는 `apps/sn` 디렉터리
- `FE_REPO_ROOT`: `FE_APP_ROOT/../..`
- `WORKSPACE_ROOT`: `FE_APP_ROOT/../../..`
- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md`
- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend Git 저장소 루트)
- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` (하수관로 백엔드 서비스 모듈)
- `COMMON_DOCS_ROOT`: `BE_REPO_ROOT/docs`
- `FE_APP_DOCS_ROOT`: `FE_APP_ROOT/docs`
경로 결정은 아래 우선순위를 따른다.
1. 작업 환경이나 사용자가 `WORKSPACE_ROOT`, `COMMON_DOCS_ROOT`, `BE_REPO_ROOT`, `BE_SERVICE_ROOT` 등의 유효한 경로를 명시한 경우 그 값을 우선한다. 저장소를 서로 다른 상위 폴더에 배치한 개발자도 이 방식으로 사용할 수 있다.
2. 명시값이 없으면 위의 `FE_APP_ROOT` 기준 상대경로로 계산한다. 드라이브 문자나 사용자별 상위 디렉터리 이름이 달라도 같은 저장소 배치 구조를 사용할 수 있다.
3. 계산한 경로는 사용 전에 대상 파일이나 디렉터리의 존재 여부를 확인한다. 없거나 후보가 여러 개인 경우 특정 절대경로를 추측하지 말고 현재 저장소에서 확인 가능한 작업만 진행한 뒤 필요한 경로를 사용자에게 확인한다.
4. 작업 문서와 결과 보고에는 특별한 사유가 없으면 개발자별 절대경로 대신 위 경로 별칭이나 저장소 기준 상대경로를 기록한다.
PowerShell에서 현재 프론트엔드 저장소를 기준으로 확인할 때는 다음처럼 동적으로 계산할 수 있다. 각 단계는 앞 단계에서 찾은 저장소 위치를 기반으로 하므로 개발자별 설치 경로에 의존하지 않는다.
```powershell
$feRepoRoot = (git rev-parse --show-toplevel).Trim()
$feAppRoot = Join-Path $feRepoRoot 'apps/sn'
$workspaceRoot = Split-Path $feRepoRoot -Parent
$commonRootAgents = Join-Path $workspaceRoot 'AGENTS.md'
$beRepoRoot = Join-Path $workspaceRoot 'IIPBS'
$beServiceRoot = Join-Path $beRepoRoot 'sewerage-service'
$commonDocsRoot = Join-Path $beRepoRoot 'docs'
```
공통 시작 기준은 `COMMON_ROOT_AGENTS`를 따른다.
이 파일은 프론트엔드 `sn` 앱 세부 기준이다.
백엔드(`sewerage-service`)와 함께 수정하거나 API/응답/DB/인증/권한 연동 영향이 있는 작업은 아래 파일을 함께 확인한다.
- 공통 시작 기준: `AGENTS.md`
- 백엔드 세부 기준: `BE_REPO_ROOT/AGENTS.md`
- 백엔드 세부 기준: `BE_SERVICE_ROOT/AGENTS.md`
- 프론트엔드 `sn` 앱 기준: `FE_APP_ROOT/AGENTS.md`
---
@@ -134,6 +166,9 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
## 작업 원칙
- `sn` 프론트엔드 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `src/shared/util/download-excel-workbook.ts` 재사용을 먼저 검토한다. 세부 기준은 [컴포넌트정책의 공통 유틸 재사용 기준](docs/기준문서/004-컴포넌트정책.md#공통-유틸-재사용-기준)을 따른다. 이 코드 경로는 `FE_APP_ROOT` 기준이다.
- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다.
3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다.
@@ -228,7 +263,7 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne
### 프론트엔드 실행 프로세스 관리
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 임시 파일·결과 파일 정리 기준을 적용한다. 일회성 검증 스크립트·캡처·Network/서버 로그·원시 결과 파일은 최종 보고서에 요약 후 삭제한다. 정식 회귀 테스트·최종 산출물·사용자 원본/복구 백업은 유지한다. 사용자 dev server가 쓰는 .next·공유 캐시·node_modules는 임시 검증물이라는 이유로 포괄 삭제하지 않는다.
- 모든 세션·역할·문서/검증 섹션에 `COMMON_ROOT_AGENTS`의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다.
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
+1 -1
View File
@@ -19,7 +19,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { NoFooterLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import useStore from '../store/useStore';
import useStore from "@/entities/reference-data/model/reference-data-store";
import { authService } from "@/entities/auth";
export default function MapLayout({
@@ -2,13 +2,13 @@
"use client";
import * as XLSX from "xlsx";
import { getRawJson } from "@/components/lib/api";
import { getRawJson } from "@/shared/api/sewerage-api";
import {
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvResponse,
} from "./agedPipeStatusByCtpvUtils";
} from "./aged-pipe-status-by-ctpv-utils";
/** 적용조건의 시도별 노후관로현황 전체를 조회해 연도 그룹 Excel로 생성한다. */
export const downloadAgedPipeStatusByCtpvExcel = async (
@@ -4,10 +4,10 @@
*/
"use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import {
getStandardRowNumber,
StandardDataTable,
StandardPopupLayout,
} from "@common/business_components";
@@ -18,10 +18,10 @@ import {
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { getRawJson } from "@/shared/api/sewerage-api";
import LoadingOverlay from "@/components/ui/loading-bar";
import { useAlertStore } from "@/shared/lib";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import {
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
buildAgedPipeStatusYearOptions,
@@ -32,9 +32,9 @@ import {
type AgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvResponse,
type AgedPipeStatusByCtpvRow,
} from "./agedPipeStatusByCtpvUtils";
import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
} from "./aged-pipe-status-by-ctpv-utils";
import { downloadAgedPipeStatusByCtpvExcel } from "./aged-pipe-status-by-ctpv-export";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
// CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다.
const AGED_PIPE_STATUS_PAGE_SIZE = 10;
@@ -71,7 +71,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
id: "rowNumber",
header: "순번",
cell: ({ row }) =>
getStandardRowNumber({
getSnRowNumber({
total: rows.length,
rowIndex: row.index,
}),
@@ -66,7 +66,8 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
filters: Partial<AgedPipeStatusByCtpvFilters>,
): AgedPipeStatusByCtpvFilters => {
const currentYear = new Date().getFullYear();
let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2));
// 최초 진입·초기화는 당해연도 1년 조회로 시작하며 직접 선택한 범위는 유지한다.
let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
let elapsedYears = Math.trunc(
Number(
@@ -74,7 +75,7 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
),
);
if (!Number.isFinite(startYear)) startYear = currentYear - 2;
if (!Number.isFinite(startYear)) startYear = currentYear;
if (!Number.isFinite(endYear)) endYear = currentYear;
if (
!Number.isFinite(elapsedYears) ||
@@ -1,13 +1,9 @@
/** 노후관로 목록의 엑셀 다운로드 데이터와 서식을 생성한다. */
"use client";
import { postBlobData } from "@/components/lib/api";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import {
AGED_PIPE_EXCEL_FIELDS,
normalizeAgedPipeFilters,
type AgedPipeFilters,
} from "./agedPipeUtils";
import { postBlobData } from "@/shared/api/file-api";
import { fileDownload } from "@/components/lib/utils/file-download";
import { AGED_PIPE_EXCEL_FIELDS, normalizeAgedPipeFilters, type AgedPipeFilters } from "@/entities/facility/lib/aged-pipe-utils";
type AgedPipeExportProgress = {
fileName?: string;
@@ -6,8 +6,8 @@
import { useEffect, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
import { validateElapsedYears, type AgedPipeFilters } from "@/entities/facility/lib/aged-pipe-utils";
import LayerSettingCard from "../layer-setting/layer-setting-card";
type MaybePromise<T = void> = T | Promise<T>;
@@ -0,0 +1,71 @@
/** 노후관로의 지도와 목록이 사용하는 DB 레이어 설정 계약이다. */
import { getRawJson } from "@/shared/api/sewerage-api";
import {
buildAgedPipeCqlFilters,
isAgedPipeLayerNode,
type AgedPipeFilters,
} from "@/entities/facility/lib/aged-pipe-utils";
/** DB 응답을 그대로 보존하며 최초 표출 전에 필터만 해석한다. */
export const prepareAgedPipeLayerRows = (
rows: any[],
filters: AgedPipeFilters,
): any[] =>
rows.map((row) => {
const next = { ...row };
if (isAgedPipeLayerNode(row)) {
next.agedPipeFilterTemplate = row.flterSql;
try {
validateAgedPipeLayer(row);
next.flterSql = buildAgedPipeCqlFilters(filters, row.flterSql).join(
" AND ",
);
} catch (error) {
// 노후관로 DB 미전환이 다른 시설물 레이어까지 중단시키지 않도록 이 행만 차단한다.
next.agedPipeConfigError =
error instanceof Error ? error.message : "노후관로 레이어 설정 오류";
next.actlLyrYn = "N";
next.flterSql = "EXCLUDE";
}
}
if (Array.isArray(row.children))
next.children = prepareAgedPipeLayerRows(row.children, filters);
return next;
});
/** 환경마다 달라질 수 있는 일련번호 대신 DB 업무명을 통해 설정 행을 찾는다. */
export const findAgedPipeLayer = (rows: any[]): any | undefined => {
for (const row of rows) {
if (isAgedPipeLayerNode(row)) return row;
const child = Array.isArray(row.children)
? findAgedPipeLayer(row.children)
: undefined;
if (child) return child;
}
};
/** 예전 제어용 행을 실제 WFS 레이어처럼 요청하지 않도록 전환 상태를 확인한다. */
export const validateAgedPipeLayer = (row: any) => {
if (row?.agedPipeConfigError) throw new Error(row.agedPipeConfigError);
if (
!row ||
row.actlLyrYn !== "Y" ||
row.srvcNm !== "wfs" ||
!row.lyrTyNm ||
row.lyrTyNm === "tb_old_pipe"
) {
throw new Error(
"노후관로 DB 레이어 설정을 실제 WFS 레이어와 sql_filt 템플릿으로 갱신해 주세요.",
);
}
};
/** 독립 목록 팝업도 부모창의 하드코딩 없이 동일 설정 API를 조회한다. */
export const fetchAgedPipeLayer = async () => {
const rows = await getRawJson(
"/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK",
);
const row = findAgedPipeLayer(Array.isArray(rows) ? rows : []);
validateAgedPipeLayer(row);
return row;
};
@@ -3,36 +3,40 @@
* 노후관로를 검색하고 목록·엑셀 다운로드·지도 위치 확인 기능을 제공한다.
*/
"use client";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getData } from "@/components/lib/api";
import { getData } from "@/shared/api/sewerage-api";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/entities/reference-data/model/reference-data-store";
import LoadingOverlay from "@/components/ui/loading-bar";
import {
AGED_PIPE_DEFAULT_FILTERS,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_LIST_HEADLIST,
AGED_PIPE_PAGE_SIZE,
buildAgedPipeCqlFilters,
getAgedPipeManagementName,
normalizeAgedPipeFilters,
toDisplayPipeRow,
validateElapsedYears,
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
import { downloadAgedPipeExcel } from "./agedPipeExport";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
type AgedPipeManagementAgency,
} from "@/entities/facility/lib/aged-pipe-utils";
import { fetchAgedPipeLayer } from "./aged-pipe-layer";
import { downloadAgedPipeExcel } from "./aged-pipe-export";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
export default function AgedPipePopupPage() {
const facilityCodes = useFacilityCodeCatalog();
const alert = useAlertStore((state) => state.setMessage);
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
@@ -53,6 +57,18 @@ export default function AgedPipePopupPage() {
const [appliedFilters, setAppliedFilters] =
useState<AgedPipeFilters>(initialFilters);
const [rows, setRows] = useState<AgedPipeRow[]>([]);
// 관리기관명 조회가 늦게 완료되어도 기존 목록을 재조회하지 않고 표시만 갱신한다.
const [managementAgencies, setManagementAgencies] = useState<
AgedPipeManagementAgency[]
>([]);
const displayRows = useMemo(
() =>
rows.map((row) => ({
...row,
mngInstNm: getAgedPipeManagementName(row, managementAgencies),
})),
[rows, managementAgencies],
);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
@@ -73,15 +89,34 @@ export default function AgedPipePopupPage() {
if (sgg.length === 0) fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
useEffect(() => {
let active = true;
// 기존 GIS 관리 화면과 같은 기관 목록을 사용하고, 실패 시 관로 조회는 유지한다.
getData("v1/fcltPpl/mngInsts")
.then((response) => {
if (active)
setManagementAgencies(Array.isArray(response) ? response : []);
})
.catch(() => {
if (active)
alert("관리기관명을 불러오지 못해 관리기관 코드로 표시합니다.");
});
return () => {
active = false;
};
}, [alert]);
const fetchRows = useCallback(
async (page: number, nextFilters: AgedPipeFilters, size: number) => {
const normalized = normalizeAgedPipeFilters(nextFilters);
// 독립 팝업에서도 현재 DB의 원천 레이어와 필터 템플릿을 사용한다.
const layer = await fetchAgedPipeLayer();
const queryParams = new URLSearchParams({
lyrTypeNm: AGED_PIPE_LAYER_TYPE,
lyrTypeNm: layer.lyrTyNm,
page: String(page - 1),
size: String(size),
});
const cqlFilters = buildAgedPipeCqlFilters(normalized);
const cqlFilters = buildAgedPipeCqlFilters(normalized, layer.flterSql);
if (cqlFilters.length > 0) {
queryParams.append("cqlFiter", cqlFilters.join(" and "));
}
@@ -92,12 +127,12 @@ export default function AgedPipePopupPage() {
const total = Number(response?.count ?? 0);
const rows = responseRows.map(
(item: Record<string, unknown>, index: number) =>
toDisplayPipeRow(item, index, page, ctpv, sgg),
toDisplayPipeRow(item, index, page, ctpv, sgg, facilityCodes),
);
return { rows, total };
},
[ctpv, sgg],
[ctpv, sgg, facilityCodes],
);
const loadRows = useCallback(
@@ -115,13 +150,18 @@ export default function AgedPipePopupPage() {
setAppliedFilters(normalizeAgedPipeFilters(nextFilters));
} catch (error) {
console.error("노후관로 목록 조회 중 오류:", error);
alert(
error instanceof Error
? error.message
: "노후관로 목록 조회 중 오류가 발생했습니다.",
);
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
[alert, fetchRows, filters],
);
useEffect(() => {
@@ -247,31 +287,6 @@ export default function AgedPipePopupPage() {
) : null}
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
경과년도
</span>
<Input
className="w-[12rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={String(filters.elapsedYears ?? "")}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setFilters({
...filters,
elapsedYears:
digits === "" ? ("" as unknown as number) : Number(digits),
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시도
@@ -316,6 +331,31 @@ export default function AgedPipePopupPage() {
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
경과년도
</span>
<Input
className="w-[12rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={String(filters.elapsedYears ?? "")}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setFilters({
...filters,
elapsedYears:
digits === "" ? ("" as unknown as number) : Number(digits),
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
@@ -341,7 +381,7 @@ export default function AgedPipePopupPage() {
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<AgedPipeRow & Record<string, unknown>>}
rows={displayRows}
headlist={AGED_PIPE_LIST_HEADLIST}
title="노후관로 목록"
total={total}
@@ -10,26 +10,22 @@ import {
ChartColumnBig,
ChevronUp,
PanelsTopLeft,
PencilRuler,
Search,
Settings2,
TriangleAlert,
Waves,
} from "lucide-react";
import { createPredictionAnalysisTimeRange } from "@/features/fldngPredcPrst/model/analysisTimeRange";
import { createPredictionAnalysisTimeRange } from "@/features/fldng-predc-prst/model/analysis-time-range";
import {
windowPopupDefinitions,
type WindowPopupKey,
} from "@/features/windowPopup/model/popupDefinitions";
} from "@/features/window-popup/model/popup-definitions";
import { getRawJson } from "@/components/lib/api";
import { getRawJson } from "@/shared/api/sewerage-api";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
onOpenInfoSearch: () => void;
onToggleGisEdit?: () => void;
isGisEditActive?: boolean;
isInfoSearchActive?: boolean;
initialActiveKey?: WindowPopupKey | null;
};
@@ -89,16 +85,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title,
icon: ChartColumnBig,
},
{
key: "gisEditPopup",
label: windowPopupDefinitions.gisEditPopup.title,
icon: PencilRuler,
},
{
key: "gisManagementPopup",
label: windowPopupDefinitions.gisManagementPopup.title,
icon: Settings2,
},
{
key: "flowRiskPopup",
label: windowPopupDefinitions.flowRiskPopup.title,
@@ -119,8 +105,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
export default function AnalysisPopupMenu({
onOpenPopup,
onOpenInfoSearch,
onToggleGisEdit,
isGisEditActive = false,
isInfoSearchActive = false,
initialActiveKey = null,
}: Props) {
@@ -155,12 +139,6 @@ export default function AnalysisPopupMenu({
};
}, []);
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
setActiveKey(null);
}
}, [activeKey, isGisEditActive]);
useEffect(() => {
if (!isInfoSearchActive && activeKey === "infoSearch") {
setActiveKey(null);
@@ -214,10 +192,7 @@ export default function AnalysisPopupMenu({
<div className="flex flex-col gap-[3px] p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive =
item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
const isActive = activeKey === item.key;
const label =
item.key === "fldngPredcPrst"
? `침수예측현황(${seriousPredictionCount}건)`
@@ -233,12 +208,6 @@ export default function AnalysisPopupMenu({
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
setActiveKey(item.key);
if (item.key === "infoSearch") {
@@ -1,72 +0,0 @@
/** 선택한 지도 레이어 종류에 맞는 시설 상세 컴포넌트를 반환한다. */
import MsSwagePpl from "../popupComponent/MsSwagePpl"; // 하수관로
import sewagePump from "../popupComponent/sewagePump"; // 하수펌프
import smh from "../popupComponent/smh";
import Ssc from "../popupComponent/Ssc";
import StormWaterPump from "../popupComponent/stormWaterPump";
import SwtrmtFclt from "../popupComponent/SwtrmtFclt";
import SwtrmtPrcsZoneAnalysis from "../popupComponent/SwtrmtPrcsZoneAnalysis";
type LayerComponentProps = {
featureData: any;
isMainSsc?: boolean;
isDrtwSewagePump?: boolean;
rainMeasureData?: any;
onSaveClick?: () => void;
onFormDataChange?: (data: any) => void; // 추가
chmbrXcnts: string;
chmbrYdnts: string;
gisObject?: Record<string, any>; // GIS 객체 추가
rawFeatureData?: any;
surveyResultContent?: React.ReactNode;
};
type LayerContentMap = {
[key: string]: {
component?: React.ComponentType<any>;
props?: any;
};
};
// 레이어 이름과 컴포넌트를 매핑
// 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업
const layerContentMap: LayerContentMap = {
"하수처리시설": { component: SwtrmtFclt, }, // 디지털 자료관 연계정보가 없으니 일단 주석 처리
"하수처리구역": { component: SwtrmtPrcsZoneAnalysis },
"우수토실": { component: Ssc, },
"중점우수토실": { component: Ssc, }, // 중점우수토실 여부 설정
"하수관로": { component: MsSwagePpl, },
"하수맨홀": { component: smh, },
"하수펌프장": { component: sewagePump },
// "빗물펌프": { component: StormWaterPump },
// "오수펌프": { component: sewagePump },
};
// 동적 컴포넌트 반환 함수
export const getLayerContentComponent = (layerNm: string): React.FC<LayerComponentProps> | null => {
const component = layerContentMap[layerNm];
if (!component || !component.component) {
// component가 없으면 null 반환
return () => null;
}
return component.component as React.FC<LayerComponentProps>
};
// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단)
export const handleInputChange = (section: string,
index: number,
field: string,
value: string,
dataMap: {
[key: string]: {
data: { year: string; type: string, bizInfoNm: string, bizType: string }[];
setData: React.Dispatch<React.SetStateAction<{ year: string; type: string, bizInfoNm: string, bizType: string }[]>>;
};
}
) => {
const target = dataMap[section];
if (!target) return;
const updated = [...target.data];
if (updated[index]) {
updated[index][field as keyof typeof updated[number]] = value;
}
target.setData(updated);
};
@@ -1,924 +0,0 @@
/** 지도 시설 상세 및 목록에서 사용하는 컬럼·코드 정의를 제공한다. */
// 하수관로
export const MsSwagePplCol = [
{ id: 'fclt_mng_no', title: '시설관리번호' },
{ id: 'mngInstNm', title: '관리기관' },
{ id: 'inst_mng_no', title: '기관관리번호' },
{ id: 'flowDirectionBasis', title: '유하방향 근거' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'elapsedYears', title: '경과년도' },
{ id: 'mapsht_no', title: '도엽번호' },
{ id: 'pdmt_vl', title: '관경(mm)' },
{ id: 'prlg_vl', title: '연장(m)' },
{ id: 'usg_cd', title: '용도' },
{ id: 'mqlt_cd', title: '재질' },
{ id: 'avg_grdn', title: '평균구배(m/m)' },
{ id: 'drtw_mltv_area', title: '오수배수면적(m²)' },
{ id: 'excln_mltv_area', title: '우수배수면적(m²)' },
{ id: 'tmpt_dpth', title: '시점깊이(m)' },
{ id: 'tmpt_mhl_mng_no', title: '시점맨홀관리번호' },
{ id: 'scend_dpth', title: '종점깊이(m)' },
{ id: 'scend_mhl_mng_no', title: '종점맨홀관리번호' },
{ id: 'trfln_cnt', title: '차선수' },
{ id: 'vrtc_len', title: '세로길이(m)' },
{ id: 'wdth_len', title: '가로길이(m)' },
// { id: 'rnyssn_fvlc', title: '우기 시 유속(m/s)' },
// { id: 'clar_fvlc', title: '건기 시 유속(m/s)' },
// { id: 'mng_inst_cd', title: '관리기관코드' },
// { id: 'pbadms_emd_cd', title: '행정읍면동코드' },
// { id: 'fclty_shp_cd', title: '시설물형태코드' },
// { id: 'fid', title: 'FID' },
// { id: 'ftre_smbol_cd', title: '지형지물부호코드' },
// { id: 'lyrTypeNm', title: '레이어타입명' },
]
// 하수관로 searchModal 기본정보tab
export const MsSwagePplCol2 = [
{ id: 'rowNumber', title: '순번' },
{ id: 'fcltMngNo', title: '시설\n관리번호' },
{ id: 'instMngNo', title: '기관\n관리번호' },
{ id: 'flowDirectionBasis', title: '유하방향\n근거' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
{ id: "swtrmt_zone_nm", title: "처리구역" },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'mapshtNo', title: '도엽번호' },
{ id: 'usgCd', title: '용도' },
{ id: 'pdmtVl', title: '관경\n(mm)' },
{ id: 'prlgVl', title: '연장\n(m)' },
// { id: 'avgGrdn', title: '평균구배\n(m/m)' },
{ id: 'tmptDpth', title: '시점깊이\n(m)' },
{ id: 'tmptMhlMngNo', title: '시점맨홀\n관리번호' },
{ id: 'scendDpth', title: '종점깊이\n(m)' },
{ id: 'scendMhlMngNo', title: '종점맨홀\n관리번호' },
{ id: 'vrtcLen', title: '세로길이\n(m)' },
{ id: 'wdthLen', title: '가로길이\n(m)' },
{ id: 'fcltyShpCd', title: '시설물형태코드' },
// { id: 'fid', title: 'FID' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'mqltCd', title: '재질코드' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
// { id: 'usgCd', title: '용도코드' },
// { id: 'lyrTypeNm', title: '레이어타입명' },
]
export const MsSwagePplExcelCol = {
'fcltMngNo': '시설 관리번호',
'instMngNo': '기관 관리번호',
'ctpvNm': '시도',
'sggNm': '시군구',
'swtrmt_zone_nm': '처리구역',
'instlYmd': '설치일자',
'usgCd': '용도',
'pdmtVl': '관경(mm)',
'prlgVl': '연장(m)',
'tmptDpth': '시점 깊이(m)',
'tmptMhlMngNo': '시점맨홀 관리번호',
'scendDpth': '종점 깊이(m)',
'scendMhlMngNo': '종점맨홀 관리번호',
'vrtcLen': '세로길이(m)',
'wdthLen': '가로길이(m)',
'fcltyShpCd': '시설물형태코드',
'mqltCd': '재질코드',
};
// 하수관로 용도 코드표
export const MS_SWAGE_PPL_USE_CODES = [
{ val: "SBA000", text: "미분류" },
{ val: "SBA001", text: "합류관" },
{ val: "SBA002", text: "차집관" },
{ val: "SBA003", text: "우수관" },
{ val: "SBA004", text: "오수관" },
{ val: "SBA999", text: "기타" },
];
// 하수관로 재질 코드표
export const MS_SWAGE_PPL_CODES = [
{ val: "MOP000", text: "미분류" },
{ val: "MOP001", text: "닥타일주철관(DCIP)" },
{ val: "MOP002", text: "강관(SP)" },
{ val: "MOP003", text: "아연도강관(GS)" },
{ val: "MOP004", text: "염화비닐관(PVC)" },
{ val: "MOP005", text: "폴리에틸렌관(PE)" },
{ val: "MOP006", text: "스테인레스관(SSP)" },
{ val: "MOP007", text: "동관(COP)" },
{ val: "MOP008", text: "P.S.콘크리트관(PC)" },
{ val: "MOP009", text: "석면시멘트관(AS)" },
{ val: "MOP010", text: "회주철관(CIP)" },
{ val: "MOP011", text: "연관(LP)" },
{ val: "MOP012", text: "흉관(HP)" },
{ val: "MOP013", text: "철근콘크리트관(RC)" },
{ val: "MOP014", text: "강관(ST)" },
{ val: "MOP015", text: "주철관(CI)" },
{ val: "MOP016", text: "화학제품류관(PV)" },
{ val: "MOP017", text: "도관(VC)" },
{ val: "MOP018", text: "석축(SE)" },
{ val: "MOP019", text: "도복장 강관(SP)" },
{ val: "MOP020", text: "흉관(EP)" },
{ val: "MOP021", text: "아연도강관(GP)" },
{ val: "MOP022", text: "청동관(BR)" },
{ val: "MOP023", text: "고무몰딩(RM)" },
{ val: "MOP024", text: "주강(CS)" },
{ val: "MOP025", text: "VR관" },
{ val: "MOP026", text: "HDPE관" },
{ val: "MOP027", text: "시멘트라이닝주철관(C)" },
{ val: "MOP028", text: "수도용 PE관(PE)" },
{ val: "MOP029", text: "석면시멘트관(AC)" },
{ val: "MOP030", text: "폴리에틸렌분체라이닝강관(PEP)" },
{ val: "MOP031", text: "에폭시코팅강관(EPS)" },
{ val: "MOP032", text: "HI-3P" },
{ val: "MOP033", text: "철근콘크리트박스(RCB)" },
{ val: "MOP034", text: "콘크리트중선함(CD)" },
{ val: "MOP035", text: "후랙시불전선함(FP)" },
{ val: "MOP036", text: "전선함(FD)" },
{ val: "MOP038", text: "이중보온관" },
{ val: "MOP039", text: "폴리에틸렌피복강관(PLP)" },
{ val: "MOP041", text: "FRP" },
{ val: "MOP042", text: "강재" },
{ val: "MOP999", text: "기타" },
{ val: "MOP800", text: "2중벽PE관" },
{ val: "MOP801", text: "3중벽PE관" },
{ val: "MOP802", text: "가스배관용 강관" },
{ val: "MOP803", text: "강관(피복 Double, 용접접합)" },
{ val: "MOP804", text: "강관(피복 Single, 용접접합)" },
{ val: "MOP805", text: "강관(CP)" },
{ val: "MOP806", text: "강철" },
{ val: "MOP807", text: "강화플라스틱복합관" },
{ val: "MOP808", text: "경질염화비닐관(HI-VP)" },
{ val: "MOP809", text: "고강성PVC이중벽관" },
{ val: "MOP810", text: "고광도PVC이중벽관" },
{ val: "MOP811", text: "고밀도폴리에틸렌관(THP)" },
{ val: "MOP812", text: "고밀도폴리에틸렌파상형이중벽관" },
{ val: "MOP813", text: "공장제 박스관거" },
{ val: "MOP814", text: "관로없음(알케이블)" },
{ val: "MOP815", text: "나선형강관" },
{ val: "MOP816", text: "내충격수도관" },
{ val: "MOP817", text: "내충격용하수도용경질염화비닐관(HI-VG)" },
{ val: "MOP818", text: "내충격HI-VG2관" },
{ val: "MOP819", text: "내충격PVC하수관" },
{ val: "MOP820", text: "녹석(돌산호과 일종)" },
{ val: "MOP821", text: "동관(구리관, 용접)" },
{ val: "MOP822", text: "둥근잔돌" },
{ val: "MOP823", text: "무근콘크리트" },
{ val: "MOP824", text: "백관" },
{ val: "MOP825", text: "복합철판파형관" },
{ val: "MOP826", text: "분체라이닝관" },
{ val: "MOP827", text: "석축박스" },
{ val: "MOP828", text: "석축철콘" },
{ val: "MOP829", text: "수도용경질염화비관" },
{ val: "MOP830", text: "수도용경질염화비닐이음관" },
{ val: "MOP831", text: "수도용엑포시수지분체내외면코팅강관" },
{ val: "MOP832", text: "수지파형강관(PCS)" },
{ val: "MOP833", text: "수지CSP" },
{ val: "MOP834", text: "스테인레스 주름관" },
{ val: "MOP835", text: "스테인레스관(STS)" },
{ val: "MOP836", text: "스테인레스스틸파이프관" },
{ val: "MOP837", text: "아스콘" },
{ val: "MOP838", text: "아스팔트" },
{ val: "MOP839", text: "아스팔트도복장강관(ASP)" },
{ val: "MOP840", text: "아연" },
{ val: "MOP841", text: "아연도금(백관)" },
{ val: "MOP842", text: "알루미늄" },
{ val: "MOP843", text: "알루미늄합금" },
{ val: "MOP844", text: "압력배관용 탄소강관" },
{ val: "MOP845", text: "양철관" },
{ val: "MOP846", text: "양철주름관(CSP)" },
{ val: "MOP847", text: "연합인슈강판" },
{ val: "MOP848", text: "와사관(GSP)" },
{ val: "MOP849", text: "원심력철근콘크리트" },
{ val: "MOP850", text: "유리섬유관(GRP)" },
{ val: "MOP851", text: "유리섬유복합관(GRP)" },
{ val: "MOP852", text: "이음매없는동및동합금관" },
{ val: "MOP853", text: "일반배관용스테인리스강관" },
{ val: "MOP854", text: "전압 콘크리트관" },
{ val: "MOP855", text: "주름관" },
{ val: "MOP856", text: "주름주철파형강관" },
{ val: "MOP857", text: "지중 저압케이블 입상관" },
{ val: "MOP858", text: "진동및전압철근콘크리트관" },
{ val: "MOP859", text: "철근콘크리트" },
{ val: "MOP860", text: "철근콘크리트 블록" },
{ val: "MOP861", text: "철근콘크리트조립흙막이" },
{ val: "MOP862", text: "철판매입관" },
{ val: "MOP863", text: "청동" },
{ val: "MOP864", text: "코아식프리스트레스트콘크리트관" },
{ val: "MOP865", text: "콘크리트" },
{ val: "MOP866", text: "콘크리트 폴륨관" },
{ val: "MOP867", text: "콘크리트블록" },
{ val: "MOP868", text: "탄소강" },
{ val: "MOP869", text: "탄소강(카본스틸)" },
{ val: "MOP870", text: "탄소주강" },
{ val: "MOP871", text: "트러후" },
{ val: "MOP872", text: "파선형강관(RCF)" },
{ val: "MOP873", text: "파형강관(CSP)" },
{ val: "MOP874", text: "파형관" },
{ val: "MOP875", text: "파형플라스틱관" },
{ val: "MOP876", text: "폴리머관" },
{ val: "MOP877", text: "폴리에스테르수지콘크리트관" },
{ val: "MOP878", text: "폴리에틸렌 전선관" },
{ val: "MOP879", text: "폴리에틸렌 피복강관" },
{ val: "MOP880", text: "폴리에틸렌관 1호관" },
{ val: "MOP881", text: "폴리에틸렌관 2호관" },
{ val: "MOP882", text: "폴리에틸렌관(2중벽)" },
{ val: "MOP883", text: "폴리에틸렌관(3중벽)" },
{ val: "MOP884", text: "폴리에틸렌분체라이닝" },
{ val: "MOP885", text: "폴리에틸렌이중벽하수관" },
{ val: "MOP886", text: "폴리탄산에스테르" },
{ val: "MOP887", text: "프리스트레스트콘크리트실린더관" },
{ val: "MOP888", text: "플라스틱" },
{ val: "MOP889", text: "플라스틱주름관" },
{ val: "MOP890", text: "하수도용고강성경질염화비닐이중벽주름관" },
{ val: "MOP891", text: "현장타설관" },
{ val: "MOP892", text: "현장타설철근콘크리트관" },
{ val: "MOP893", text: "흑관" },
{ val: "MOP894", text: "흙" },
{ val: "MOP895", text: "AP" },
{ val: "MOP896", text: "API관" },
{ val: "MOP897", text: "ASP" },
{ val: "MOP898", text: "CMCIP" },
{ val: "MOP899", text: "CSP" },
{ val: "MOP900", text: "DRP" },
{ val: "MOP901", text: "DSP" },
{ val: "MOP902", text: "DTC관" },
{ val: "MOP903", text: "ELP" },
{ val: "MOP904", text: "EPP" },
{ val: "MOP905", text: "FC관" },
{ val: "MOP906", text: "H" },
{ val: "MOP907", text: "H6" },
{ val: "MOP908", text: "HCP" },
{ val: "MOP909", text: "HI_2P관" },
{ val: "MOP910", text: "HI-KP" },
{ val: "MOP911", text: "HL" },
{ val: "MOP912", text: "IP" },
{ val: "MOP913", text: "KCGP" },
{ val: "MOP914", text: "KP관" },
{ val: "MOP915", text: "P2S관" },
{ val: "MOP916", text: "PC박스(PCB)" },
{ val: "MOP917", text: "PCS" },
{ val: "MOP918", text: "PDP" },
{ val: "MOP919", text: "PE이중벽관" },
{ val: "MOP920", text: "PE적측벽1종관" },
{ val: "MOP921", text: "PFP관" },
{ val: "MOP922", text: "PG관" },
{ val: "MOP923", text: "PIP관(이중보온관)" },
{ val: "MOP924", text: "PLCSP" },
{ val: "MOP925", text: "PS" },
{ val: "MOP926", text: "PUB" },
{ val: "MOP927", text: "PVB" },
{ val: "MOP928", text: "PVC이중벽관(PVC2)" },
{ val: "MOP929", text: "PVC-DC" },
{ val: "MOP930", text: "PVC-DV" },
{ val: "MOP931", text: "SBK" },
{ val: "MOP932", text: "SCP" },
{ val: "MOP933", text: "SEB" },
{ val: "MOP934", text: "SIS관(이중보온관)" },
{ val: "MOP935", text: "SMC" },
{ val: "MOP936", text: "STW" },
{ val: "MOP937", text: "SUS" },
{ val: "MOP938", text: "THP나선관" },
{ val: "MOP939", text: "U관" },
{ val: "MOP940", text: "VG2" },
{ val: "MOP941", text: "WSP(와사피)" },
{ val: "MOP942", text: "Steel Grating" },
{ val: "MOP943", text: "모래" },
{ val: "MOP944", text: "인조석" },
{ val: "MOP945", text: "SPOL" },
{ val: "MOP946", text: "PCF관(강판보강 PE 나선형관)" },
{ val: "MOP947", text: "PVC3(PVC3중벽관)" },
{ val: "MOP948", text: "PE다중벽관" },
{ val: "MOP977", text: "전주용입상관" },
{ val: "MOP978", text: "MD관" },
];
// 하수관로 'fcltyShpCd' '시설물형태코드'
export const MS_SWAGE_PPL_SHAPE = [
{ val: "FOR000 ", text: "미분류" },
{ val: "FOR001 ", text: "원형" },
{ val: "FOR002 ", text: "원추형" },
{ val: "FOR003 ", text: "원통형" },
{ val: "FOR004 ", text: "구형" },
{ val: "FOR005 ", text: "타원형" },
{ val: "FOR006 ", text: "난원" },
{ val: "FOR007 ", text: "반원형" },
{ val: "FOR010 ", text: "피라미드형" },
{ val: "FOR011 ", text: "사면체형" },
{ val: "FOR012 ", text: "기둥,축" },
{ val: "FOR013 ", text: "돛대,활대처럼 둥글고 긴 원재" },
{ val: "FOR014 ", text: "아치형" },
{ val: "FOR015 ", text: "다중아치형" },
{ val: "FOR016 ", text: "돔형" },
{ val: "FOR017 ", text: "고형체형" },
{ val: "FOR020 ", text: "사각형" },
{ val: "FOR021 ", text: "평방형" },
{ val: "FOR022 ", text: "다이아몬드형" },
{ val: "FOR023 ", text: "사다리꼴" },
{ val: "FOR024 ", text: "각형" },
{ val: "FOR025 ", text: "마제형관" },
{ val: "FOR026 ", text: "태형" },
{ val: "FOR030 ", text: "A형" },
{ val: "FOR031 ", text: "H형" },
{ val: "FOR032 ", text: "I형" },
{ val: "FOR033 ", text: "Y형" },
{ val: "FOR034 ", text: "T형" },
{ val: "FOR050 ", text: "불규칙한 형태" },
{ val: "FOR900 ", text: "계란형" },
{ val: "FOR901 ", text: "구(사각)형" },
{ val: "FOR902 ", text: "높이조절철개형" },
{ val: "FOR903 ", text: "박스형(RCB)" },
{ val: "FOR904 ", text: "반월개거" },
{ val: "FOR905 ", text: "반월개거(측구)" },
{ val: "FOR906 ", text: "벽돌조" },
{ val: "FOR907 ", text: "사각철판" },
{ val: "FOR908 ", text: "석축개거" },
{ val: "FOR909 ", text: "석축개거(측구)" },
{ val: "FOR910 ", text: "승수로" },
{ val: "FOR911 ", text: "옹벽개거" },
{ val: "FOR912 ", text: "옹벽개거(측구)" },
{ val: "FOR913 ", text: "이단" },
{ val: "FOR914 ", text: "이중침전형" },
{ val: "FOR915 ", text: "자연개거" },
{ val: "FOR916 ", text: "자연개거(측구)" },
{ val: "FOR917 ", text: "조립식" },
{ val: "FOR918 ", text: "지상관" },
{ val: "FOR919 ", text: "지상단구" },
{ val: "FOR920 ", text: "지상쌍구" },
{ val: "FOR921 ", text: "지하단구" },
{ val: "FOR922 ", text: "지하쌍구" },
{ val: "FOR923 ", text: "직사각형" },
{ val: "FOR924 ", text: "타설식" },
{ val: "FOR925 ", text: "토류형" },
{ val: "FOR926 ", text: "흄관원형" },
{ val: "FOR927 ", text: "L형측구" },
{ val: "FOR928 ", text: "LO형측구" },
{ val: "FOR929 ", text: "LU형측구" },
{ val: "FOR930 ", text: "PVC이중벽관" },
{ val: "FOR931 ", text: "RCB 맨홀" },
{ val: "FOR932 ", text: "U형" },
{ val: "FOR933 ", text: "U형측구(미복개)" },
{ val: "FOR934 ", text: "U형측구(복개)" },
{ val: "FOR999 ", text: "기타" }
];
// 하수처리시설
export const SwtrmtFcltCol = [
{ id: 'swtrmt_fclt_nm', title: '하수처리시설명' },
{ id: 'fclt_cpct', title: '시설용량(㎥/d)' },
{ id: 'rgn_expln', title: '지역설명' },
{ id: 'cmcn_ymd', title: '준공일자' },
{ id: 'oprtng_strt_ymd', title: '가동개시일자' },
{ id: 'clsg_ymd', title: '폐쇄일자' },
// { id: 'prcs_msrc_cd', title: '처리공법코드' },
// { id: 'lae_cd', title: '지자체코드' },
{ id: 'lae_nm', title: '지자체명' },
{ id: 'ctpv_nm', title: '시도명' },
{ id: 'sgg_nm', title: '시군구명' },
// { id: 'sarea_cd', title: '권역코드' },
{ id: 'sarea_nm', title: '권역명' },
{ id: 'lrge_sarea_nm', title: '대권역명' },
{ id: 'rvr_nm', title: '하천명' },
{ id: 'addr', title: '주소' },
{ id: 'dtl_addr', title: '상세주소' },
{ id: 'rdnm_addr', title: '도로명주소' },
{ id: 'dtl_rdnm_addr', title: '상세도로명주소' },
{ id: 'dschg_wtst_nm', title: '방류수계명' },
{ id: 'plln_area', title: '부지면적(m²)' },
{ id: 'mng_rprtv_bzenty_nm', title: '관리대행업제명' },
{ id: 'swtrmt_ppltn_cnt', title: '하수처리인구수(인)' },
{ id: 'prcs_zone_area_sum', title: '처리구역면적합계(m²)' },
{ id: 'xcrd', title: 'X좌표' },
{ id: 'ycrd', title: 'Y좌표' },
// { id: 'oprtngSttsCd', title: '가동상태코드' },
{ id: 'rprs_msrc_cn', title: '대표공법내용' },
// { id: 'stty_crtr_bod', title: '법정기준생물화학적산소요구량(mg/L)' },
// { id: 'stty_crtr_toc', title: '법정기준TOC((mg/L))' },
// { id: 'stty_crtr_ss', title: '법정기준SS(mg/L)' },
// { id: 'stty_crtr_tn', title: '법정기준총질소(mg/L)' },
// { id: 'stty_crtr_tph', title: '법정기준총인(mg/L)' },
// { id: 'stty_crtr_clbcl_cnt', title: '법정기준대장균수(군수/100mL)' },
{ id: 'bsn_nm', title: '유역명' },
// { id: 'obsrvnSptCd', title: '관측지점코드' },
];
export const SwtrmtFcltCol2 = [
{ id: 'rowNumber', title: '순번' },
{ id: 'swtrmtFcltNm', title: '하수처리\n시설명' },
{ id: 'fcltCpct', title: '시설\n용량\n(㎥/d)' },
{ id: 'rgnExpln', title: '지역설명' },
// { id: 'cmcnYmd', title: '준공일자' },
{ id: 'oprtngStrtYmd', title: '가동개시일자' },
// { id: 'clsgYmd', title: '폐쇄일자' },
// { id: 'prcsMsrcCd', title: '처리공법코드' },
// { id: 'laeCd', title: '지자체코드' },
{ id: 'laeNm', title: '지자체명' },
// { id: 'ctpvNm', title: '시도명' },
// { id: 'sggNm', title: '시군구명' },
// { id: 'sareaCd', title: '권역코드' },
{ id: 'sareaNm', title: '권역명' },
// { id: 'lrgeSareaNm', title: '대권역명' },
{ id: 'rvrNm', title: '하천명' },
// { id: 'addr', title: '주소' },
// { id: 'dtlAddr', title: '상세주소' },
// { id: 'rdnmAddr', title: '도로명주소' },
// { id: 'dtlRdnmAddr', title: '상세도로명주소' },
{ id: 'dschgWtstNm', title: '방류수계명' },
// { id: 'pllnArea', title: '부지면적(m²)' },
// { id: 'mngRprtvBzentyNm', title: '관리대행업체명' },
{ id: 'swtrmtPpltnCnt', title: '하수\n처리\n인구수\n(인)' },
// { id: 'prcsZoneAreaSum', title: '처리구역면적합계(m²)' },
// { id: 'xcrd', title: 'X좌표' },
// { id: 'ycrd', title: 'Y좌표' },
// { id: 'oprtngSttsCd', title: '가동상태코드' },
{ id: 'rprsMsrcCn', title: '대표\n공법\n내용' },
// { id: 'sttyCrtrBod', title: '법정기준생물화학적산소요구량(mg/L)' },
// { id: 'sttyCrtrToc', title: '법정기준TOC(mg/L)' },
// { id: 'sttyCrtrSs', title: '법정기준SS(mg/L)' },
// { id: 'sttyCrtrTn', title: '법정기준총질소(mg/L)' },
// { id: 'sttyCrtrTph', title: '법정기준총인(mg/L)' },
// { id: 'sttyCrtrClbclCnt', title: '법정기준대장균수(군수/100mL)' },
// { id: 'bsnNm', title: '유역명' },
// { id: 'obsrvnSptCd', title: '관측지점코드' },
];
export const SwtrmtFcltExcelCol = {
'swtrmtFcltNm': '하수처리시설명',
'fcltCpct': '시설 용량(㎥/d)',
'rgnExpln': '지역설명',
'oprtngStrtYmd': '가동개시 일자',
'laeNm': '지자체명',
'sareaNm': '권역명',
'rvrNm': '하천명',
'dschgWtstNm': '방류수계명',
'swtrmtPpltnCnt': '하수 처리 인구수(인)',
'rprsMsrcCn': '대표 공법 내용'
};
// 하수처리구역
export const SwtrmtPrcsZoneCol = [
// { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' },
{ id: 'swtrmt_zone_nm', title: '하수처리구역명' },
// { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' },
// { id: 'ctpv_cd', title: '시도코드' },
{ id: 'ctpv_nm', title: '시도명' },
// { id: 'sgg_cd', title: '시군구코드' },
{ id: 'sgg_nm', title: '시군구명' },
{ id: 'swtrmt_fclt_nm', title: '하수처리시설명' },
];
export const SwtrmtPrcsZoneCol2 = [
{ id: 'rowNumber', title: '순번' },
// { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' },
{ id: 'swtrmtZoneNm', title: '하수처리구역명' },
// { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' },
// { id: 'ctpv_cd', title: '시도코드' },
{ id: 'ctpvNm', title: '시도' },
// { id: 'sgg_cd', title: '시군구코드' },
{ id: 'sggNm', title: '시군구' },
{ id: 'swtrmtFcltNm', title: '하수처리시설명' },
];
export const SwtrmtPrcsZoneExcelCol = {
'swtrmtZoneNm': '하수처리구역명',
'ctpvNm': '시도',
'sggNm': '시군구',
'swtrmtFcltNm': '하수처리시설명'
};
// 침수흔적도
export const FldngWtlvMapCol = [
{ id: 'fldng_yr', title: '침수년도' },
{ id: 'fldng_dstr_nm', title: '침수재해명' },
{ id: 'ctpv_nm', title: '시도명' },
{ id: 'sgg_nm', title: '시군구명' },
{ id: 'fldng_bgng_dtm', title: '침수시작일시' },
{ id: 'fldng_end_dtm', title: '침수종료일시' },
{ id: 'fldng_hr', title: '침수시간' },
{ id: 'fldng_dstrct_nm', title: '침수지구명' },
{ id: 'fldng_cs_dtl_cd', title: '침수원인상세코드' },
{ id: 'dtl_ocrn_cs_cn', title: '침수발생원인' },
{ id: 'avg_fldng_wtlv', title: '평균침수위' },
{ id: 'fldng_area', title: '침수면적' },
{ id: 'exmn_yr', title: '조사년도' },
{ id: 'dam_amt', title: '피해금액' },
{ id: 'rain_bgng_tm', title: '강우시작일시' },
{ id: 'rain_end_tm', title: '강우종료일시' },
{ id: 'rain_tot', title: '총강우량(mm)' },
{ id: 'rain_str', title: '강우강도(mm/hr)' },
];
export const FldngWtlvMapCol2 = [
{ id: 'rowNumber', title: '순번' },
{ id: 'fldngYr', title: '침수년도' },
{ id: 'fldngDstrNm', title: '침수재해명' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
{ id: 'fldngBgngDtm', title: '침수시작일시' },
{ id: 'fldngEndDtm', title: '침수종료일시' },
{ id: 'fldngHr', title: '침수시간' },
{ id: 'fldngDstrctNm', title: '침수지구명' },
{ id: 'fldngCsDtlCd', title: '침수원인상세코드' },
{ id: 'avgFldngWtlv', title: '평균침수위' },
{ id: 'fldngArea', title: '침수면적' },
{ id: 'exmnYr', title: '조사년도' },
{ id: 'damAmt', title: '피해금액' },
{ id: 'rainBgngTm', title: '강우시작일시' },
{ id: 'rainEndTm', title: '강우종료일시' },
{ id: 'rainTot', title: '총강우량(mm)' },
{ id: 'rainStr', title: '강우강도(mm/hr)' },
];
export const FldngDpthMapCol = [
{ id: 'fldng_yr', title: '침수년도' },
{ id: 'ctpv_nm', title: '시도명' },
{ id: 'sgg_nm', title: '시군구명' },
{ id: 'fldng_dpth', title: '침수깊이(m)' },
{ id: 'fldng_area', title: '침수면적(㎡)' },
// { id: 'shape_area', title: '형상면적(㎡)' },
// { id: 'shape_len', title: '형상길이' },
];
export const FldngWtlvMapExcelCol = {
'fldngYr': '침수년도',
'fldngDstrNm': '침수재해명',
'ctpvNm': '시도',
'sggNm': '시군구',
'bgngYmd': '침수시작일자',
'fldngBgngHm': '침수시작시분',
'endYmd': '침수종료일자',
'fldngEndHm': '침수종료시분',
'fldngHr': '침수시간',
'fldngDstrctNm': '침수지구명',
'fldngCsDtlCd': '침수원인상세코드',
'avgFldngWtlv': '평균침수위',
'fldngArea': '침수면적',
'exmnYr': '조사년도',
'damAmt': '피해금액'
};
// 하수맨홀
export const MsSwageMhlCol = [
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftre_smbol_cd', title: '지형지물부호코드' },
{ id: 'fclt_mng_no', title: '시설관리번호' },
// { id: 'pbadms_emd_cd', title: '행정읍면동코드' },
{ id: 'mapsht_no', title: '도엽번호' },
// { id: 'mng_inst_cd', title: '관리기관코드' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'last_drdgn_ymd', title: '최종준설일자' },
// { id: 'usg_cd', title: '용도코드' },
// { id: 'fclty_shp_cd', title: '시설물형태코드' },
// { id: 'mhl_knd_cd', title: '맨홀종류코드' },
{ id: 'swage_mhL_diam_vl', title: '하수맨홀구경(mm)' },
{ id: 'mqlt_cd', title: '재질' },
{ id: 'wdth_len', title: '가로길이(m)' },
{ id: 'vrtc_len', title: '세로길이(m)' },
// { id: 'ang_dir', title: '표고' },
{ id: 'altd_vl', title: '지표고(EL.m)' },
{ id: 'dpthl_vl', title: '맨홀저고(EL.m)' },
// { id: 'inst_mng_no', title: '기관관리번호' }
];
export const MsSwageMhlCol2 = [
{ id: 'rowNumber', title: '순번' },
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fcltMngNo', title: '시설관리번호' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
// { id: 'mapshtNo', title: '도엽번호' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'lastDrdgnYmd', title: '최종준설일자' },
{ id: 'usgCd', title: '용도' },
// { id: 'fcltyShpCd', title: '시설물형태코드' },
// { id: 'mhlKndCd', title: '맨홀종류코드' },
{ id: 'swageMhlDiamVl', title: '하수맨홀구경(mm)' },
// { id: 'mqltCd', title: '재질코드' },
{ id: 'wdthLen', title: '가로길이(m)' },
{ id: 'vrtcLen', title: '세로길이(m)' },
// { id: 'angDir', title: '표고' },
{ id: 'altdVl', title: '지표고(EL.m)' },
{ id: 'dpthlVl', title: '맨홀저고(EL.m)' },
// { id: 'instMngNo', title: '기관관리번호' }
];
export const MsSwageMhlExcelCol = {
'fcltMngNo': '시설관리번호',
'ctpvNm': '시도',
'sggNm': '시군구',
'instlYmd': '설치일자',
'usgCd': '용도',
'swageMhlDiamVl': '하수맨홀구경(mm)',
'wdthLen': '가로길이(m)',
'vrtcLen': '세로길이(m)',
'altdVl': '지표고(EL.m)',
'dpthlVl': '맨홀저고(EL.m)'
};
// 하수맨홀 뚜껑재질 코드표
export const MS_SWAGE_MHL_CODES = [
{ val: "SBC000", text: "미분류" },
{ val: "SBC001", text: "주철" },
{ val: "SBC002", text: "철판" },
{ val: "SBC003", text: "콘크리트" },
{ val: "SBC004", text: "그레이팅" },
{ val: "SBC091", text: "칼라" },
{ val: "SBC092", text: "S.G" },
{ val: "SBC999", text: "기타" },
{ val: "SBC900", text: "각주철" },
{ val: "SBC901", text: "강재" },
{ val: "SBC902", text: "나무" },
{ val: "SBC903", text: "나무,널판지,합판" },
{ val: "SBC904", text: "대리석" },
{ val: "SBC905", text: "뚜껑없음" },
{ val: "SBC906", text: "보도용콘크리트" },
{ val: "SBC907", text: "스틸(ST)" },
{ val: "SBC908", text: "신형 CON" },
{ val: "SBC909", text: "알루미늄" },
{ val: "SBC910", text: "염화비닐(PE)" },
{ val: "SBC911", text: "인조석" },
{ val: "SBC912", text: "인조석(주철)" },
{ val: "SBC913", text: "인조CONC" },
{ val: "SBC914", text: "철, 철근" },
{ val: "SBC915", text: "칼라콘크리트" },
{ val: "SBC916", text: "플라스틱" },
{ val: "SBC917", text: "PVC" },
{ val: "SBC918", text: "SP" },
{ val: "SBC919", text: "SS" },
{ val: "SBC920", text: "닥타일주철" },
{ val: "SBC921", text: "철근G" },
{ val: "SBC922", text: "폴리에틸렌(PE)" },
{ val: "SBC923", text: "회주철" }
];
// 하수맨홀 용도 코드표
export const MS_SWAGE_MHL_USE_CODES = [
{ val: "SMU000 ", text: "미분류" },
{ val: "SMU001 ", text: "우수" },
{ val: "SMU002 ", text: "오수" },
{ val: "SMU003 ", text: "집수" },
{ val: "SMU004 ", text: "합류" },
{ val: "SMU005 ", text: "차집" },
{ val: "SMU999 ", text: "기타" },
{ val: "SMU900 ", text: "1호" },
{ val: "SMU901 ", text: "2호" },
{ val: "SMU902 ", text: "3호" },
{ val: "SMU903 ", text: "4호" },
{ val: "SMU904 ", text: "5호" },
{ val: "SMU905 ", text: "6호" },
{ val: "SMU906 ", text: "7호" },
{ val: "SMU907 ", text: "8호" },
{ val: "SMU908 ", text: "구거용맨홀" },
{ val: "SMU909 ", text: "부관부착맨홀" },
{ val: "SMU910 ", text: "조립식맨홀" },
{ val: "SMU911 ", text: "특1호" },
{ val: "SMU912 ", text: "특2호" },
{ val: "SMU913 ", text: "특3호" },
{ val: "SMU914 ", text: "특4호" },
{ val: "SMU915 ", text: "특5호" },
{ val: "SMU916 ", text: "하수맨홀용도" },
{ val: "SMU917 ", text: "현장타설관거용" }
];
// 우수펌프장
export const ExclnSewagePumpCol = [
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fclt_mng_no', title: '시설관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
{ id: 'mapsht_no', title: '도엽번호' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'swage_pmstn_nm', title: '하수펌프장명' },
{ id: 'plln_area', title: '부지면적(m²)' },
// { id: 'opngSttsCd', title: '개통상태코드' },
// { id: 'usgCd', title: '용도코드' },
{ id: 'daily_prcs_cpct', title: '일일처리용량(㎥)' },
{ id: 'max_impnd_cpct', title: '최대저수용량(㎥)' },
{ id: 'altd_vl', title: '표고(EL.m)' },
{ id: 'wtlv_vl', title: '수위(m)' },
{ id: 'excln_wpmp_ablt_vl', title: '우수양수능력(m²/d)' },
// { id: 'instMngNo', title: '기관관리번호' },
]
export const ExclnSewagePumpCol2 = [
{ id: 'rowNumber', title: '순번' },
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fcltMngNo', title: '시설관리번호' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
// { id: 'mapshtNo', title: '도엽번호' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'swagePmstnNm', title: '하수펌프장명' },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'pllnArea', title: '부지면적(m²)' },
// { id: 'opngSttsCd', title: '개통상태코드' },
// { id: 'usgCd', title: '용도코드' },
{ id: 'dailyPrcsCpct', title: '일일처리용량(㎥)' },
{ id: 'maxImpndCpct', title: '최대저수용량(㎥)' },
{ id: 'altdVl', title: '표고(EL.m)' },
{ id: 'wtlvVl', title: '수위(m)' },
{ id: 'exclnWpmpAbltVl', title: '우수양수능력(m²/d)' },
// { id: 'instMngNo', title: '기관관리번호' },
]
export const ExclnSewagePumpExcelCol = {
'fcltMngNo': '시설관리번호',
'ctpvNm': '시도',
'sggNm': '시군구',
'swagePmstnNm': '하수펌프장명',
'instlYmd': '설치일자',
'dailyPrcsCpct': '일일처리용량(㎥)',
'maxImpndCpct': '최대저수용량(㎥)',
'altdVl': '표고(EL.m)',
'wtlvVl': '수위(m)',
'exclnWpmpAbltVl': '우수양수능력(m²/d)'
};
// 오수펌프장
export const DrtwSewagePumpCol = [
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fclt_mng_no', title: '시설관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
{ id: 'mapsht_no', title: '도엽번호' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'swage_pmstn_nm', title: '하수펌프장명' },
{ id: 'plln_area', title: '부지면적(m²)' },
// { id: 'opngSttsCd', title: '개통상태코드' },
// { id: 'usgCd', title: '용도코드' },
{ id: 'daily_prcs_cpct', title: '일일처리용량(㎥)' },
{ id: 'max_impnd_cpct', title: '최대저수용량(㎥)' },
{ id: 'altd_vl', title: '표고(EL.m)' },
{ id: 'wtlv_vl', title: '수위(m)' },
{ id: 'excln_wpmp_ablt_vl', title: '오수양수능력(m²/d)' },
// { id: 'instMngNo', title: '기관관리번호' },
]
export const DrtwSewagePumpCol2 = [
{ id: 'rowNumber', title: '순번' },
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fcltMngNo', title: '시설관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
// { id: 'mapshtNo', title: '도엽번호' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'swagePmstnNm', title: '하수펌프장명' },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'pllnArea', title: '부지면적(m²)' },
// { id: 'opngSttsCd', title: '개통상태코드' },
// { id: 'usgCd', title: '용도코드' },
{ id: 'dailyPrcsCpct', title: '일일처리용량(㎥)' },
{ id: 'maxImpndCpct', title: '최대저수용량(㎥)' },
{ id: 'altdVl', title: '표고(EL.m)' },
{ id: 'wtlvVl', title: '수위(m)' },
{ id: 'exclnWpmpAbltVl', title: '오수양수능력(m²/d)' },
// { id: 'instMngNo', title: '기관관리번호' },
]
// 펌프장 용도 코드표
export const DRTW_SEWAGE_PUMP_USE_CODES = [
{ val: "SBE000", text: "미분류" },
{ val: "SBE001", text: "빗물펌프장" },
{ val: "SBE002", text: "오수펌프장" },
{ val: "SBE003", text: "처리장내펌프장" },
{ val: "SBE900", text: "오수배제" },
{ val: "SBE901", text: "우수배제" },
{ val: "SBE999", text: "기타" },
];
// 우수토실
export const stofcbrCol = [
// { id: 'ftre_smbol_cd', title: '지형지물부호코드' },
{ id: 'fclt_mng_no', title: '시설관리번호' },
// { id: 'pbadms_emd_cd', title: '행정읍면동코드' },
{ id: 'mapsht_no', title: '도엽번호' },
// { id: 'mng_inst_cd', title: '관리기관코드' },
{ id: 'instl_ymd', title: '설치일자' },
// { id: 'fclty_shp_cd', title: '시설물형태코드' },
{ id: 'worflw_wall_hgt', title: '월류벽높이(m)' },
// { id: 'mhl_exst_yn_cd', title: '맨홀존재여부코드' },
// { id: 'swage_mhl_ftre_smbol_cd', title: '하수맨홀지형지물부호코드' },
{ id: 'swage_mhl_mng_no', title: '하수맨홀관리번호' },
{ id: 'drtn_angl', title: '방향각도(°)' },
]
export const stofcbrCol2 = [
{ id: 'rowNumber', title: '순번' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fcltMngNo', title: '시설관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
{ id: 'mapshtNo', title: '도엽번호' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'fcltyShpCd', title: '시설물형태코드' },
{ id: 'worflwWallHgt', title: '월류벽높이(m)' },
// { id: 'mhlExstYnCd', title: '맨홀존재여부코드' },
// { id: 'swageMhlFtreSmbolCd', title: '하수맨홀지형지물부호코드' },
{ id: 'swageMhlMngNo', title: '하수맨홀관리번호' },
{ id: 'drtnAngl', title: '방향각도(°)' },
]
export const stofcbrExcelCol = {
'fcltMngNo': '시설관리번호',
'mapshtNo': '도엽번호',
'ctpvNm': '시도',
'sggNm': '시군구',
'instlYmd': '설치일자',
'worflwWallHgt': '월류벽높이(m)',
'swageMhlMngNo': '하수맨홀관리번호',
'drtnAngl': '방향각도(°)'
};
// 중점우수토실 측정지점
export const untrtSwgRainCol1 = [
{ id: 'manage_no', title: '관리번호' },
{ id: 'swr_ppl_eqp_nm', title: '처리구역' },
{ id: 'swr_ppl_trt_nm', title: '처리분구' },
{ id: 'addr', title: '인근주소' },
{ id: 'chmbr_xcnts', title: '토실 위도' },
{ id: 'chmbr_ydnts', title: '토실 경도' }
];
// 중점우수토실 측정지점 시설현황
export const untrtSwgRainCol2 = [
{ id: 'mesure_spot_se', title: '구분' }, // 유량계측정방식 기타
{ id: 'discharge_pipe_spec', title: '방류관로 규격(m)' },
{ id: 'swr_ppl_eqp_ar', title: '배수구역 면적(m²)' },
{ id: 'swr_trt_popl', title: '하수처리 인구(인)' },
{ id: 'screen_instl_at', title: '스크린설치 여부' },
{ id: 'weir_hg', title: '위어 높이(m)' },
{ id: 'weir_lt', title: '위어 길이(m)' }
];
// 중점우수토실 강우정보 (id값 틀릴 수 있음)
export const untrtSwgRainCol3 = [
{ id: 'rainBeginDt', title: '강우시작일시' },
{ id: 'rainEndDt', title: '강우종료일시' },
{ id: 'precdngDry', title: '선행건기(h)' },
{ id: 'rainInfo', title: '강우정보' },
{ id: 'rainFall', title: '누적강우량(mm)' },
{ id: 'cntncTime', title: '지속시간(h)' },
{ id: 'rainIn', title: '강우강도(mm/h)' }
];
// 중점우수토실 측정정보 (id값 틀릴 수 있음)
export const untrtSwgRainCol4 = [
{ id: 'mesureBeginDt', title: '측정시작일시' },
{ id: 'mesureEndDt', title: '측정종료일시' },
{ id: 'mesureIntrvl', title: '측정간격(분)' },
{ id: 'mesureMhrlsInstlLc', title: '측정기기 설치위치' },
{ id: 'flwmtrCombined', title: '유량계 종류 및 모델명' }, // 합쳐진 컬럼
{ id: 'qltwtrBeginDt', title: '채수시작일시' },
{ id: 'qltwtrEndDt', title: '채수종료일시' },
{ id: 'qltwtrCo', title: '채수횟수' }
];
// 중점우수토실 수량/수질측정 정보
export const untrtSwgRainCol5 = [
{ id: 'odr', title: '회차' },
{ id: 'mesureDt', title: '측정일자' },
{ id: 'mesureQy', title: '수량(m³)' },
{ id: 'mesureBod', title: 'BOD(mg/L)' },
{ id: 'mesureToc', title: 'TOC(mg/L)' },
{ id: 'mesureLoad', title: '오염부하량(kg)' }
];
// 🎯 날짜 필드들 (yyyymmdd -> yyyy년 mm월 dd일)
export const dateFields = [
'instl_ymd', 'last_drdgn_ymd', 'cmcn_ymd', 'oprtng_strt_ymd', 'clsg_ymd',
'mesure_begin_dt', 'mesure_end_dt', 'qltwtr_begin_dt', 'qltwtr_end_dt',
'rain_begin_dt', 'rain_end_dt', 'mesureDt',
// 🎯 camelCase 버전 추가
'instlYmd', 'lastDrdgnYmd', 'cmcnYmd', 'oprtngStrtYmd', 'clsgYmd',
'mesureBeginDt', 'mesureEndDt', 'qltwtrBeginDt', 'qltwtrEndDt',
'rainBeginDt', 'rainEndDt',
'flfmtBgngYmd', 'flfmtEndYmd', 'flfmtYr',
'bgng_ymd', 'end_ymd', 'bgngYmd', 'endYmd'
];
// 🎯 연월 필드들 (yyyymm -> yyyy년 m월)
export const yearMonthFields = ['aplcnYm',];
// 🎯 날짜시간 필드들 (yyyy-mm-dd hh:mm:ss -> yyyy년 m월 d일 hh:mm)
export const dateTimeFields = ['regDt',];
// 🎯 숫자 필드들 (천 단위 구분자)
export const numberFields = [
'pdmt', 'prlg', 'avg_grdn', 'drtw_mltv_area', 'excln_mltv_area', 'tmpt_dpth', 'scend_dpth', 'trfln_cnt', 'vrtc_len', 'wdth_len',
'fclt_cpct', 'plln_area', 'prcs_zone_area_sum', 'swtrmt_ppltn_cnt', 'stty_crtr_bod', 'stty_crtr_toc', 'stty_crtr_ss', 'stty_crtr_tn', 'stty_crtr_tph', 'stty_crtr_clbcl_cnt',
'swage_mh_diam', 'wdth_len', 'vrtc_len', 'altd_vl', 'dpthlVl',
'plln_area', 'daily_prcs_cpct', 'max_impnd_cpct', 'altd_vl', 'wtlv_vl', 'excln_wpmp_ablt',
'worflw_wall_hgt',
'swr_ppl_eqp_ar', 'swr_trt_popl', 'weir_hg', 'weir_lt',
'precdng_dry', 'rain_fall', 'cntnc_time', 'rain_in',
'mesure_intrvl', 'qltwtr_co', 'mesureQy', 'mesureBod', 'mesureToc', 'mesureLoad',
// 🎯 camelCase 버전 추가
'avgGrdn', 'drtwMltvArea', 'exclnMltvArea', 'tmptDpth', 'scendDpth', 'trflnCnt', 'vrtcLen', 'wdthLen',
'fcltCpct', 'pllnArea', 'prcsZoneAreaSum', 'swtrmtPpltnCnt', 'sttyCltrBod', 'sttyCltrToc', 'sttyCltrSs', 'sttyCltrTn', 'sttyCltrTph', 'sttyCltrClbclCnt',
'swageMhDiam', 'altdVl', 'dpthlVl',
'dailyPrcsCpct', 'maxImpndCpct', 'wtlvVl', 'exclnWpmpAblt',
'worflwWallHgt',
'swrPplEqpAr', 'swrTrtPopl', 'weirHg', 'weirLt',
'precdngDry', 'rainFall', 'cntncTime', 'rainIn',
'mesureIntrvl', 'qltwtrCo', 'rnysnFvlc', 'clarFvlc',
'bizAmt', 'count',
'chmbr_xcnts', 'chmbr_ydnts',
'fldng_hr', 'avg_fldng_wtlv', 'fldng_area', 'dam_amt',
'fldngHr', 'avgFldngWtlv', 'fldngArea', 'damAmt'
];
@@ -4,8 +4,8 @@
*/
"use client";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmn-map-types";
import LayerSettingCard from "../layer-setting/layer-setting-card";
type Props = {
jobType: ExmnMapJobType;
@@ -14,6 +14,7 @@ type Props = {
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
onClose?: () => void;
statusMessage?: string;
};
const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"];
@@ -26,6 +27,7 @@ export default function ExmnLayerSettingPanel({
enabled = true,
onEnabledChange = () => {},
onClose = () => {},
statusMessage = "",
}: Props) {
const updateFilter = (key: keyof ExmnMapFilters, value: string) => {
onFiltersChange({
@@ -46,6 +48,7 @@ export default function ExmnLayerSettingPanel({
onClose={onClose}
>
<div className="space-y-[6px]">
{statusMessage ? <p role="status" className="text-[1.1rem] text-muted-foreground">{statusMessage}</p> : null}
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
<span className={`flex items-center gap-1 ${labelClass}`}>
<span
@@ -1,11 +1,13 @@
/** 관로조사 지도 데이터 조회 API와 응답 변환 함수를 제공한다. */
import { getRawJson } from "../../../../components/lib/api";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
import { getRawJson } from "@/shared/api/sewerage-api";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmn-map-types";
type ExmnLayerScope = {
sggCd?: string;
ctpvNm?: string;
sggNm?: string;
exmnBizId?: string;
trtdstId?: string;
};
/** 구버전 응답이 하류맨홀을 포함해도 업로드 관로조사의 상류맨홀 피처만 지도에 전달한다. */
@@ -36,16 +38,19 @@ export const retainUpstreamManholeFeatures = (response: any) => {
export const getExmnMapLayer = async (
jobType: ExmnMapJobType,
filters: ExmnMapFilters,
scope: ExmnLayerScope,
scope: ExmnLayerScope = {},
signal?: AbortSignal,
) => {
const query = new URLSearchParams({ jobType });
if (scope.sggCd?.trim()) query.set("sggCd", scope.sggCd.trim());
if (scope.ctpvNm?.trim()) query.set("ctpvNm", scope.ctpvNm.trim());
if (scope.sggNm?.trim()) query.set("sggNm", scope.sggNm.trim());
if (scope.exmnBizId?.trim()) query.set("exmnBizId", scope.exmnBizId.trim());
if (scope.trtdstId?.trim()) query.set("trtdstId", scope.trtdstId.trim());
if (filters.bizCycl) query.set("bizCycl", filters.bizCycl);
if (filters.structuralGrade) query.set("structuralGrade", filters.structuralGrade);
if (filters.operationalGrade) query.set("operationalGrade", filters.operationalGrade);
const response = await getRawJson(`/sewerage-service/api/v1/exmnMap/layers?${query.toString()}`);
const response = await getRawJson(`/sewerage-service/api/v1/exmnMap/layers?${query.toString()}`, { signal });
return jobType === "MNHL" ? retainUpstreamManholeFeatures(response) : response;
};
@@ -21,6 +21,7 @@ export type ExmnMapFilters = {
export type ExmnMapEntryContext = {
exmnBizId: string;
trtdstId: string;
ctpvNm: string;
sggNm: string;
bizCycl: string;
@@ -126,7 +127,7 @@ const normalizeControlNode = (
...node,
lyrSn: nodeId,
id: nodeId,
// DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다.
// DB에 저장된 과거 명칭과 무관하게 조사사업 표준 화면 명칭으로 표시한다.
lyrNm: fallbackName,
upperLyrSn: upperId,
upId: upperId,
@@ -291,6 +292,7 @@ export const buildExmnMapEntryContext = (
const rawJobType = String(searchParams.get("exmnJobType") ?? "").toUpperCase();
return {
exmnBizId: String(searchParams.get("exmnBizId") ?? "").trim(),
trtdstId: String(searchParams.get("trtdstId") ?? "").trim(),
ctpvNm: String(searchParams.get("ctpvNm") ?? "").trim(),
sggNm: String(searchParams.get("sggNm") ?? "").trim(),
bizCycl: String(searchParams.get("bizCycl") ?? "").replace(/[^0-9]/g, ""),
@@ -0,0 +1,266 @@
"use client";
import { beginMapNavigation } from "@/features/map-search/lib/map-navigation";
import {
useCallback,
useEffect,
useRef,
useState,
type RefObject,
} from "react";
import GeoJSON from "ol/format/GeoJSON";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { getExmnMapLayer } from "./exmn-map-api";
import {
EMPTY_EXMN_MAP_FILTERS,
EXMN_RESULT_PPL_LAYER_ID,
EXMN_RESULT_MNHL_LAYER_ID,
type ExmnMapEntryContext,
type ExmnMapFilters,
type ExmnMapJobType,
} from "./exmn-map-types";
type Options = {
gisApp: any;
mapRef: RefObject<any>;
ready: boolean;
treeData: any[];
entry: ExmnMapEntryContext;
pipeChecked: boolean;
manholeChecked: boolean;
pipeEnabled: boolean;
manholeEnabled: boolean;
pipeFilters: ExmnMapFilters;
manholeFilters: ExmnMapFilters;
pipeStyle: any;
manholeStyle: any;
};
type LayerState = {
layer?: VectorLayer<any>;
controller?: AbortController;
sequence: number;
};
/** 전체 조사 레이어와 상세현황 위치 이동을 분리하며 유형별 요청을 취소/무효화한다. */
export const useExmnResultMap = (options: Options) => {
const {
gisApp,
mapRef,
ready,
treeData,
entry,
pipeChecked,
manholeChecked,
pipeEnabled,
manholeEnabled,
pipeFilters,
manholeFilters,
pipeStyle,
manholeStyle,
} = options;
// 지도 객체/요청 수명은 React 렌더링과 분리하고 관로·맨홀을 독립적으로 관리한다.
const layers = useRef<Record<ExmnMapJobType, LayerState>>({
PPL: { sequence: 0 },
MNHL: { sequence: 0 },
});
const fittedContext = useRef("");
const [messages, setMessages] = useState<Record<ExmnMapJobType, string>>({
PPL: "",
MNHL: "",
});
/** 체크 해제/언마운트 시 늦게 도착한 응답도 다시 표시되지 않게 한다. */
const removeLayer = useCallback(
(job: ExmnMapJobType) => {
const state = layers.current[job];
state.sequence++;
state.controller?.abort();
state.controller = undefined;
if (state.layer) {
gisApp.Module.select?.removeLayer?.(state.layer);
mapRef.current?.removeLayer?.(state.layer);
}
state.layer = undefined;
},
[gisApp, mapRef],
);
/** 일반 진입은 전체, 조사관리 지도보기는 선택 사업·처리분구만 조회해 겹친 타 사업 선택을 방지한다. */
const loadLayer = useCallback(
async (job: ExmnMapJobType, filters: ExmnMapFilters) => {
const map = mapRef.current;
if (!map) return;
const navigation = entry.jobType === job && entry.exmnBizId && entry.trtdstId
? beginMapNavigation(map) : null;
removeLayer(job);
const state = layers.current[job];
const sequence = state.sequence;
const controller = new AbortController();
state.controller = controller;
const active = () =>
state.sequence === sequence && !controller.signal.aborted;
setMessages((current) => ({
...current,
[job]: "조사결과를 불러오는 중입니다.",
}));
try {
const response = await getExmnMapLayer(
job,
filters,
entry.exmnBizId && entry.trtdstId
? { exmnBizId: entry.exmnBizId, trtdstId: entry.trtdstId }
: {},
controller.signal,
);
if (!active()) return;
const data = response?.data ?? response;
const features = new GeoJSON().readFeatures(data, {
dataProjection: "EPSG:5179",
featureProjection: gisApp.TO_PROJ,
});
const source = new VectorSource({ features });
const layer = new VectorLayer({
source,
zIndex: job === "PPL" ? 9988 : 9989,
style: job === "PPL" ? pipeStyle : manholeStyle,
});
layer.setProperties({
id:
job === "PPL"
? EXMN_RESULT_PPL_LAYER_ID
: EXMN_RESULT_MNHL_LAYER_ID,
lyrNm: job === "PPL" ? "관로조사" : "맨홀조사",
lyrTyNm: job === "PPL" ? "tb_ms_swage_ppl" : "tb_ms_swage_mhl",
clickPsblYn: "Y",
chcYn: "Y",
});
state.layer = layer;
map.addLayer(layer);
gisApp.Module.select?.addLayer?.(layer);
const missing = Number(data?.missingGeometryCount ?? 0);
const diagnostics = data?.diagnostics ?? {};
const reasons = [
["지역 확인 불가", diagnostics.regionUnresolvedCount],
["시설번호 없음", diagnostics.identifierMissingCount],
["도형 없음", diagnostics.geometryNotFoundCount],
["도형 중복", diagnostics.ambiguousGeometryCount],
]
.filter(([, count]) => Number(count) > 0)
.map(([label, count]) => `${label} ${count}건`)
.join(", ");
const summary = features.length
? `${features.length.toLocaleString()}개 표시`
: "표시할 조사결과가 없습니다.";
const status = missing
? `${summary} · 공간정보 미연결 ${missing}건${reasons ? ` (${reasons})` : ""}`
: summary;
setMessages((current) => ({ ...current, [job]: status }));
if (entry.jobType !== job || !entry.exmnBizId || !entry.trtdstId)
return;
// 위치 조회 실패가 정상적으로 조회된 기본 레이어를 지우지 않도록 별도 처리한다.
try {
const selectedResponse = await getExmnMapLayer(
job,
filters,
{ exmnBizId: entry.exmnBizId, trtdstId: entry.trtdstId },
controller.signal,
);
if (!active()) return;
const selectedData = selectedResponse?.data ?? selectedResponse;
const selectedFeatures = new GeoJSON().readFeatures(selectedData, {
dataProjection: "EPSG:5179",
featureProjection: gisApp.TO_PROJ,
});
if (!selectedFeatures.length) {
setMessages((current) => ({
...current,
[job]: `${status} · 선택한 처리분구에 현재 조건으로 표시할 공간정보가 없습니다.`,
}));
return;
}
const selectedSource = new VectorSource({
features: selectedFeatures,
});
// 별도 강조선을 추가하지 않고 기존 조사 레이어 색상으로 표시한다.
const contextKey = `${entry.exmnBizId}:${entry.trtdstId}:${job}`;
const extent = selectedSource.getExtent();
if (
fittedContext.current !== contextKey &&
extent.every(Number.isFinite) && navigation?.commit(extent)
) {
map
.getView()
.fit(extent, {
duration: 300,
padding: [80, 420, 80, 80],
maxZoom: 18,
constrainResolution: true,
nearest: true,
});
fittedContext.current = contextKey;
}
} catch (error) {
if (active())
setMessages((current) => ({
...current,
[job]: `${status} · 선택 영역 조회 실패: ${error instanceof Error ? error.message : "다시 조회해 주세요."}`,
}));
}
} catch (error) {
if (active())
setMessages((current) => ({
...current,
[job]: `조회 실패: ${error instanceof Error ? error.message : "다시 조회해 주세요."}`,
}));
}
},
[
entry.exmnBizId,
entry.trtdstId,
entry.jobType,
gisApp,
mapRef,
pipeStyle,
manholeStyle,
removeLayer,
],
);
useEffect(() => {
if (ready && pipeChecked)
void loadLayer("PPL", pipeEnabled ? pipeFilters : EMPTY_EXMN_MAP_FILTERS);
else removeLayer("PPL");
return () => removeLayer("PPL");
}, [
ready,
pipeChecked,
pipeEnabled,
pipeFilters,
treeData,
loadLayer,
removeLayer,
]);
useEffect(() => {
if (ready && manholeChecked)
void loadLayer(
"MNHL",
manholeEnabled ? manholeFilters : EMPTY_EXMN_MAP_FILTERS,
);
else removeLayer("MNHL");
return () => removeLayer("MNHL");
}, [
ready,
manholeChecked,
manholeEnabled,
manholeFilters,
treeData,
loadLayer,
removeLayer,
]);
return { removeLayer, messages };
};
@@ -1,8 +1,8 @@
/** 침수흔적 강우분석과 연결된 심선·위선 목록 조회를 담당한다. */
import { getData } from "@/components/lib/api";
import { getData } from "@/shared/api/sewerage-api";
import type { FldngHstryDetailCacheValue } from "./fldngHstryTypes";
import type { FldngHstryDetailCacheValue } from "@/entities/flood-history/model/flood-history-types";
const normalizeFldngHstryRainAnalysisPayload = (data: any) => {
if (!data || typeof data !== "object") return {};
@@ -5,8 +5,8 @@
"use client";
import { useMemo, useState } from "react";
import type { FldngHstryItem } from "./fldngHstryTypes";
import { formatFldngHstryArea, formatFldngHstryDepth } from "./fldngHstryUtils";
import type { FldngHstryItem } from "@/entities/flood-history/model/flood-history-types";
import { formatFldngHstryArea, formatFldngHstryDepth } from "@/entities/flood-history/lib/flood-history-utils";
type Props = {
detail: FldngHstryItem;
@@ -3,6 +3,7 @@
* 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다.
*/
"use client";
import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
@@ -10,12 +11,8 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type Feature from "ol/Feature.js";
import VectorSource from "ol/source/Vector.js";
import { loadFldngHstryDetail } from "./fldngHstryClient";
import {
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
type FldngHstryDetailCacheValue,
} from "./fldngHstryTypes";
import { loadFldngHstryDetail } from "./fldng-hstry-client";
import { FLDNG_HSTRY_DEPTH_LAYER_TYPE, FLDNG_HSTRY_LAYER_TYPE, type FldngHstryDetailCacheValue } from "@/entities/flood-history/model/flood-history-types";
type FldngHstryGisLayer = {
searchLayer?: { getSource?: () => VectorSource | null } | null;
@@ -27,6 +24,7 @@ type FldngHstryGisLayer = {
showSearchResultsFromGeoJSON: (
layerTypeName: string,
featureId: string,
options?: { beforeFocus: (extent: unknown) => boolean },
) => void | Promise<void>;
};
@@ -131,11 +129,14 @@ const displayCachedFldngHstryFeatures = (
gisLayer: FldngHstryGisLayer,
layerTypeName: string,
features: Feature[],
beforeFocus: (extent: unknown) => boolean,
) => {
const matched = Object.values(GisApp.layerCode ?? {}).find(
(item: any) => item?.lyrTyNm === layerTypeName,
) as any;
if (!matched || features.length === 0) return;
// 캐시를 재사용해도 최신 선택의 유효 도형일 때만 검색조건을 초기화한다.
if (!beforeFocus(features[0]?.getGeometry()?.getExtent())) return;
gisLayer.clearSearchLayer(matched);
const layer = gisLayer.ensureSearchLayer(matched);
@@ -152,17 +153,20 @@ const showFldngHstryFeature = async (
featureId: string,
) => {
const cacheKey = `${layerTypeName}:${featureId}`;
/** 캐시 대기와 신규 WFS 조회가 동일한 이동 성공/취소 계약을 사용한다. */
const navigationOptions = mapSearchNavigationOptions((GisApp.Module.core as any).map);
const cachedRequest = cache.get(cacheKey);
if (cachedRequest) {
displayCachedFldngHstryFeatures(
gisLayer,
layerTypeName,
await cachedRequest,
navigationOptions.beforeFocus,
);
return;
}
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId);
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId, navigationOptions);
const source = gisLayer.searchLayer?.getSource?.();
if (!source) return;
@@ -5,12 +5,9 @@
"use client";
import { SelectBox } from "@common/business_components/ui";
import {
FLDNG_HSTRY_MIN_YEAR,
type FldngHstryYearRange,
} from "./fldngHstryTypes";
import { normalizeFldngHstryYearRange } from "./fldngHstryUtils";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
import { FLDNG_HSTRY_MIN_YEAR, FLDNG_HSTRY_MAX_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types";
import { normalizeFldngHstryYearRange } from "@/entities/flood-history/lib/flood-history-utils";
import LayerSettingCard from "../layer-setting/layer-setting-card";
type Props = {
yearRange: FldngHstryYearRange;
@@ -29,10 +26,10 @@ export default function FldngHstryLayerSettingPanel({
onClose,
}: Props) {
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
const currentYear = new Date().getFullYear();
// 제공 자료의 최대 연도부터 최소 연도까지 선택 목록을 구성한다.
const yearOptions = Array.from(
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
(_, index) => String(currentYear - index),
{ length: FLDNG_HSTRY_MAX_YEAR - FLDNG_HSTRY_MIN_YEAR + 1 },
(_, index) => String(FLDNG_HSTRY_MAX_YEAR - index),
);
const startYearOptions = yearOptions
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
@@ -7,34 +7,17 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { getRawJson } from "@/shared/api/sewerage-api";
import { requestGridExcel } from "@/shared/api/grid-export";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import { useAlertStore } from "@/shared/lib";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_DEFAULT_FILTERS,
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
FLDNG_HSTRY_DOWNLOAD_HEADERS,
FLDNG_HSTRY_LIST_HEADLIST,
FLDNG_HSTRY_PAGE_SIZE,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
type FldngHstryApiItem,
type FldngHstryFilters,
type FldngHstryTableRow,
} from "./fldngHstryTypes";
import {
attachFldngHstryGeometry,
buildFldngHstryListQueryString,
buildFldngHstryYearOptions,
normalizeFldngHstryFilters,
toFldngHstryTableRows,
} from "./fldngHstryUtils";
import useStore from "@/entities/reference-data/model/reference-data-store";
import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryApiItem, type FldngHstryFilters, type FldngHstryTableRow } from "@/entities/flood-history/model/flood-history-types";
import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows } from "@/entities/flood-history/lib/flood-history-utils";
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
@@ -58,26 +41,6 @@ const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
return "";
};
const buildDownloadRows = (rows: FldngHstryTableRow[]) =>
rows.map((row) => ({
rowNo: row.rowNo,
floodYearDisplay: row.floodYearDisplay,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
disasterName: row.disasterName,
rainBgngTmDisplay: row.rainBgngTmDisplay,
rainEndTmDisplay: row.rainEndTmDisplay,
rainTotDisplay: row.rainTotDisplay,
rainStrDisplay: row.rainStrDisplay,
floodHourDisplay: row.floodHourDisplay,
districtName: row.districtName,
causeDetailCode: row.causeDetailCode,
causeDetailName: row.causeDetailName,
avgFloodDepthDisplay: row.avgFloodDepthDisplay,
floodAreaDisplay: row.floodAreaDisplay,
exmnYearDisplay: row.exmnYearDisplay,
damageAmountDisplay: row.damageAmountDisplay,
}));
const resolveRegionNames = (
row: FldngHstryTableRow,
@@ -265,19 +228,13 @@ export default function FldngHstryPopupPage() {
setIsLoading(true);
try {
const response = await fetchAllPages({
batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
getItems: (page) => page.rows,
getTotal: (page) => page.total,
});
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(response.items) as unknown as JSON[],
fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["침수발생이력"],
headerRange: "A1:Q1",
headerMapping: FLDNG_HSTRY_DOWNLOAD_HEADERS,
});
const params = new URLSearchParams(buildFldngHstryListQueryString(appliedFilters, 1, FLDNG_HSTRY_PAGE_SIZE));
params.delete("page"); params.delete("size");
await requestGridExcel("v1/spatialGridExports/floodHistories", {
history: Object.fromEntries(params),
ctpvNames: Object.fromEntries(ctpv.map((item) => [String(item.val).trim(), item.text])),
sggNames: Object.fromEntries(sgg.map((item) => [String(item.val).trim(), item.text])),
}, `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`);
} catch (error) {
console.error("침수발생이력 다운로드 중 오류:", error);
alert(
@@ -465,9 +422,13 @@ export default function FldngHstryPopupPage() {
}
/>
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.
</div>
{/* 기존 행 클릭 안내에 인수테스트 요구(자료 원천 표시) 출처 문구를 글머리표로 더한다. */}
<PopupFooterNote
items={[
"행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.",
"출처 : 행정안전부 재난보험과(2002년~2025년)",
]}
/>
</StandardPopupLayout>
);
}
@@ -7,12 +7,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import useStore from "@/store/useStore";
import { getData } from "@/shared/api/sewerage-api";
import LoadingOverlay from "@/components/ui/loading-bar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import useStore from "@/entities/reference-data/model/reference-data-store";
import {
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
FLOOD_DNGR_MAP_DEPTH_OPTIONS,
@@ -25,7 +26,7 @@ import {
type FloodDngrMapExtent,
type FloodDngrMapFilters,
type FloodDngrMapTableRow,
} from "./floodDngrMapTypes";
} from "./flood-dngr-map-types";
const FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS = 10_000;
const FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS = 12_000;
@@ -226,7 +227,12 @@ export default function FloodDngrMapPopupPage() {
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
);
const payload = response as FloodDngrMapExtent;
const payload = response as FloodDngrMapExtent | null | undefined;
// 성공 응답의 null·생략된 data는 위치 부재이다. 좌표 접근과 지도 메시지를 중단한다.
if (payload == null) {
setErrorMessage("위치 정보가 없습니다");
return;
}
const extent = [
payload?.minX,
payload?.minY,
@@ -237,7 +243,7 @@ export default function FloodDngrMapPopupPage() {
extent.length !== 4 ||
extent.some((value) => !Number.isFinite(value))
) {
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
}
window.opener?.postMessage(
{
@@ -253,11 +259,9 @@ export default function FloodDngrMapPopupPage() {
} catch (error) {
console.error("도시침수지도 위치 이동 중 오류:", error);
const message =
error instanceof Error && error.message.includes("404")
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
: error instanceof Error
? error.message
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
error instanceof Error
? error.message
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
setErrorMessage(message);
} finally {
setIsLoading(false);
@@ -395,6 +399,11 @@ export default function FloodDngrMapPopupPage() {
onRowClick={(row) => void handleRowClick(row)}
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
/>
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
<PopupFooterNote
items={["출처 : 한강홍수통제소, 행정구역별 도시침수지도(2025.09)"]}
/>
</StandardPopupLayout>
);
}
@@ -159,7 +159,7 @@ export const toFloodDngrMapTableRows = (
): FloodDngrMapTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: total - page * pageSize - index,
rowNo: page * pageSize + index + 1,
ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5),
sbmrgDpthlDisplay: toDepthRangeDisplay(
@@ -4,15 +4,19 @@
*/
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef } from "react";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
import { Button } from "@common/components/ui";
import { getData } from "@/components/lib/api";
import { VerticalTable } from "@/components/ui/verticalTable";
import { getData } from "@/shared/api/sewerage-api";
import { VerticalTable } from "@/components/ui/vertical-table";
import type { GisEditChange, GisEditPipeBasicInfo } from "./gisEditTypes";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
import type { GisEditChange, GisEditPipeBasicInfo } from "./gis-edit-types";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
} from "@/entities/facility/model/facility-code-fallbacks";
type Props = {
change: GisEditChange;
@@ -206,20 +210,25 @@ const normalizeBasicFormValues = (values: Record<string, any>) => {
return nextValues;
};
const buildBaseFeatureValues = (featureData: any) => {
const buildBaseFeatureValues = (
featureData: any,
catalog?: ReturnType<typeof useFacilityCodeCatalog>,
) => {
const resolveUsageCode = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_USE_CODES.find(
(item) => item.val === raw || item.text === raw,
);
const matched = [
...(catalog?.MS_SWAGE_PPL_USE_CODES ?? []),
...MS_SWAGE_PPL_USE_CODES,
].find((item) => item.val === raw || item.text === raw);
return matched?.val.trim() ?? raw;
};
const resolveMaterialCode = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_CODES.find(
(item) => item.val === raw || item.text === raw,
);
const matched = [
...(catalog?.MS_SWAGE_PPL_CODES ?? []),
...MS_SWAGE_PPL_CODES,
].find((item) => item.val === raw || item.text === raw);
return matched?.val.trim() ?? raw;
};
@@ -243,13 +252,12 @@ const buildBaseFeatureValues = (featureData: any) => {
}, {});
};
const getPipeUsageLabel = (value: unknown) => {
const getPipeUsageLabel = (value: unknown, codes = MS_SWAGE_PPL_USE_CODES) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
return (
MS_SWAGE_PPL_USE_CODES.find(
(item) => item.val.trim() === raw || item.text === raw,
)?.text ?? raw
codes.find((item) => item.val.trim() === raw || item.text === raw)?.text ??
raw
);
};
@@ -269,10 +277,17 @@ export default function GisEditPipeSessionPopup({
onClose,
onSave,
}: Props) {
const catalog = useFacilityCodeCatalog();
// CMS 명칭 갱신은 입력 중인 초안을 초기화할 이유가 아니다.
const initializedSource = useRef<{
featureData: any;
changeId: string;
isNewPipe: boolean;
} | null>(null);
const isNewPipe = change.editType === "ADD";
const baseFeatureValues = useMemo(
() => buildBaseFeatureValues(featureData),
[featureData],
() => buildBaseFeatureValues(featureData, catalog),
[featureData, catalog],
);
const [basicInfoTableData, setBasicInfoTableData] = useState<
Record<string, any>
@@ -322,12 +337,36 @@ export default function GisEditPipeSessionPopup({
PIPE_BASIC_INFO_FIELDS.map((field) =>
field.id === "mngInstCd"
? { ...field, options: mngInstSelectOptions }
: field,
: field.id === "usgCd" || field.id === "mqltCd"
? {
...field,
options: [
EMPTY_SELECT_OPTION,
...(field.id === "usgCd"
? catalog.MS_SWAGE_PPL_USE_CODES
: catalog.MS_SWAGE_PPL_CODES
).map((item) => ({ value: item.val, label: item.text })),
],
}
: field,
),
[mngInstSelectOptions],
[mngInstSelectOptions, catalog],
);
useEffect(() => {
const previous = initializedSource.current;
if (
previous &&
previous.featureData === featureData &&
previous.changeId === change.changeId &&
previous.isNewPipe === isNewPipe
)
return;
initializedSource.current = {
featureData,
changeId: change.changeId,
isNewPipe,
};
const initialBasicValues: Record<string, any> = {
...baseFeatureValues,
...normalizeBasicFormValues(change.basicInfo ?? {}),
@@ -335,7 +374,7 @@ export default function GisEditPipeSessionPopup({
setBasicInfoTableData(initialBasicValues);
setInputInfoTableData(buildInitialInputDraft(change));
}, [baseFeatureValues, change.changeId, isNewPipe]);
}, [baseFeatureValues, featureData, change.changeId, isNewPipe]);
useEffect(() => {
if (isNewPipe) return;
@@ -429,7 +468,7 @@ export default function GisEditPipeSessionPopup({
<h3 className="whitespace-nowrap text-2xl font-medium text-primary-foreground">
하수관로
{basicInfoTableData?.usgCd
? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})`
? `(${getPipeUsageLabel(basicInfoTableData.usgCd, catalog.MS_SWAGE_PPL_USE_CODES)})`
: ""}{" "}
정보 <br />
관리번호 : {displayManagementNo || "신규"}
@@ -6,8 +6,8 @@
import { Button } from "@common/components";
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
import type { GisEditSessionSnapshot, GisEditTool } from "./gis-edit-types";
import { useGisEditOperationLabels } from "./use-gis-edit-common-codes";
type Props = GisEditSessionSnapshot & {
onRemoveFocusedChange: () => void;
@@ -7,8 +7,8 @@
import { Check, Move, PenTool, Plus, Trash2, X } from "lucide-react";
import { Button } from "@common/components/ui";
import type { GisEditTool } from "./gisEditTypes";
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
import type { GisEditTool } from "./gis-edit-types";
import { useGisEditOperationLabels } from "./use-gis-edit-common-codes";
type Props = {
isEditMode: boolean;
@@ -1,2 +1,2 @@
/** GIS 편집 모델은 기능이 소유하고 기존 화면 import 경로는 유지한다. */
export * from "@/features/gisEdit/model/types";
export * from "@/features/gis-edit/model/types";
@@ -3,9 +3,9 @@
import { useEffect, useState } from "react";
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
import { getCommonCodes } from "@/entities/code/api/common-code-api";
import type { GisEditTool } from "./gisEditTypes";
import type { GisEditTool } from "./gis-edit-types";
const GIS_EDIT_OPERATION_UP_CD = "GISEDITOPRT";
@@ -22,19 +22,19 @@ import {
getFeatureProperties,
normalizePipeGeometry,
createChangeId,
} from "@/features/gisEdit";
export { EMPTY_GIS_EDIT_PIPE_BASIC_INFO } from "@/features/gisEdit";
} from "@/features/gis-edit";
export { EMPTY_GIS_EDIT_PIPE_BASIC_INFO } from "@/features/gis-edit";
import type {
GisEditChange,
GisEditPipeBasicInfo,
GisEditSavePayload,
GisEditTool,
} from "./gisEditTypes";
} from "./gis-edit-types";
export const useGisEdit = (treeData: any[]) => {
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다.
// GIS 편집 모드 on/off는 지도 우측 GIS 편집 아이콘과 직접 연결된다.
const [isEditMode, setIsEditMode] = useState(false);
// 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다.
const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl");
@@ -12,26 +12,22 @@ import {
} from "@common/business_components";
import { Button, Input } from "@common/components";
import {
getBlobDataWithHeaders,
getData,
postMutiPart,
postRawJson,
} from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import { getBlobDataWithHeaders, postMutiPart } from "@/shared/api/file-api";
import { getData, postRawJson } from "@/shared/api/sewerage-api";
import LoadingOverlay from "@/components/ui/loading-bar";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import GisUploadPreviewMap from "./GisUploadPreviewMap";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { useGisManagementCommonCodes } from "./useGisManagementCommonCodes";
import type { UploadFacilityType, MngInstOption, UploadHistoryItem, GisUploadResponse, ValidationErrorItem, ViewMode } from "@/features/gisManagement/model/upload-types";
import { getLocalizedValidationErrorMessage, getFileExtension, formatDateTime, toScopeKey, buildManagementLabel, getErrorMessage } from "@/features/gisManagement/lib/upload-display";
import { createGisDownloadClient, saveBlob } from "@/features/gisManagement/api/gis-download";
import { postRefreshMessageToOpener } from "@/features/gisManagement/lib/popup-refresh";
import GisUploadPreviewMap from "./gis-upload-preview-map";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { useGisManagementCommonCodes } from "./use-gis-management-common-codes";
import type { UploadFacilityType, MngInstOption, UploadHistoryItem, GisUploadResponse, ValidationErrorItem, ViewMode } from "@/features/gis-management/model/upload-types";
import { getLocalizedValidationErrorMessage, getFileExtension, formatDateTime, toScopeKey, buildManagementLabel, getErrorMessage } from "@/features/gis-management/lib/upload-display";
import { createGisDownloadClient, saveBlob } from "@/features/gis-management/api/gis-download";
import { postRefreshMessageToOpener } from "@/features/gis-management/lib/popup-refresh";
import { createGisUploadRetryGuard, getGisPartialSuccessNotice } from "@/features/gisManagement";
import { createGisUploadRetryGuard, getGisPartialSuccessNotice } from "@/features/gis-management";
// 기존 전송 경로를 유지하며 다운로드 작업의 비동기 처리는 기능 모듈에 위임한다.
const { downloadGeoPackage, startDownloadJob, waitForDownloadJob } =
@@ -57,7 +53,7 @@ const GIS_UPLOAD_HISTORY_HEADLIST: UiHeadProps[] = [
];
/** 오류 원본 행번호와 공통 표의 역순 순번을 별도 열로 표시한다. */
/** 오류 원본 행번호와 공통 표의 오름차순 표시 순번을 별도 열로 표시한다. */
const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [
{ id: "no", title: "순번", widthPercent: 8 },
{ id: "sourceRowNo", title: "행번호", widthPercent: 10 },
@@ -20,7 +20,7 @@ import { get as getProjection } from "ol/proj";
import { register } from "ol/proj/proj4";
import proj4 from "proj4";
import { getRawJson } from "@/components/lib/api";
import { getRawJson } from "@/shared/api/sewerage-api";
type GisUploadPreviewMapProps = {
uploadId: string;
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
import { getCommonCodes } from "@/entities/code/api/common-code-api";
type CodeLabelMap = Record<string, string>;
@@ -0,0 +1,85 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { PencilRuler, Settings2 } from "lucide-react";
type Props = {
isGisEditActive: boolean;
onToggleGisEdit: () => void;
onOpenGisManagement: () => Window | null;
};
/** 기존 지도 도구와 같은 크기로 GIS 버튼의 기본·활성·키보드 포커스를 표시한다. */
const getButtonClass = (isActive: boolean) =>
`flex h-[40px] w-[40px] items-center justify-center p-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${
isActive
? "bg-primary text-primary-foreground"
: "bg-background text-label hover:bg-primary hover:text-primary-foreground"
}`;
/** 분석도구 메뉴와 독립적으로 GIS 편집 모드와 관리 팝업의 진입 버튼을 제공한다. */
export default function GisToolButtons({
isGisEditActive,
onToggleGisEdit,
onOpenGisManagement,
}: Props) {
// 관리 팝업이 닫히면 버튼의 활성 표시도 해제한다.
const [isGisManagementActive, setIsGisManagementActive] = useState(false);
// 렌더링과 무관한 감지 타이머를 보관해 재클릭·화면 이탈 시 중복 실행을 방지한다.
const closeWatcherRef = useRef<number | null>(null);
useEffect(() => () => {
if (closeWatcherRef.current !== null) {
window.clearInterval(closeWatcherRef.current);
}
}, []);
/** 기존 팝업 실행 함수를 호출하고 차단·닫힘 상태를 버튼에 반영한다. */
const handleOpenGisManagement = () => {
if (closeWatcherRef.current !== null) {
window.clearInterval(closeWatcherRef.current);
closeWatcherRef.current = null;
}
const popup = onOpenGisManagement();
setIsGisManagementActive(Boolean(popup && !popup.closed));
if (!popup || popup.closed) return;
closeWatcherRef.current = window.setInterval(() => {
if (!popup.closed) return;
if (closeWatcherRef.current !== null) {
window.clearInterval(closeWatcherRef.current);
closeWatcherRef.current = null;
}
setIsGisManagementActive(false);
}, 300);
};
return (
<div
role="group"
aria-label="GIS 도구"
className="absolute right-[16px] top-[222.67px] z-[45] flex w-[40px] flex-col overflow-hidden rounded-[3px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
>
<button
type="button"
title="GIS 편집"
aria-label="GIS 편집"
aria-pressed={isGisEditActive}
onClick={onToggleGisEdit}
className={`${getButtonClass(isGisEditActive)} border-b border-[#eeeeee]`}
>
<PencilRuler className="h-[20px] w-[20px]" aria-hidden="true" />
</button>
<button
type="button"
title="GIS 관리"
aria-label="GIS 관리"
aria-haspopup="dialog"
className={getButtonClass(isGisManagementActive)}
onClick={handleOpenGisManagement}
>
<Settings2 className="h-[20px] w-[20px]" aria-hidden="true" />
</button>
</div>
);
}
@@ -11,26 +11,25 @@ import {
type UiHeadProps,
type UiTableSort,
} from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel";
import { useAlertStore } from "@/shared/lib";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import { getData } from "@/shared/api/sewerage-api";
import { appendApiSearchParams } from "@/shared/util/util";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import { formatDisplayData } from "@/components/lib/utils/format-display-value";
import useStore from "@/entities/reference-data/model/reference-data-store";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import LoadingOverlay from "@/components/ui/loading-bar";
import {
INFTWT_RESULT_ROW_SELECT_MESSAGE,
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
type InftwtResultRow,
} from "./inftwtResultTypes";
} from "./inftwt-result-types";
const INFTWT_RESULT_PAGE_SIZE = 10;
@@ -78,6 +77,8 @@ const DEFAULT_FILTERS: InftwtResultFilters = {
};
const postRowSelectMessage = (row: InftwtResultRow) => {
// 같은 클릭의 message/storage 전달을 식별한다.
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(
@@ -85,6 +86,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => {
JSON.stringify({
row,
timestamp: Date.now(),
requestId,
}),
);
} catch (error) {
@@ -97,6 +99,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => {
{
type: INFTWT_RESULT_ROW_SELECT_MESSAGE,
row,
requestId,
},
window.location.origin,
);
@@ -160,7 +163,7 @@ export default function InftwtResultPopupPage() {
return {
...(formattedItem as Record<string, unknown>),
rowNumber: Math.max(
totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index),
(page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
0,
),
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
@@ -241,29 +244,7 @@ export default function InftwtResultPopupPage() {
try {
setIsLoading(true);
const response = await fetchAllPages<any, Record<string, unknown>>({
fetchPage: (page, size) =>
getData(
`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}?page=${page}&size=${size}`,
),
getItems: (page) =>
Array.isArray(page?.content) ? page.content : [],
getTotal: (page) => Number(page?.totalElements ?? 0),
});
const excelRows = response.items;
await downloadExcelWithKoreanHeaders({
data: excelRows as unknown as JSON[],
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
sheetNameArray: ["침입수 분석결과"],
headerRange: "A1:D1",
headerMapping: {
inftwtAnlsSn: "침입수분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
},
});
await downloadAnalysisExcel("inftwt", selectedRow.inftwtAnlsSn, `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`);
} catch (error) {
console.error("침입수 분석결과 다운로드 중 오류:", error);
alert("침입수 분석결과 다운로드 중 오류가 발생했습니다.");
@@ -1,4 +1,5 @@
"use client";
import { beginMapNavigation, fitMapSelection, mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
/** 침입수 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
@@ -10,8 +11,8 @@ import Fill from "ol/style/Fill.js";
import Style from "ol/style/Style.js";
import { intersects as intersectsExtent } from "ol/extent";
import { getRawJson } from "@/components/lib/api";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { getRawJson } from "@/shared/api/sewerage-api";
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
import { useAlertStore } from "@/shared/lib";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
@@ -23,7 +24,7 @@ import {
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
type InftwtResultMapViewResponse,
type InftwtResultRow,
} from "./inftwtResultTypes";
} from "./inftwt-result-types";
type UseInftwtResultMapParams = {
gisMaps: MutableRefObject<any | null>;
@@ -35,7 +36,6 @@ type UseInftwtResultMapParams = {
const INFTWT_ZONE_LAYER_ID = "SN_INFTWT_RESULT_ZONE_LAYER";
const INFTWT_PIPE_LAYER_ID = "SN_INFTWT_RESULT_PIPE_LAYER";
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
const zoneStyle = new Style({
fill: new Fill({
@@ -302,7 +302,7 @@ export const useInftwtResultMap = ({
return;
}
map.getView?.().fit?.(extent, {
fitMapSelection(map, extent, {
duration: 350,
padding: DEFAULT_RESULT_FIT_PADDING,
maxZoom: oilqPipeExtent ? 17 : shouldUseZoneExtent ? 15 : 16,
@@ -319,7 +319,7 @@ export const useInftwtResultMap = ({
if (normalizedSggCd && normalizedSggCd !== "0") {
const sigunguCenter = getSigunguCenter(normalizedSggCd);
if (sigunguCenter) {
moveMapToCenter(gisMaps, sigunguCenter, 12);
moveMapToCenter(gisMaps, sigunguCenter, 12, mapSearchNavigationOptions(gisMaps.current).beforeFocus);
return true;
}
}
@@ -328,7 +328,7 @@ export const useInftwtResultMap = ({
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
if (sidoCenterCoordinate) {
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11, mapSearchNavigationOptions(gisMaps.current).beforeFocus);
return true;
}
}
@@ -395,8 +395,17 @@ export const useInftwtResultMap = ({
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
);
/** message/storage가 전달하는 동일 선택 요청을 한 번만 처리한다. */
const selectionRequestsRef = useRef(new Set<string>());
const handleRowSelect = useCallback(
async (row: InftwtResultRow) => {
async (row: InftwtResultRow, requestId?: string) => {
if (requestId) {
const requests = selectionRequestsRef.current;
if (requests.has(requestId)) return;
requests.add(requestId);
if (requests.size > 64) requests.delete(requests.values().next().value!);
}
const navigation = beginMapNavigation(gisMaps.current);
try {
activeResultRowRef.current = row;
setFeatureData(null);
@@ -412,21 +421,9 @@ export const useInftwtResultMap = ({
mapViewCacheRef.current.set(row.inftwtAnlsSn, mapView);
}
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(INFTWT_PROC_SEARCH_EVENT, {
detail: {
ctpvCd: row.ctpvCd ?? "0",
sggCd: row.sggCd ?? "0",
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
suppressMapMove: (mapView.oilqPipeCount ?? 0) > 0,
},
}),
);
}
if (!navigation.isCurrent()) return;
setActiveMapView(mapView);
void renderMapView(mapView);
await renderMapView(mapView);
} catch (error) {
console.error("移⑥엯??遺꾩꽍寃곌낵 吏€???곌퀎 以??ㅻ쪟:", error);
alert("침입수 분석결과를 지도에 연계하지 못했습니다.");
@@ -439,16 +436,16 @@ export const useInftwtResultMap = ({
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== INFTWT_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
void handleRowSelect(event.data.row as InftwtResultRow);
void handleRowSelect(event.data.row as InftwtResultRow, event.data.requestId);
};
const handleStorageEvent = (event: StorageEvent) => {
if (event.key !== INFTWT_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return;
try {
const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow };
const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow; requestId?: string };
if (!payload?.row) return;
void handleRowSelect(payload.row);
void handleRowSelect(payload.row, payload.requestId);
} catch (error) {
console.warn("移⑥엯??遺꾩꽍寃곌낵 storage ?숆린??媛믪쓣 ?댁꽍?섏? 紐삵뻽?듬땲??", error);
}
@@ -8,20 +8,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api";
import { requestGridExcel } from "@/shared/api/grid-export";
import { getData } from "@/shared/api/sewerage-api";
import { appendApiSearchParams } from "@/shared/util/util";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/loading-bar";
import useStore from "@/entities/reference-data/model/reference-data-store";
import { useAlertStore } from "@/shared/lib";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { useUserStore } from "@/shared/lib/use-user-store";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
import {
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
PIPE_ANALYSIS_DETAIL_HEADLIST,
PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
applyScopeToDetailFilters,
@@ -36,24 +34,11 @@ import {
toPipeAnalysisDetailViewRows,
type PipeAnalysisDetailFilters,
type PipeAnalysisDetailRow,
} from "./pipeAnalysisUtils";
} from "./pipe-analysis-utils";
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10;
const PIPE_ANALYSIS_MAP_WINDOW_NAME = "sn-sewerage-map-root";
const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"rowNo",
"ctpvNm",
"sggNm",
"fcltMngNo",
"instMngNo",
"swtrmtZoneNm",
"instlYmd",
"useNm",
"pdmtVl",
"prlgVl",
"materialName",
] as const;
const PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST;
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
@@ -62,6 +47,8 @@ const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
: fallbackMessage;
export default function PipeAnalysisDetailPopupPage() {
const { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } =
useFacilityCodeCatalog();
const alert = useAlertStore((state) => state.setMessage);
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
@@ -158,11 +145,20 @@ export default function PipeAnalysisDetailPopupPage() {
responseRows as PipeAnalysisDetailRow[],
ctpv,
sgg,
{ materials: MS_SWAGE_PPL_CODES, uses: MS_SWAGE_PPL_USE_CODES },
),
total: Number(response?.count ?? 0),
};
},
[baseAnalysisType, baseElapsedYearBucket, ctpv, scope.regionCode, sgg],
[
baseAnalysisType,
baseElapsedYearBucket,
ctpv,
scope.regionCode,
sgg,
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
],
);
const loadRows = useCallback(
@@ -240,33 +236,31 @@ export default function PipeAnalysisDetailPopupPage() {
try {
setIsLoading(true);
const response = await fetchAllPages({
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
getItems: (page) => page.rows,
getTotal: (page) => page.total,
});
const exportRows = response.items.map((row) => ({
rowNo: row.rowNo,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
fcltMngNo: row.fcltMngNo,
instMngNo: row.instMngNo,
swtrmtZoneNm: row.swtrmtZoneNm,
instlYmd: row.instlYmd,
useNm: row.useNm,
pdmtVl: row.pdmtVl,
prlgVl: row.prlgVl,
materialName:
resolveMaterialKoreanName(row.materialCode) || row.materialName,
}));
const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`;
await downloadExcelWithKoreanHeaders({
data: exportRows as unknown as JSON[],
fileName: `관로분석_관로현황_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로현황"],
headerRange: detailHeaderRange,
headerMapping: PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
const params = new URLSearchParams({ analysisType: baseAnalysisType });
appendApiSearchParams(params, {
ctpvCd: appliedFilters.ctpvCd,
sggCd: appliedFilters.sggCd,
swtrmtZoneNm: appliedFilters.swtrmtZoneNm,
elapsedYearBucket: baseElapsedYearBucket,
materialCode: appliedFilters.materialCode,
useCd: appliedFilters.useCd,
diameterFrom: appliedFilters.diameterFrom,
diameterTo: appliedFilters.diameterTo,
});
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
await requestGridExcel(
"v1/spatialGridExports/pipes",
{
pipe: Object.fromEntries(params),
ctpvNames: Object.fromEntries(
ctpv.map((item) => [String(item.val).trim(), item.text]),
),
sggNames: Object.fromEntries(
sgg.map((item) => [String(item.val).trim(), item.text]),
),
},
`관로분석_관로현황_${buildPipeAnalysisFileDate()}.xlsx`,
);
} catch (error) {
console.error("관로현황 다운로드 중 오류:", error);
alert(
@@ -293,12 +287,12 @@ export default function PipeAnalysisDetailPopupPage() {
) {
options.push({
value: baseMaterialCode,
label: resolveMaterialName(baseMaterialCode),
label: resolveMaterialName(baseMaterialCode, MS_SWAGE_PPL_CODES),
});
}
return options;
}, [baseMaterialCode]);
}, [baseMaterialCode, MS_SWAGE_PPL_CODES]);
const titleSuffix = useMemo(() => {
const locationParts: string[] = [];
@@ -329,7 +323,7 @@ export default function PipeAnalysisDetailPopupPage() {
if (baseElapsedYearBucket)
parts.push(resolveElapsedYearBucketLabel(baseElapsedYearBucket));
if (baseMaterialCode && baseMaterialCode !== "0")
parts.push(resolveMaterialName(baseMaterialCode));
parts.push(resolveMaterialName(baseMaterialCode, MS_SWAGE_PPL_CODES));
return parts.length > 0 ? ` (${parts.join(" / ")})` : "";
}, [
baseAnalysisType,
@@ -339,6 +333,7 @@ export default function PipeAnalysisDetailPopupPage() {
filters.ctpvCd,
filters.sggCd,
filters.swtrmtZoneNm,
MS_SWAGE_PPL_CODES,
rows,
sgg,
]);
@@ -414,7 +409,9 @@ export default function PipeAnalysisDetailPopupPage() {
if (event.key === "Enter") handleSearch();
}}
/>
<span className="shrink-0 text-xl font-semibold text-foreground">~</span>
<span className="shrink-0 text-xl font-semibold text-foreground">
~
</span>
<Input
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
aria-label="관경 최대값"
@@ -8,16 +8,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api";
import { getData } from "@/shared/api/sewerage-api";
import { appendApiSearchParams } from "@/shared/util/util";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/loading-bar";
import useStore from "@/entities/reference-data/model/reference-data-store";
import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import PipeAnalysisSummaryTable from "./PipeAnalysisSummaryTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import {
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
PIPE_ANALYSIS_TYPE_OPTIONS,
@@ -35,7 +36,7 @@ import {
type PipeAnalysisSummaryResponse,
type PipeAnalysisSummaryViewRow,
type PipeAnalysisYearMaterialGroup,
} from "./pipeAnalysisUtils";
} from "./pipe-analysis-utils";
const SUMMARY_FILE_PREFIX: Record<string, string> = {
YEAR: "관로분석_경과년도별",
@@ -83,8 +84,12 @@ export default function PipeAnalysisPopupPage() {
Array<{ value: string; label: string }>
>([]);
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
// 복합표는 물리 행 수가 아니라 서버가 반환하는 처리구역 그룹 수를 사용한다.
const [summaryTotal, setSummaryTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
// 늦게 완료된 이전 페이지 응답이 최신 조회조건의 표를 덮어쓰지 않게 한다.
const summaryRequestId = useRef(0);
useEffect(() => {
if (ctpv.length === 0) fetchCtpv();
@@ -124,9 +129,13 @@ export default function PipeAnalysisPopupPage() {
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode, sgg]);
const fetchSummary = useCallback(
async (nextFilters: PipeAnalysisSummaryFilters) => {
async (nextFilters: PipeAnalysisSummaryFilters, page = 1) => {
const requestId = ++summaryRequestId.current;
setIsLoading(true);
const params = new URLSearchParams({
analysisType: nextFilters.analysisType,
page: String(page - 1),
size: String(PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
});
appendApiSearchParams(params, {
@@ -137,22 +146,27 @@ export default function PipeAnalysisPopupPage() {
if (nextFilters.scopeRegionCode)
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
const response = (await getData(
`v1/fclts/pplAnalysis/summaries?${params.toString()}`,
)) as PipeAnalysisSummaryResponse;
const nextMaterialColumns = Array.isArray(response?.materialColumns)
? response.materialColumns
: [];
setMaterialColumns(nextMaterialColumns);
setRows(buildPipeAnalysisSummaryViewRows(response, ctpv, sgg));
try {
const response = (await getData(
`v1/fclts/pplAnalysis/summaryPage?${params.toString()}`,
)) as PipeAnalysisSummaryResponse & { count: number };
if (requestId !== summaryRequestId.current) return;
const nextMaterialColumns = Array.isArray(response?.materialColumns) ? response.materialColumns : [];
setMaterialColumns(nextMaterialColumns);
setRows(buildPipeAnalysisSummaryViewRows(response, ctpv, sgg));
setSummaryTotal(response.count);
setSummaryCurrentPage(page);
} catch (error) {
if (requestId === summaryRequestId.current) throw error;
} finally {
if (requestId === summaryRequestId.current) setIsLoading(false);
}
},
[ctpv, sgg],
);
const handleSearch = useCallback(
async (nextFilters = draftFilters) => {
setIsLoading(true);
try {
const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope);
setDraftFilters(scopedFilters);
@@ -168,9 +182,8 @@ export default function PipeAnalysisPopupPage() {
),
);
setRows([]);
setSummaryTotal(0);
setMaterialColumns([]);
} finally {
setIsLoading(false);
}
},
[alert, draftFilters, fetchSummary, scope],
@@ -191,6 +204,18 @@ export default function PipeAnalysisPopupPage() {
}
try {
// 화면은 한 페이지뿐이므로 Excel만 별도 전체 집계 계약으로 조회한다.
const params = new URLSearchParams({ analysisType: appliedFilters.analysisType });
appendApiSearchParams(params, {
ctpvCd: appliedFilters.ctpvCd, sggCd: appliedFilters.sggCd,
swtrmtZoneNm: appliedFilters.swtrmtZoneNm,
});
if (appliedFilters.scopeRegionCode) params.set("scopeRegionCode", appliedFilters.scopeRegionCode);
const response = await getData(`v1/fclts/pplAnalysis/summaries?${params}`) as PipeAnalysisSummaryResponse;
const materialColumns = response.materialColumns ?? [];
const rows = buildPipeAnalysisSummaryViewRows(response, ctpv, sgg);
const orderedYearMaterialGroups = buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
.map((group, index) => ({ ...group, rowNo: index + 1 }));
const exportHeadList = buildPipeAnalysisSummaryHeadList(
appliedFilters.analysisType,
materialColumns,
@@ -334,31 +359,11 @@ export default function PipeAnalysisPopupPage() {
() =>
yearMaterialGroups.map((group, index) => ({
...group,
rowNo: yearMaterialGroups.length - index,
rowNo: (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE + index + 1,
})),
[yearMaterialGroups],
[yearMaterialGroups, summaryCurrentPage],
);
const summaryPageOffset =
(summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? rows
: rows.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[appliedFilters.analysisType, rows, summaryPageOffset],
);
const visibleYearMaterialGroups = useMemo(
() =>
orderedYearMaterialGroups.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[orderedYearMaterialGroups, summaryPageOffset],
);
const sggOptions = useMemo(
() =>
draftFilters.ctpvCd === "0"
@@ -393,8 +398,8 @@ export default function PipeAnalysisPopupPage() {
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
{/* 시도는 전체 조회 없이 개별 지역만 선택한다. */}
{ctpv.filter((item) => String(item.val).trim() !== "0").map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
@@ -498,19 +503,18 @@ export default function PipeAnalysisPopupPage() {
</PopupSearchPanel>
<PipeAnalysisSummaryTable
rows={pagedRows}
groups={visibleYearMaterialGroups}
rows={rows}
groups={orderedYearMaterialGroups}
grouped={appliedFilters.analysisType === "YEAR_MATERIAL"}
heads={headList}
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
total={
appliedFilters.analysisType === "YEAR_MATERIAL"
? orderedYearMaterialGroups.length
: rows.length
}
total={summaryTotal}
page={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
onPageChange={(page) => {
void fetchSummary(appliedFilters, page)
.catch(() => alert("관로분석 페이지를 불러오지 못했습니다."));
}}
onCellClick={handleCellClick}
onGroupedCellClick={handleYearMaterialCellClick}
actions={
@@ -519,6 +523,13 @@ export default function PipeAnalysisPopupPage() {
</Button>
}
/>
{/* 인수테스트 요구: 관로분석의 기반 자료(원천)를 목록 아래에 표시한다. */}
<PopupFooterNote
items={[
"국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음",
]}
/>
</StandardPopupLayout>
);
}
@@ -15,7 +15,7 @@ import type {
PipeAnalysisSummaryViewRow,
PipeAnalysisYearMaterialGroup,
PipeAnalysisYearMaterialGroupRow,
} from "./pipeAnalysisUtils";
} from "./pipe-analysis-utils";
type SummaryRow =
| {
@@ -158,7 +158,7 @@ export default function PipeAnalysisSummaryTable({
if (summaryRow.kind === "flat") {
const value =
head.id === "rowNo"
? total - ((page - 1) * pageSize + summaryRow.sourceIndex)
? (page - 1) * pageSize + summaryRow.sourceIndex + 1
: summaryRow.value[head.id];
const onClick =
summaryRow.value.__cellFilters[head.id] && Number(value) > 0
@@ -3,7 +3,10 @@
import type { SelectDataType } from "@common/business_components";
import type { IUserInfo } from "@/shared/store";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
} from "@/entities/facility/model/facility-code-fallbacks";
export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE =
"SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT";
@@ -125,13 +128,6 @@ export type PipeAnalysisYearMaterialGroup = {
rows: PipeAnalysisYearMaterialGroupRow[];
};
const materialMap = new Map(
MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]),
);
const useMap = new Map(
MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]),
);
export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{
value: PipeAnalysisType;
label: string;
@@ -144,7 +140,8 @@ export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{
export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters =
{
analysisType: "YEAR",
ctpvCd: "0",
// 관로분석 최초 조회·초기화의 기본 시도는 부산광역시다. 계정 지역 제한은 별도 적용한다.
ctpvCd: "26",
sggCd: "0",
swtrmtZoneNm: "0",
scopeRegionCode: "",
@@ -486,13 +483,17 @@ export const toPipeAnalysisDetailViewRows = (
rows: PipeAnalysisDetailRow[],
ctpvOptions: SelectDataType[],
sggOptions: SelectDataType[],
catalog?: {
materials: { val: string; text: string }[];
uses: { val: string; text: string }[];
},
) =>
rows.map((row) => ({
...row,
ctpvNm: row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
sggNm: row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
useNm: resolveUseName(row.useCd),
materialName: resolveMaterialName(row.materialCode),
useNm: resolveUseName(row.useCd, catalog?.uses),
materialName: resolveMaterialName(row.materialCode, catalog?.materials),
}));
export const buildPipeAnalysisSummaryDownloadHeaders = (
@@ -538,11 +539,14 @@ export const PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS = {
materialName: "관재질",
};
export const resolveMaterialName = (materialCode?: string) => {
export const resolveMaterialName = (
materialCode?: string,
codes = MS_SWAGE_PPL_CODES,
) => {
const code = String(materialCode ?? "").trim();
if (!code) return "-";
if (code === "OTHER") return "기타";
return materialMap.get(code) ?? code;
return codes.find((item) => item.val.trim() === code)?.text ?? code;
};
/**
@@ -627,10 +631,13 @@ export const resolvePipeAnalysisTypeLabel = (analysisType?: string) => {
);
};
export const resolveUseName = (useCode?: string) => {
export const resolveUseName = (
useCode?: string,
codes = MS_SWAGE_PPL_USE_CODES,
) => {
const code = String(useCode ?? "").trim();
if (!code) return "-";
return useMap.get(code) ?? code;
return codes.find((item) => item.val.trim() === code)?.text ?? code;
};
export const resolveRegionName = (
@@ -13,24 +13,23 @@ import {
} from "@common/business_components";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import { getData } from "@/shared/api/sewerage-api";
import { appendApiSearchParams } from "@/shared/util/util";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel";
import { useAlertStore } from "@/shared/lib";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import { formatDisplayData } from "@/components/lib/utils/format-display-value";
import useStore from "@/entities/reference-data/model/reference-data-store";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import LoadingOverlay from "@/components/ui/loading-bar";
import {
RDII_RESULT_ROW_SELECT_MESSAGE,
RDII_RESULT_ROW_SELECT_STORAGE_KEY,
type RdiiResultRow,
} from "./rdiiResultTypes";
} from "./rdii-result-types";
const RDII_RESULT_PAGE_SIZE = 10;
@@ -80,6 +79,8 @@ const DEFAULT_FILTERS: RdiiResultFilters = {
};
const postRowSelectMessage = (row: RdiiResultRow) => {
// 같은 클릭의 message/storage 전달을 식별한다.
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(
@@ -87,6 +88,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => {
JSON.stringify({
row,
timestamp: Date.now(),
requestId,
}),
);
} catch (error) {
@@ -99,6 +101,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => {
{
type: RDII_RESULT_ROW_SELECT_MESSAGE,
row,
requestId,
},
window.location.origin,
);
@@ -162,7 +165,7 @@ export default function RdiiResultPopupPage() {
return {
...(formattedItem as Record<string, unknown>),
rowNumber: Math.max(
totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index),
(page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
0,
),
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
@@ -243,29 +246,7 @@ export default function RdiiResultPopupPage() {
try {
setIsLoading(true);
const response = await fetchAllPages<any, Record<string, unknown>>({
fetchPage: (page, size) =>
getData(
`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}?page=${page}&size=${size}`,
),
getItems: (page) =>
Array.isArray(page?.content) ? page.content : [],
getTotal: (page) => Number(page?.totalElements ?? 0),
});
const excelRows = response.items;
await downloadExcelWithKoreanHeaders({
data: excelRows as unknown as JSON[],
fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`,
sheetNameArray: ["RDII 분석결과"],
headerRange: "A1:D1",
headerMapping: {
rainInfwtAnlsSn: "RDII분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
},
});
await downloadAnalysisExcel("rdii", selectedRow.rainInfwtAnlsSn, `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`);
} catch (error) {
console.error("RDII 분석결과 다운로드 중 오류:", error);
alert("RDII 분석결과 다운로드 중 오류가 발생했습니다.");
@@ -1,5 +1,6 @@
/** RDII 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
"use client";
import { beginMapNavigation, fitMapSelection, mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
@@ -8,8 +9,8 @@ import VectorSource from "ol/source/Vector.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import { getRawJson } from "@/components/lib/api";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { getRawJson } from "@/shared/api/sewerage-api";
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
import { useAlertStore } from "@/shared/lib";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
@@ -21,7 +22,7 @@ import {
RDII_RESULT_ROW_SELECT_STORAGE_KEY,
type RdiiResultMapViewResponse,
type RdiiResultRow,
} from "./rdiiResultTypes";
} from "./rdii-result-types";
type UseRdiiResultMapParams = {
gisMaps: MutableRefObject<any | null>;
@@ -32,7 +33,6 @@ type UseRdiiResultMapParams = {
const RDII_PIPE_LAYER_ID = "SN_RDII_RESULT_PIPE_LAYER";
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
const PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
const pipeStyle = [
new Style({
@@ -186,7 +186,7 @@ export const useRdiiResultMap = ({
return;
}
map.getView?.().fit?.(pipeExtent, {
fitMapSelection(map, pipeExtent, {
duration: 350,
padding: DEFAULT_RESULT_FIT_PADDING,
maxZoom: 16,
@@ -201,7 +201,7 @@ export const useRdiiResultMap = ({
if (normalizedSggCd && normalizedSggCd !== "0") {
const sigunguCenter = getSigunguCenter(normalizedSggCd);
if (sigunguCenter) {
moveMapToCenter(gisMaps, sigunguCenter, 12);
moveMapToCenter(gisMaps, sigunguCenter, 12, mapSearchNavigationOptions(gisMaps.current).beforeFocus);
return true;
}
}
@@ -210,7 +210,7 @@ export const useRdiiResultMap = ({
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
if (sidoCenterCoordinate) {
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11, mapSearchNavigationOptions(gisMaps.current).beforeFocus);
return true;
}
}
@@ -260,38 +260,37 @@ export const useRdiiResultMap = ({
[clearLayers, ensurePipeLayer, gisMaps, moveToStableFocus],
);
/** message/storage가 전달하는 동일 선택 요청을 한 번만 처리한다. */
const selectionRequestsRef = useRef(new Set<string>());
const handleRowSelect = useCallback(
async (row: RdiiResultRow) => {
async (row: RdiiResultRow, requestId?: string) => {
if (requestId) {
const requests = selectionRequestsRef.current;
if (requests.has(requestId)) return;
requests.add(requestId);
if (requests.size > 64) requests.delete(requests.values().next().value!);
}
const navigation = beginMapNavigation(gisMaps.current);
try {
activeResultRowRef.current = row;
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(PROC_SEARCH_EVENT, {
detail: {
ctpvCd: row.ctpvCd ?? "0",
sggCd: row.sggCd ?? "0",
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
},
}),
);
}
const cachedMapView = mapViewCacheRef.current.get(row.rainInfwtAnlsSn);
if (cachedMapView) {
if (!navigation.isCurrent()) return;
setActiveMapView(cachedMapView);
void renderMapView(cachedMapView);
await renderMapView(cachedMapView);
return;
}
const payload = await getRawJson(`/sewerage-service/api/v1/rdiiAnlsRslt/mapView/${row.rainInfwtAnlsSn}`);
const mapView = (payload?.data ?? payload) as RdiiResultMapViewResponse;
mapViewCacheRef.current.set(row.rainInfwtAnlsSn, mapView);
if (!navigation.isCurrent()) return;
setActiveMapView(mapView);
void renderMapView(mapView);
await renderMapView(mapView);
} catch (error) {
console.error("RDII 분석결과 지도 연계 중 오류:", error);
alert("RDII 분석결과를 지도에 연계하지 못했습니다.");
@@ -304,16 +303,16 @@ export const useRdiiResultMap = ({
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== RDII_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
void handleRowSelect(event.data.row as RdiiResultRow);
void handleRowSelect(event.data.row as RdiiResultRow, event.data.requestId);
};
const handleStorageEvent = (event: StorageEvent) => {
if (event.key !== RDII_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return;
try {
const payload = JSON.parse(event.newValue) as { row?: RdiiResultRow };
const payload = JSON.parse(event.newValue) as { row?: RdiiResultRow; requestId?: string };
if (!payload?.row) return;
void handleRowSelect(payload.row);
void handleRowSelect(payload.row, payload.requestId);
} catch (error) {
console.warn("RDII 분석결과 storage 동기화 값을 해석하지 못했습니다.", error);
}
@@ -4,29 +4,30 @@
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Input } from "@common/components";
import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { getRawJson } from "@/shared/api/sewerage-api";
import {
WINDOW_POPUP_MESSAGE_TYPE,
type WindowPopupContentProps,
type WindowPopupMessageType,
} from "@/features/windowPopup/model/types";
import { MS_SWAGE_PPL_CODES } from "../common/table";
} from "@/features/window-popup/model/types";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
import {
downloadRepairHistoriesByZoneId,
fetchAllRepairHistoriesByZoneId,
fetchRepairHistoryPage,
fetchRepairHistoryMapFeatures,
type RepairHistoryFilters,
type RepairHistoryRow,
} from "../popupComponent/swtrmtPrcsZoneAnalysisApi";
} from "@/features/facility-detail/api/treatment-zone-api";
const REPAIR_HISTORY_PAGE_SIZE = 10;
const MAP_LAYER_SETTING_PATH =
@@ -162,6 +163,7 @@ const formatYear = (value: unknown) => {
export default function RepairHistoryPopupPage({
searchParams,
}: WindowPopupContentProps) {
const { MS_SWAGE_PPL_CODES } = useFacilityCodeCatalog();
const zoneId = useMemo(
() => firstSearchParam(searchParams.zoneId),
[searchParams.zoneId],
@@ -175,17 +177,21 @@ export default function RepairHistoryPopupPage({
useState<RepairHistoryFilters>(buildEmptyFilters);
const [appliedFilters, setAppliedFilters] =
useState<RepairHistoryFilters>(buildEmptyFilters);
// 전체 조회 결과는 부모 지도 강조와 팝업 내 10건 페이지 표가 함께 사용한다.
// 목록에는 현재 서버 페이지의 행만 저장하고 지도 전체 도형은 별도로 전달한다.
const [rows, setRows] = useState<RepairHistoryRow[]>([]);
const [total, setTotal] = useState(0);
// 페이지 요청과 지도 요청을 분리해 페이지 이동이 진행 중인 전체 지도 조회를 취소하지 않는다.
const pageRequestId = useRef(0);
const mapRequestId = useRef(0);
// 용도 선택지는 별도 하드코딩하지 않고 관망도 트리 설정 API의 sortOrdr를 따른다.
const [pipeUseOptions, setPipeUseOptions] = useState<PipeUseOption[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
/** 조회 결과 전체를 부모 지도 레이어와 팝업 표에 동시에 반영한다. */
/** 목록은 한 페이지만 조회하고 검색조건 변경 시에만 지도 전체 도형을 갱신한다. */
const searchRepairHistories = useCallback(
async (nextFilters: RepairHistoryFilters) => {
async (nextFilters: RepairHistoryFilters, page = 1, refreshMap = true) => {
if (!zoneId) {
setErrorMessage("처리구역 식별값이 없습니다.");
return;
@@ -193,38 +199,59 @@ export default function RepairHistoryPopupPage({
setIsLoading(true);
setErrorMessage("");
const requestId = ++pageRequestId.current;
if (refreshMap) {
const mapId = ++mapRequestId.current;
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{ zoneId, rows: [] },
);
void fetchRepairHistoryMapFeatures(zoneId, nextFilters)
.then((features) => {
if (mapId !== mapRequestId.current) return;
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{ zoneId, rows: features },
);
})
.catch(() => {
if (mapId === mapRequestId.current)
setErrorMessage(
"보수이력 지도 도형을 불러오지 못했습니다. 조회를 다시 실행해주세요.",
);
});
}
try {
const response = await fetchAllRepairHistoriesByZoneId(
const response = await fetchRepairHistoryPage(
zoneId,
nextFilters,
page - 1,
REPAIR_HISTORY_PAGE_SIZE,
);
if (requestId !== pageRequestId.current) return;
const nextRows = response.data ?? [];
setRows(nextRows);
setTotal(response.totalElements);
setAppliedFilters({ ...nextFilters });
setCurrentPage(1);
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{
zoneId,
rows: nextRows,
},
);
setCurrentPage(page);
} catch (error) {
if (requestId !== pageRequestId.current) return;
setRows([]);
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{
zoneId,
rows: [],
},
);
setTotal(0);
if (refreshMap) {
mapRequestId.current += 1;
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{ zoneId, rows: [] },
);
}
setErrorMessage(
error instanceof Error
? error.message
: "보수이력을 불러오지 못했습니다.",
);
} finally {
setIsLoading(false);
if (requestId === pageRequestId.current) setIsLoading(false);
}
},
[zoneId],
@@ -270,6 +297,8 @@ export default function RepairHistoryPopupPage({
};
window.addEventListener("beforeunload", clearParentLayer);
return () => {
pageRequestId.current += 1;
mapRequestId.current += 1;
window.removeEventListener("beforeunload", clearParentLayer);
clearParentLayer();
};
@@ -277,7 +306,7 @@ export default function RepairHistoryPopupPage({
/** 화면에서 적용한 조건과 동일한 9개 목록 항목의 XLSX를 내려받는다. */
const handleDownload = async () => {
if (!zoneId || rows.length === 0) return;
if (!zoneId || total === 0) return;
setIsLoading(true);
setErrorMessage("");
try {
@@ -306,18 +335,10 @@ export default function RepairHistoryPopupPage({
});
};
const visibleRows = useMemo(
() =>
rows.slice(
(currentPage - 1) * REPAIR_HISTORY_PAGE_SIZE,
currentPage * REPAIR_HISTORY_PAGE_SIZE,
),
[currentPage, rows],
);
// 공통 표에 전달할 표시값은 원본 API 행과 분리해 지도 연계 식별값을 보존한다.
const tableRows = useMemo<RepairHistoryTableRow[]>(
() =>
visibleRows.map((row) => ({
rows.map((row) => ({
fid: row.fid,
fcltMngNo: formatCellValue(row.fcltMngNo),
useName: formatCellValue(row.useNm),
@@ -329,7 +350,7 @@ export default function RepairHistoryPopupPage({
installationYear: formatYear(row.installationYear),
burialDepth: formatCellValue(row.burialDepth),
})),
[visibleRows],
[rows],
);
return (
<StandardPopupLayout title="보수이력 목록">
@@ -459,12 +480,14 @@ export default function RepairHistoryPopupPage({
}
headlist={REPAIR_HISTORY_HEADLIST}
title={`${zoneName || "처리구역"} 보수이력 목록`}
total={rows.length}
total={total}
page={currentPage}
pageSize={REPAIR_HISTORY_PAGE_SIZE}
onPageChange={setCurrentPage}
onPageChange={(page) =>
void searchRepairHistories(appliedFilters, page, false)
}
onRowClick={(_, index) => {
const row = visibleRows[index];
const row = rows[index];
if (row) handleRowClick(row);
}}
rowId={(row, index) => String(row.fid ?? index)}
@@ -1,6 +1,6 @@
/** 우측 상세 탭의 표시·분류 모델을 지도 조회 수명에서 분리한다. */
import type { ExmnMapJobType } from "../exmn/exmnMapTypes";
import type { SinkholeDetail, SinkholeNearbyPipe } from "../sinkhole/sinkholeTypes";
import type { ExmnMapJobType } from "../exmn/exmn-map-types";
import type { SinkholeDetail, SinkholeNearbyPipe } from "../sinkhole/sinkhole-types";
export type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
export type RightPanelTabCategory =
@@ -7,13 +7,14 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { getRawJson } from "@/shared/api/sewerage-api";
import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
import useStore from "@/entities/reference-data/model/reference-data-store";
import {
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
SBMRG_DNGR_MAP_FREQUENCY_OPTIONS,
@@ -27,7 +28,7 @@ import {
type SbmrgDngrMapApiItem,
type SbmrgDngrMapFilters,
type SbmrgDngrMapTableRow,
} from "./sbmrgDngrMapTypes";
} from "./sbmrg-dngr-map-types";
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
@@ -309,6 +310,11 @@ export default function SbmrgDngrMapPopupPage() {
].join(":")
}
/>
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
<PopupFooterNote
items={["출처 : 하수도정비 중점관리지역 20개소 과업 산출물"]}
/>
</StandardPopupLayout>
);
}
@@ -139,7 +139,7 @@ export const toSbmrgDngrMapTableRows = (
): SbmrgDngrMapTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: total - page * pageSize - index,
rowNo: page * pageSize + index + 1,
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
@@ -0,0 +1,126 @@
/** 지반침하 상세의 표시값과 다운로드 시점 관로 조회 결과를 두 시트로 저장한다. */
import { getRawJson } from "@/shared/api/sewerage-api";
import {
downloadExcelWorkbook,
type ExcelCellValue,
type ExcelSheetSpec,
} from "@/shared/util/download-excel-workbook";
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
type DownloadOptions = {
detail: SinkholeDetail;
radiusMeters: number;
summaryEntries: Array<[string, string]>;
getPipeUseName: (pipe: SinkholeNearbyPipe) => string;
};
/** 식별자는 문자열을 유지하고 결측 숫자는 0으로 바꾸지 않는다. */
const numberCell = (value: unknown): ExcelCellValue => {
if (value == null || String(value).trim() === "") return "-";
const number = Number(value);
return Number.isFinite(number) ? number : String(value);
};
/** 화면과 같은 순서의 발생정보 및 반경 내 관로 목록 시트 명세를 만든다. */
export const buildSinkholeDetailSheets = (
options: DownloadOptions,
pipes: SinkholeNearbyPipe[],
noCoordinates: boolean,
): ExcelSheetSpec[] => {
const pipeRows: ExcelCellValue[][] = pipes.map((pipe, index) => [
index + 1,
String(pipe.pipeId ?? "").trim() || "-",
options.getPipeUseName(pipe),
numberCell(pipe.elapsedYears),
numberCell(pipe.distanceMeters),
]);
if (pipeRows.length === 0)
pipeRows.push([
noCoordinates
? "좌표가 없어 반경 내 하수관로를 조회할 수 없습니다."
: "반경 내 하수관로 정보가 없습니다.",
]);
return [
{
name: "지반침하정보",
rows: [["항목", "내용"], ...options.summaryEntries],
columnWidths: [22, 75],
},
{
name: "하수관로목록",
rows: [
["사고번호", String(options.detail.acdntNo)],
["조회 반경(m)", options.radiusMeters],
["조회 기준", "기존 반경 조회 기준, 최대 200건 (가까운 거리순)"],
[],
["NO", "관로 ID", "용도", "경과년도(년)", "거리(m)"],
...pipeRows,
],
columnWidths: [12, 36, 24, 20, 18],
autoFilterRange: `A5:E${5 + pipes.length}`,
mergeRanges: ["B3:E3", ...(pipes.length === 0 ? ["A6:E6"] : [])],
},
];
};
/** 기존 반경 API를 다시 조회하여 실패를 빈 목록으로 내보내지 않는다. */
export const downloadSinkholeDetailExcel = async (options: DownloadOptions) => {
// 비동기 재조회 동안 호출자의 상세·반경·표시 행이 변경되어도 클릭 시점을 유지한다.
const snapshot: DownloadOptions = {
...options,
detail: { ...options.detail },
summaryEntries: options.summaryEntries.map(([title, value]) => [
title,
value,
]),
};
const { detail, radiusMeters } = snapshot;
const noCoordinates = String(detail.coordStatus ?? "").trim() !== "SUCCESS";
let pipes: SinkholeNearbyPipe[] = [];
if (!noCoordinates) {
const hasPoint =
detail.x != null &&
detail.y != null &&
String(detail.x).trim() !== "" &&
String(detail.y).trim() !== "" &&
Number.isFinite(Number(detail.x)) &&
Number.isFinite(Number(detail.y));
const params = new URLSearchParams({ radiusMeters: String(radiusMeters) });
if (hasPoint) {
params.set("x", String(detail.x));
params.set("y", String(detail.y));
}
const path = hasPoint
? "nearbyPipesByPoint"
: `${encodeURIComponent(detail.acdntNo)}/nearbyPipes`;
const raw = await getRawJson(
`/sewerage-service/api/v1/sinkholes/${path}?${params}`,
);
const response = raw?.data ?? raw;
if (response?.coordStatus !== "SUCCESS")
throw new Error(
"관로 조회 좌표를 확인할 수 없습니다. 상세정보를 다시 조회해 주세요.",
);
if (
!Array.isArray(response?.items) ||
response.items.some(
(item: unknown) =>
!item ||
typeof item !== "object" ||
Array.isArray(item) ||
!("pipeId" in item) ||
!("fid" in item),
)
)
throw new Error(
"관로 조회 응답이 올바르지 않습니다. 다시 시도해 주세요.",
);
pipes = response.items;
}
const now = new Date();
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
await downloadExcelWorkbook({
fileName: `지반침하정보_${detail.acdntNo}_${date}.xlsx`,
sheets: buildSinkholeDetailSheets(snapshot, pipes, noCoordinates),
});
};
@@ -4,12 +4,13 @@
*/
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@common/components";
import { VerticalTable } from "@/components/ui/verticalTable";
import { getCmsData } from "@/components/lib/api";
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkholeTypes";
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
import { VerticalTable } from "@/components/ui/vertical-table";
import { getCmsData } from "@/shared/api/external-service-api";
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
import { downloadSinkholeDetailExcel } from "./sinkhole-detail-excel";
type Props = {
detail: SinkholeDetail;
@@ -81,6 +82,9 @@ export default function SinkholeDetailPanel({
}: Props) {
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
const { restoreStatusLabels } = useSinkholeCommonCodes();
// 파일 생성 중 버튼 표시와 같은 이벤트 루프의 중복 클릭을 함께 제어한다.
const [isDownloading, setIsDownloading] = useState(false);
const downloadInProgressRef = useRef(false);
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
new Map(),
);
@@ -127,27 +131,24 @@ export default function SinkholeDetailPanel({
return { average, oldest };
}, [nearbyPipes]);
const summaryTableData = useMemo(
() => {
const restoreStatus = String(detail.restoreStatus ?? "").trim();
return [
{
acdntNo: display(detail.acdntNo),
ocrnYmd: display(detail.ocrnYmd),
fullAddress: display(detail.fullAddress || detail.daddr),
sizeWidth: display(detail.sizeWidth),
sizeLength: display(detail.sizeLength),
sizeDepth: display(detail.sizeDepth),
detailCause: display(detail.detailCause),
restoreStatus: display(
restoreStatusLabels[restoreStatus] || restoreStatus,
),
restoreCompletedYmd: display(detail.restoreCompletedYmd),
},
];
},
[detail, restoreStatusLabels],
);
const summaryTableData = useMemo(() => {
const restoreStatus = String(detail.restoreStatus ?? "").trim();
return [
{
acdntNo: display(detail.acdntNo),
ocrnYmd: display(detail.ocrnYmd),
fullAddress: display(detail.fullAddress || detail.daddr),
sizeWidth: display(detail.sizeWidth),
sizeLength: display(detail.sizeLength),
sizeDepth: display(detail.sizeDepth),
detailCause: display(detail.detailCause),
restoreStatus: display(
restoreStatusLabels[restoreStatus] || restoreStatus,
),
restoreCompletedYmd: display(detail.restoreCompletedYmd),
},
];
}, [detail, restoreStatusLabels]);
const summaryHeadList = [
{ id: "acdntNo", title: "사고번호" },
@@ -161,6 +162,35 @@ export default function SinkholeDetailPanel({
{ id: "restoreCompletedYmd", title: "복구완료일자" },
];
/** 클릭 시의 상세·반경·표시값을 고정해 탭 전환 중에도 한 사고만 내보낸다. */
const handleDownload = async () => {
if (downloadInProgressRef.current) return;
downloadInProgressRef.current = true;
setIsDownloading(true);
const summary = summaryTableData[0] as Record<string, string>;
try {
await downloadSinkholeDetailExcel({
detail: { ...detail },
radiusMeters,
summaryEntries: summaryHeadList.map(({ id, title }) => [
title,
summary[id] ?? "-",
]),
getPipeUseName,
});
} catch (error) {
console.error("지반침하 상세 엑셀 다운로드 실패:", error);
alert(
error instanceof Error
? error.message
: "엑셀 다운로드 중 오류가 발생했습니다.",
);
} finally {
downloadInProgressRef.current = false;
setIsDownloading(false);
}
};
return (
<div
id="popup"
@@ -198,9 +228,20 @@ export default function SinkholeDetailPanel({
<section className="pb-[1.2rem]">
<div className="card-header !block px-[1.2rem]">
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label">
침하발생 반경내 관로정보
</h3>
<div className="flex items-center justify-between gap-2">
<h3 className="ml-3 min-w-0 flex-1 text-2xl font-medium text-label">
침하발생 반경내 관로정보
</h3>
<Button
type="button"
className="hover:bg-primary/90 mr-3 shrink-0 bg-primary text-primary-foreground"
onClick={handleDownload}
disabled={isDownloading}
aria-busy={isDownloading}
>
{isDownloading ? "다운로드 중..." : "엑셀다운로드"}
</Button>
</div>
{canShowNearby ? (
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
<span>반경 {radiusMeters}m · </span>
@@ -13,8 +13,8 @@ import {
validateSinkholeYearRange,
type SinkholeSyncSummary,
type SinkholeYearRange,
} from "./sinkholeTypes";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
} from "./sinkhole-types";
import LayerSettingCard from "../layer-setting/layer-setting-card";
type MaybePromise<T = void> = T | Promise<T>;
@@ -11,17 +11,18 @@ import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import { getRawJson } from "@/shared/api/sewerage-api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { useAlertStore } from "@/shared/lib";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import useStore from "@/entities/reference-data/model/reference-data-store";
import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel, {
PopupSearchActions,
PopupSearchField,
} from "@/features/windowPopup/ui/PopupSearchPanel";
} from "@/features/window-popup/ui/popup-search-panel";
import type { SelectDataType } from "@common/business_components";
import {
buildSinkholeDefaultFilters,
@@ -35,8 +36,8 @@ import {
SINKHOLE_ROW_SELECT_MESSAGE,
type SinkholeFilters,
type SinkholeListItem,
} from "./sinkholeTypes";
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
} from "./sinkhole-types";
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
const PAGE_SIZE = 10;
const DOWNLOAD_BATCH_SIZE = 2000;
@@ -514,6 +515,13 @@ export default function SinkholePopupPage() {
</Button>
}
/>
{/* 인수테스트 요구: 지반침하 정보의 연계 원천과 연계 주기를 목록 아래에 표시한다. */}
<PopupFooterNote
items={[
"출처 : 지하안전정보시스템 지반침하사고 정보 연계 표출(연계 주기 : 매 시간 연계)",
]}
/>
</StandardPopupLayout>
);
}
@@ -3,12 +3,12 @@
import { useEffect, useMemo, useState } from "react";
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
import { getCommonCodes } from "@/entities/code/api/common-code-api";
import {
SINKHOLE_COORD_STATUS_OPTIONS,
SINKHOLE_RESTORE_STATUS_OPTIONS,
} from "./sinkholeTypes";
} from "./sinkhole-types";
type CodeLabelMap = Record<string, string>;
type StatusOption = { value: string; label: string };
@@ -1,5 +1,5 @@
/** 통수위험 분석 대상과 결과를 조회하는 API 함수를 제공한다. */
import { getData } from "@/app/components/lib/api";
import { getData } from "@/shared/api/sewerage-api";
import type { UiTableSort } from "@common/business_components/ui/table";
import type {
@@ -8,12 +8,24 @@ import type {
WaterRiskResultPage,
WaterRiskResultRow,
WaterRiskStatus,
} from "./waterRiskTypes";
} from "./water-risk-types";
export type WaterRiskResultQuery = {
startDate: string;
endDate: string;
/** 현재 탭 분석기준시간(정시) 정확 일치 조건. 분석시각 기간과 함께 보내지 않는다. */
analysisBaseAt?: string;
/** 과거이력 분석시각 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다. */
analysisBaseFrom?: string;
/** 과거이력 분석시각 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */
analysisBaseTo?: string;
/**
* 과거이력 강우시각(강우 시작 일시) 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다.
* 보내면 백엔드가 startDate·endDate보다 이 기간을 우선한다.
*/
rainStartedFrom?: string;
/** 과거이력 강우시각(강우 시작 일시) 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */
rainStartedTo?: string;
ctpvCd?: string;
sggCd?: string;
areaId?: string;
@@ -54,6 +66,14 @@ export const fetchWaterRiskResults = async (
});
if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd);
if (query.analysisBaseAt) params.set("analysisBaseAt", query.analysisBaseAt);
if (query.analysisBaseFrom && query.analysisBaseTo) {
params.set("analysisBaseFrom", query.analysisBaseFrom);
params.set("analysisBaseTo", query.analysisBaseTo);
}
if (query.rainStartedFrom && query.rainStartedTo) {
params.set("rainStartedFrom", query.rainStartedFrom);
params.set("rainStartedTo", query.rainStartedTo);
}
if (query.sggCd) params.set("sggCd", query.sggCd);
if (query.areaId) params.set("areaId", query.areaId);
if (query.riskStatus) params.set("riskStatus", query.riskStatus);
@@ -78,15 +98,12 @@ export const fetchWaterRiskResults = async (
export const fetchWaterRiskMapView = async (
row: WaterRiskResultRow,
): Promise<WaterRiskMapView> => {
if (!row.analysisDate || !row.ctpvCd || !row.sggCd) {
throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다.");
// 날짜·지역이 아니라 누른 행의 SWMM 실행 ID로 조회해야 같은 날 같은 지역 결과가 여러 건이어도 같은 결과를 그린다.
if (!row.executionId) {
throw new Error("지도 조회에 필요한 SWMM 실행 ID가 없습니다.");
}
const params = new URLSearchParams({
ctpvCd: row.ctpvCd,
sggCd: row.sggCd,
});
const response = await getData(
`v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`,
`v1/waterRisk/executions/${encodeURIComponent(row.executionId)}/mapView`,
);
return {
...response,
@@ -0,0 +1,105 @@
/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 기본값과 조회 검증에서 함께 사용한다. */
export const WATER_RISK_MAX_SEARCH_DAYS = 366;
const DAY_MS = 24 * 60 * 60 * 1000;
/** 날짜 입력을 UTC 일자 값으로 읽어 시간대와 일광절약시간 영향을 제거한다. */
const parseDate = (value: string): number | null => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const timestamp = Date.parse(`${value}T00:00:00Z`);
return Number.isFinite(timestamp) &&
new Date(timestamp).toISOString().slice(0, 10) === value
? timestamp
: null;
};
/** 선택 종료일을 포함해 서버가 허용하는 가장 이른 시작일이다. */
export const getWaterRiskMinStartDate = (
endDate: string,
): string | undefined => {
const end = parseDate(endDate);
return end === null
? undefined
: new Date(end - (WATER_RISK_MAX_SEARCH_DAYS - 1) * DAY_MS)
.toISOString()
.slice(0, 10);
};
/**
* 시간 단위 검색 입력(`datetime-local`, `step=3600`)의 값 형식 `yyyy-MM-ddTHH:00`이다.
* 분은 항상 00이며, 첫 번째 그룹은 날짜(yyyy-MM-dd)다.
*/
const HOUR_VALUE_PATTERN = /^(\d{4}-\d{2}-\d{2})T\d{2}:00$/;
/**
* `datetime-local` 입력값의 분을 00으로 맞춘다.
* 브라우저는 값의 분이 00이면 분 칸을 고정하지만, 빈 값에서 처음 입력하거나 직접 입력해 분이 들어온 경우는
* 스스로 고치지 않으므로 저장 전에 시간 단위로 내린다. 형식이 아니면(빈 값 포함) 그대로 둔다.
*/
export const toWaterRiskHourValue = (value: string) =>
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)
? `${value.slice(0, 13)}:00`
: value;
/** 로컬 시각을 시간 단위 입력값(yyyy-MM-ddTHH:00)으로 내린다. 기본 종료값과 미래 시각 제한에 쓴다. */
export const formatWaterRiskHourValue = (date: Date) => {
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
};
/** 시간 단위 입력값을 API 정시 일시(yyyy-MM-ddTHH:00:00)로 바꾼다. 빈 값은 빈 문자열이다. */
export const toWaterRiskApiDateTime = (value: string) =>
value ? `${value}:00` : "";
/**
* 시간 단위 기간을 백엔드 계약(시작·종료 함께, 정시, 시작 ≤ 종료, 날짜 기준 최대 366일)과
* 화면 조건(종료 ≤ 현재 정시)으로 검증한다. 오류가 없으면 빈 문자열을 반환한다.
* `required`가 아니면 시작·종료를 모두 비웠을 때 조건 없이 조회한다.
*/
const validateHourPeriod = (
from: string,
to: string,
currentHourValue: string,
{ label, required }: { label: string; required: boolean },
): string => {
if (!from && !to) {
return required ? `${label} 시작과 종료를 입력해야 합니다.` : "";
}
if (!from || !to) {
return required
? `${label} 시작과 종료를 입력해야 합니다.`
: `${label}은 시작과 종료를 모두 입력하거나 모두 비워야 합니다.`;
}
const fromMatch = HOUR_VALUE_PATTERN.exec(from);
const toMatch = HOUR_VALUE_PATTERN.exec(to);
const start = fromMatch ? parseDate(fromMatch[1]!) : null;
const end = toMatch ? parseDate(toMatch[1]!) : null;
if (start === null || end === null) return `올바른 ${label}을 입력해 주세요.`;
if (to > currentHourValue) return `${label} 종료는 현재 시각보다 늦을 수 없습니다.`;
if (from > to) return `${label} 시작은 종료보다 늦을 수 없습니다.`;
if ((end - start) / DAY_MS + 1 > WATER_RISK_MAX_SEARCH_DAYS) {
return `${label}은 최대 1년(366일)까지 조회할 수 있습니다.`;
}
return "";
};
/** 과거이력 강우시각(강우 시작 일시) 기간을 검증한다. 필수 조건이다. */
export const validateWaterRiskRainStartedPeriod = (
from: string,
to: string,
currentHourValue: string,
) =>
validateHourPeriod(from, to, currentHourValue, {
label: "강우시각",
required: true,
});
/** 과거이력 분석시각 기간을 검증한다. 선택 조건이며 모두 비우면 적용하지 않는다. */
export const validateWaterRiskAnalysisPeriod = (
from: string,
to: string,
currentHourValue: string,
) =>
validateHourPeriod(from, to, currentHourValue, {
label: "분석시각",
required: false,
});
@@ -4,15 +4,16 @@
*/
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef } from "react";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import {
WINDOW_POPUP_MESSAGE_TYPE,
type WindowPopupContentProps,
} from "@/features/windowPopup/model/types";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
} from "@/features/window-popup/model/types";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import {
StandardPopupLayout,
type UiHeadProps,
@@ -20,34 +21,59 @@ import {
} from "@common/business_components";
import type { UiTableSort } from "@common/business_components/ui/table";
import { Button } from "@common/components";
import {
formatWaterRiskHourValue,
getWaterRiskMinStartDate,
toWaterRiskApiDateTime,
toWaterRiskHourValue,
validateWaterRiskAnalysisPeriod,
validateWaterRiskRainStartedPeriod,
} from "./water-risk-date-range";
import {
fetchWaterRiskAreas,
fetchWaterRiskMapView,
fetchWaterRiskResults,
} from "./waterRiskClient";
} from "./water-risk-client";
import {
buildWaterRiskStatusLabel,
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
isWaterRiskDanger,
type WaterRiskAreaOption,
type WaterRiskResultPage,
type WaterRiskResultRow,
type WaterRiskSessionSnapshot,
} from "./waterRiskTypes";
type WaterRiskStatus,
} from "./water-risk-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import LoadingOverlay from "@/components/ui/loading-bar";
type WaterRiskResultMode = "current" | "history";
/**
* 과거이력 검색조건이다. 시각 값은 모두 시간 단위 입력값(yyyy-MM-ddTHH:00)이며
* 시작·종료 정시를 모두 포함해 조회한다. 현재 탭은 중점관리지역만 이 값을 쓴다.
*/
type ResultFilters = {
startDate: string;
endDate: string;
ctpvCd: string;
sggCd: string;
areaId: string;
riskStatus: "ALL" | "DANGER" | "NORMAL";
riskStatus: "ALL" | WaterRiskStatus;
/** 과거이력 분석시각 기간 시작. 비우면 분석시각 조건을 적용하지 않는다. */
analysisFrom: string;
/** 과거이력 분석시각 기간 종료. 시작과 함께 입력한다. */
analysisTo: string;
/** 과거이력 강우시각(강우 시작 일시) 기간 시작. 필수이며 기본은 최근 1년 시작일 00시다. */
rainStartedFrom: string;
/** 과거이력 강우시각(강우 시작 일시) 기간 종료. 필수이며 기본은 현재 정시다. */
rainStartedTo: string;
};
/** 과거이력 시각 범위 입력 키. 분석시각·강우시각 입력을 같은 모양으로 그릴 때 쓴다. */
type HourRangeKeys =
| { fromKey: "analysisFrom"; toKey: "analysisTo" }
| { fromKey: "rainStartedFrom"; toKey: "rainStartedTo" };
const WATER_RISK_PAGE_SIZE = 10;
const EMPTY_RESULT_PAGE: WaterRiskResultPage = {
items: [],
@@ -64,6 +90,8 @@ type WaterRiskTableRow = Record<string, unknown> & {
sidoName: string;
sggName: string;
areaName: string;
/** 대표실행 분석기준시각(excn.anls_crtr_dt)을 yyyy.MM.dd HH:mm으로 표시한 값 */
analysisBaseAt: string;
rainStartedAt: string;
currentAccumRainQty: string;
forecastRainQty: string;
@@ -76,53 +104,56 @@ const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [
{
id: "rowNo",
title: "순번",
widthPercent: 6,
widthPercent: 5,
sortable: true,
sortKey: "rowNumber",
},
{
id: "sidoName",
title: "시도",
widthPercent: 8,
widthPercent: 7,
sortable: true,
sortKey: "ctpvName",
},
{ id: "sggName", title: "시군구", widthPercent: 8, sortable: true },
{ id: "areaName", title: "중점관리지역", widthPercent: 14, sortable: true },
{ id: "sggName", title: "시군구", widthPercent: 7, sortable: true },
{ id: "areaName", title: "중점관리지역", widthPercent: 12, sortable: true },
// 대표실행 분석기준시각. 컬럼 id가 백엔드 정렬 키(analysisBaseAt)와 같다.
{ id: "analysisBaseAt", title: "분석시각", widthPercent: 11, sortable: true },
{
id: "rainStartedAt",
title: "강우시작시간",
widthPercent: 13,
title: "강우시작시각",
widthPercent: 11,
sortable: true,
},
{
id: "currentAccumRainQty",
title: "누적강우량(mm)",
widthPercent: 11,
widthPercent: 9,
align: "right",
sortable: true,
},
{
id: "forecastRainQty",
title: "예측강우량(mm)",
widthPercent: 11,
widthPercent: 9,
align: "right",
sortable: true,
},
{
id: "maxFloodingAt",
title: "최대 침수 예상시점",
widthPercent: 13,
widthPercent: 11,
sortable: true,
},
{
id: "maxFloodingFlow",
title: "최대 침수유량(m³/s)",
widthPercent: 10,
widthPercent: 9,
align: "right",
sortable: true,
},
{ id: "riskStatus", title: "위험 여부", widthPercent: 7 },
// "위험(매핑관로없음)" 문구가 들어가도록 기존보다 넓게 둔다.
{ id: "riskStatus", title: "위험 여부", widthPercent: 9 },
];
const formatOptionalNumber = (
@@ -150,18 +181,20 @@ const formatAnalysisBaseAt = (value?: string | null) => {
return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim();
};
/** 개발 DB의 과거 시험자료와 운영 최신자료를 함께 찾도록 최근 1년을 기본 조회기간으로 사용한다. */
/** 최초 진입과 검색 초기화에 사용하는 기본 조회조건이다. 강우시각은 최근 1년이다. */
const buildDefaultResultFilters = (): ResultFilters => {
const endDate = new Date();
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 365);
const now = new Date();
return {
startDate: formatLocalDate(startDate),
endDate: formatLocalDate(endDate),
ctpvCd: "ALL",
sggCd: "ALL",
areaId: "ALL",
riskStatus: "ALL",
// 분석시각 기간은 선택 조건이므로 기본·초기화 상태는 비워 둔다.
analysisFrom: "",
analysisTo: "",
// 강우시각은 필수 조건이다. 365일 전 00시 ~ 현재 정시(종료일 포함 최대 366일)를 기본으로 한다.
rainStartedFrom: `${getWaterRiskMinStartDate(formatLocalDate(now))!}T00:00`,
rainStartedTo: formatWaterRiskHourValue(now),
};
};
@@ -174,12 +207,16 @@ const postMessageToOpener = (
window.opener.postMessage({ type, payload }, window.location.origin);
};
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. */
const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") =>
riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. 위험(매핑관로없음)도 위험 배지를 쓴다. */
const getRiskBadgeClassName = (riskStatus: WaterRiskStatus) =>
isWaterRiskDanger(riskStatus) ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */
/**
* 목록 행을 대표 SWMM 실행 ID로 식별한다. 행 하나가 실행 하나이므로 같은 날·지역 행도 겹치지 않는다.
* 실행 ID가 없는 응답(구 BE)에서는 기존 날짜·시각·지역 조합으로 대체한다.
*/
const buildResultRowKey = (row: WaterRiskResultRow) =>
row.executionId ||
[
row.analysisDate ?? "",
row.rainStartedAt ?? "",
@@ -191,7 +228,10 @@ const buildResultRowKey = (row: WaterRiskResultRow) =>
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
// 최초 진입과 초기화 모두 현재 정시를 종료로 하는 최근 1년 강우시각을 사용한다.
const initialFilters = useMemo(() => buildDefaultResultFilters(), []);
// 과거이력 시각 입력은 현재 정시까지 고를 수 있다. 렌더링마다 다시 계산해 정시가 바뀌면 따라간다.
const currentHourValue = formatWaterRiskHourValue(new Date());
const [resultMode, setResultMode] = useState<WaterRiskResultMode>("current");
const [draftResultFilters, setDraftResultFilters] =
useState<ResultFilters>(initialFilters);
@@ -234,11 +274,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
let active = true;
setIsLoading(true);
setErrorMessage("");
const isHistory = resultMode === "history";
// 현재 탭은 진행 중 강우를 오늘까지 최근 1년 강우시작일로 찾고, 과거이력의 적용 기간이 이를 제한하지 않게 분리한다.
// 과거이력은 적용 시 검증을 통과한 강우시각 기간을 보내며, 강우시작일도 그 날짜로 맞춘다.
const requestToday = formatLocalDate(new Date());
const startDate = isHistory
? appliedResultFilters.rainStartedFrom.slice(0, 10)
: getWaterRiskMinStartDate(requestToday)!;
const endDate = isHistory
? appliedResultFilters.rainStartedTo.slice(0, 10)
: requestToday;
// 과거이력의 분석시각 기간은 적용 시 검증을 통과한 값이며, 비어 있으면 조건 없이 조회한다.
const analysisBaseFrom = isHistory
? toWaterRiskApiDateTime(appliedResultFilters.analysisFrom)
: "";
const analysisBaseTo = isHistory
? toWaterRiskApiDateTime(appliedResultFilters.analysisTo)
: "";
const rainStartedFrom = isHistory
? toWaterRiskApiDateTime(appliedResultFilters.rainStartedFrom)
: "";
const rainStartedTo = isHistory
? toWaterRiskApiDateTime(appliedResultFilters.rainStartedTo)
: "";
fetchWaterRiskResults({
startDate: appliedResultFilters.startDate,
endDate: appliedResultFilters.endDate,
startDate,
endDate,
analysisBaseAt:
resultMode === "current" ? currentAnalysisBaseAt : undefined,
analysisBaseFrom: analysisBaseFrom || undefined,
analysisBaseTo: analysisBaseTo || undefined,
rainStartedFrom: rainStartedFrom || undefined,
rainStartedTo: rainStartedTo || undefined,
ctpvCd:
resultMode === "current" || appliedResultFilters.ctpvCd === "ALL"
? undefined
@@ -302,9 +369,14 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
const nextSnapshot = (event.data.payload ??
EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot;
if (nextSnapshot.selectedAreaId) {
const matchingRow = resultPage.items.find(
(row) => row.areaId === nextSnapshot.selectedAreaId,
);
// 지도에 표시 중인 실행 ID가 있으면 그 행만 선택한다. 같은 지역 첫 행으로 바꾸면 다른 결과가 선택돼 보인다.
const matchingRow = nextSnapshot.selectedExecutionId
? resultPage.items.find(
(row) => row.executionId === nextSnapshot.selectedExecutionId,
)
: resultPage.items.find(
(row) => row.areaId === nextSnapshot.selectedAreaId,
);
if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow));
}
};
@@ -407,6 +479,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
sidoName: row.sidoName || "-",
sggName: row.sggName || "-",
areaName: row.areaName,
analysisBaseAt: formatAnalysisBaseAt(row.analysisBaseAt),
rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt),
currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1),
forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1),
@@ -420,15 +493,31 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
const handleApplyResultFilters = () => {
if (!draftResultFilters.startDate || !draftResultFilters.endDate) {
setErrorMessage("조회 시작일과 종료일을 입력해야 합니다.");
return;
}
if (draftResultFilters.startDate > draftResultFilters.endDate) {
setErrorMessage("조회 시작일은 종료일보다 늦을 수 없습니다.");
return;
if (resultMode === "history") {
const nowHourValue = formatWaterRiskHourValue(new Date());
// 분석시각 기간은 선택 조건이다. 입력한 경우에만 시작·종료 짝, 순서, 기간, 현재 정시 이하를 확인한다.
const analysisPeriodError = validateWaterRiskAnalysisPeriod(
draftResultFilters.analysisFrom,
draftResultFilters.analysisTo,
nowHourValue,
);
if (analysisPeriodError) {
setErrorMessage(analysisPeriodError);
return;
}
// 강우시각 기간은 필수 조건이다. 시작·종료 짝, 순서, 최대 366일, 현재 정시 이하를 확인한다.
const rainStartedPeriodError = validateWaterRiskRainStartedPeriod(
draftResultFilters.rainStartedFrom,
draftResultFilters.rainStartedTo,
nowHourValue,
);
if (rainStartedPeriodError) {
setErrorMessage(rainStartedPeriodError);
return;
}
}
setErrorMessage("");
setResultCurrentPage(1);
setAppliedResultFilters({ ...draftResultFilters });
setReloadVersion((version) => version + 1);
@@ -449,29 +538,78 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
setSelectedResultKey("");
};
/** 늦게 끝난 이전 행 요청이 최신 선택을 덮지 않도록 식별한다. */
const mapRequestRef = useRef<string>("");
/** 선택 행의 실제 GeoJSON을 조회한 뒤 부모 지도 창에 전달한다. */
const handleSelectResultRow = async (row: WaterRiskResultRow) => {
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
mapRequestRef.current = requestId;
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, {
phase: "loading", requestId,
});
setSelectedResultKey(buildResultRowKey(row));
setIsMapLoading(true);
setErrorMessage("");
try {
const mapView = await fetchWaterRiskMapView(row);
if (mapRequestRef.current !== requestId) return;
postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT, {
requestId,
areaId: row.areaId,
resultRow: row,
mapView,
});
} catch (error) {
if (mapRequestRef.current !== requestId) return;
setErrorMessage(
error instanceof Error
? error.message
: "통수위험 지도 결과를 조회하지 못했습니다.",
);
} finally {
setIsMapLoading(false);
if (mapRequestRef.current === requestId) setIsMapLoading(false);
}
};
/**
* 침수예측현황과 같은 형태(라벨 + 시작 `~` 종료 `datetime-local`)로 과거이력 시각 범위를 그린다.
* `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정하고, 분이 들어온 경우는 저장 전에 00으로 내린다.
* 현재 정시 이후는 고를 수 없게 `max`만 둔다(`min`은 step 기준점을 바꾸므로 두지 않는다).
*/
const renderHourRange = (label: string, { fromKey, toKey }: HourRangeKeys) => (
<div className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
{label}
</div>
<input
type="datetime-local"
step={3600}
max={currentHourValue}
value={draftResultFilters[fromKey]}
onChange={(event) => {
const nextValue = toWaterRiskHourValue(event.target.value);
setDraftResultFilters((current) => ({ ...current, [fromKey]: nextValue }));
}}
aria-label={`${label} 시작`}
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-xl font-medium text-label">~</span>
<input
type="datetime-local"
step={3600}
max={currentHourValue}
value={draftResultFilters[toKey]}
onChange={(event) => {
const nextValue = toWaterRiskHourValue(event.target.value);
setDraftResultFilters((current) => ({ ...current, [toKey]: nextValue }));
}}
aria-label={`${label} 종료`}
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</div>
);
return (
<StandardPopupLayout title={windowPopupDefinitions.flowRiskPopup.title}>
<section
@@ -505,41 +643,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</div>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
{/* 과거이력 검색조건 순서: 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태 */}
{resultMode === "history" ? (
<>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
강우 발생기간
</div>
<span className="flex shrink-0 items-center gap-2">
<input
type="date"
value={draftResultFilters.startDate}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
startDate: event.target.value,
}))
}
aria-label="조회 시작일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-xl font-medium text-label">~</span>
<input
type="date"
value={draftResultFilters.endDate}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
endDate: event.target.value,
}))
}
aria-label="조회 종료일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</span>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
@@ -624,27 +730,39 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</label>
{resultMode === "history" ? (
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
위험상태
</div>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target
.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
<option value="DANGER">위험</option>
<option value="NORMAL">정상</option>
</select>
</label>
<>
{/* 분석시각(선택)·강우시각(필수): 시간 단위로만 고르며 시작·종료 정시를 모두 포함해 조회한다. */}
{renderHourRange("분석시각", {
fromKey: "analysisFrom",
toKey: "analysisTo",
})}
{renderHourRange("강우시각", {
fromKey: "rainStartedFrom",
toKey: "rainStartedTo",
})}
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
위험상태
</div>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target
.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
<option value="DANGER">위험</option>
<option value="DANGER_UNMAPPED">위험(매핑관로없음)</option>
<option value="NORMAL">정상</option>
</select>
</label>
</>
) : null}
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
@@ -659,10 +777,14 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
type="button"
color="white"
onClick={() => {
setDraftResultFilters(initialFilters);
setAppliedResultFilters(initialFilters);
// 초기화 시점의 기본기간·전체 조건으로 첫 페이지를 다시 조회한다.
const defaultFilters = buildDefaultResultFilters();
setDraftResultFilters(defaultFilters);
setAppliedResultFilters(defaultFilters);
setResultCurrentPage(1);
setSelectedResultKey("");
setErrorMessage("");
setReloadVersion((version) => version + 1);
}}
>
초기화
@@ -670,6 +792,12 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</div>
</PopupSearchPanel>
{errorMessage ? (
<div role="alert" className="shrink-0 px-1 text-xl text-destructive">
{errorMessage}
</div>
) : null}
<MapPopupDataTable
rows={tableRows}
headlist={WATER_RISK_RESULT_HEADLIST}
@@ -684,31 +812,39 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
renderCell={(row, headId) =>
headId === "riskStatus" ? (
<span
className={`sn-popup-badge min-w-[5.6rem] ${getRiskBadgeClassName(row.riskStatus)}`}
className={`sn-popup-badge min-w-[5.6rem] whitespace-nowrap ${getRiskBadgeClassName(row.riskStatus)}`}
>
{buildWaterRiskStatusLabel(row.riskStatus)}
</span>
) : undefined
}
scrollMode="horizontal"
minTableWidth="124rem"
// 분석시각 컬럼 추가로 기존 124rem보다 넓힌다.
minTableWidth="136rem"
emptyMessage="조회된 결과가 없습니다."
sort={resultSort}
onSortChange={handleSortChange}
actions={
<span className="text-xl font-normal text-muted-foreground">
SWMM 시뮬레이션 기반 통수위험 분석 결과 (GIS 매핑 관로 중
Capacity 0.8 이상이 1개라도 있으면{" "}
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
<span className="font-bold text-[#1677ff]">정상</span>)
</span>
}
/>
{errorMessage ? (
<div aria-live="polite" className="px-1 text-xl text-destructive">
{errorMessage}
</div>
) : null}
{/*
인수테스트 요구: 판정 기준을 목록 머리글 오른쪽에서 침수예측현황과 같은 하단 위치로 옮기고,
분석 원천·실행 주기·침수 항목 설명을 글머리표로 함께 표시한다.
*/}
<PopupFooterNote
items={[
<>
분석 결과 관로의 Capacity(통수능) 값이 0.8 이상이 1개라도 있으면{" "}
<span className="font-bold text-[#ff1c15]">위험</span>, 매핑되지
않은 관로만 0.8 이상이면{" "}
<span className="font-bold text-[#ff1c15]">위험(매핑관로없음)</span>,
없으면 <span className="font-bold text-[#1677ff]">정상</span>
</>,
"통수능이 위험에 해당하지만 지도화면에 해당 관로를 매핑할 수 없을 때, 빨간색 관로는 보이지 않을 수 있습니다.",
"분석 실행 시기 : 통수위험은 관측강우가 0mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행",
"분석 방법 : 기상청 향후 6시간 예측강우를 20개소 중점관리지역의 관망모델(SWMM모델)에 연동하여 관로 통수량을 분석하여 통수위험여부를 분석",
"최대 침수예상시점과 최대침수유량은 맨홀 월류량이 있을때 그 시점과 값이 나타나는것입니다.",
]}
/>
</section>
</StandardPopupLayout>
);
@@ -1,7 +1,13 @@
/** 통수위험 분석 화면에서 사용하는 타입·상수와 표시값 변환을 정의한다. */
"use client";
export type WaterRiskStatus = "DANGER" | "NORMAL";
/**
* 백엔드 위험상태 코드이다.
* DANGER: 유효 GIS 매핑 관로 중 Capacity 기준 이상 관로가 있음.
* DANGER_UNMAPPED: 매핑 위험 관로는 없고, 유효 GIS 매핑이 없는 관로만 기준 이상임.
* NORMAL: 기준 이상 관로가 없음.
*/
export type WaterRiskStatus = "DANGER" | "DANGER_UNMAPPED" | "NORMAL";
export type WaterRiskCalculationMethod = "SWMM";
/** 대표실행에서 capacity가 가장 큰 관로의 실제 SWMM 결과이다. */
@@ -14,6 +20,9 @@ export type WaterRiskPipeSummary = {
/** 강우이벤트 대표실행 한 건을 팝업 목록에 표시하는 모델이다. */
export type WaterRiskResultRow = {
/** 대표 SWMM 실행 ID(UUID). 목록 행 식별과 지도 조회(executions/{executionId}/mapView) 키다. */
executionId: string;
/** 강우시작일(yyyy-MM-dd). 분석시각(analysisBaseAt)과 다른 값이다. */
analysisDate?: string;
analysisBaseAt?: string | null;
rainStartedAt?: string | null;
@@ -36,14 +45,25 @@ export type WaterRiskResultRow = {
/** 부모 지도와 별도 팝업 사이에서 공유하는 최소 선택 상태이다. */
export type WaterRiskSessionSnapshot = {
selectedAreaId: string | null;
/** 지도에 표시 중인 SWMM 실행 ID. 같은 지역 행이 여러 개여도 팝업이 정확한 행을 다시 선택하게 한다. */
selectedExecutionId: string | null;
};
export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = {
selectedAreaId: null,
selectedExecutionId: null,
};
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) =>
status === "DANGER" ? "위험" : "정상";
/** 위험상태 코드를 목록 배지·지도 라벨·정보카드에 쓰는 화면 문구로 바꾼다. */
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) => {
if (status === "DANGER") return "위험";
if (status === "DANGER_UNMAPPED") return "위험(매핑관로없음)";
return "정상";
};
/** 배지·지도 색을 위험 계열(빨강)로 표시할 상태인지 판별한다. 매핑관로없음도 위험 색을 쓴다. */
export const isWaterRiskDanger = (status: WaterRiskStatus | null | undefined) =>
status === "DANGER" || status === "DANGER_UNMAPPED";
export type WaterRiskAreaOption = {
areaId: string;
@@ -70,7 +90,14 @@ export type WaterRiskGeoJsonGeometry = {
/** 선택한 대표실행의 실제 중점관리지역 및 관로 지도 결과이다. */
export type WaterRiskMapView = {
/** 지도에 표시한 SWMM 실행 ID. 요청한 목록 행의 executionId와 같다. */
executionId?: string;
/** 강우시작일(yyyy-MM-dd) */
analysisDate: string;
/** 분석기준시각 */
analysisBaseAt?: string | null;
/** 강우시작시각 */
rainStartedAt?: string | null;
areaId: string;
areaName: string;
ctpvCd: string;
@@ -0,0 +1,72 @@
/** 조사결과·침수이력의 지도 스타일. 기존 값과 처리 순서를 유지한다. */
import Fill from "ol/style/Fill.js";
import Icon from "ol/style/Icon.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import Text from "ol/style/Text.js";
import {
EXMN_GRADE_COLORS,
EXMN_RESULT_MANHOLE_ICON_SRC,
} from "../model/map-constants";
export const getExmnGradeColor = (feature: any) => {
const grade = Number(feature?.get?.("structuralGrade") ?? 0);
return EXMN_GRADE_COLORS[grade] ?? "#2563eb";
};
export const createExmnPipeStyle = (feature: any) =>
new Style({
stroke: new Stroke({
color: getExmnGradeColor(feature),
width: 4,
}),
});
// 레이어 트리 범례와 지도 피처가 동일한 기존 맨홀 형태의 보라색 심볼을 사용한다.
export const createExmnManholeStyle = () =>
new Style({
image: new Icon({
src: EXMN_RESULT_MANHOLE_ICON_SRC,
anchor: [0.5, 0.5],
anchorXUnits: "fraction",
anchorYUnits: "fraction",
}),
});
export const createFldngHstryAreaStyle = (label: string) =>
new Style({
fill: new Fill({
color: "rgba(30, 136, 229, 0.16)",
}),
stroke: new Stroke({
color: "rgba(30, 136, 229, 0.95)",
width: 3,
}),
text: new Text({
text: label,
font: "700 12px sans-serif",
fill: new Fill({ color: "#ffffff" }),
backgroundFill: new Fill({ color: "rgba(15, 23, 42, 0.88)" }),
padding: [4, 7, 4, 7],
overflow: true,
}),
zIndex: 9994,
});
export const createFldngHstryDepthStyle = (label: string, color: string) =>
new Style({
fill: new Fill({
color,
}),
stroke: new Stroke({
color: "rgba(71, 85, 105, 0.72)",
width: 1.4,
}),
text: new Text({
text: label,
font: "700 11px sans-serif",
fill: new Fill({ color: "#0f172a" }),
overflow: true,
}),
zIndex: 9993,
});
@@ -0,0 +1,112 @@
/** GIS 편집 FID·속성·선택 변환 보조. 기존 값과 처리 순서를 유지한다. */
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type { GisEditSessionSnapshot } from "@/app/(map-layout)/map/components/gis-edit/gis-edit-types";
import { GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO } from "../model/map-constants";
export const expandGisEditPipeSnapExtent = (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 * GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO;
const bufferY = height * GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO;
return [minX - bufferX, minY - bufferY, maxX + bufferX, maxY + bufferY];
};
export const normalizeGisEditFid = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
const withoutNamespace = raw.includes(":")
? (raw.split(":").pop() ?? raw)
: raw;
return withoutNamespace.startsWith("tb_ms_swage_ppl.")
? withoutNamespace.slice("tb_ms_swage_ppl.".length)
: withoutNamespace;
};
export const getGisEditFeatureFid = (data: any) =>
normalizeGisEditFid(
data?.get?.("fid") ??
data?.values_?.fid ??
data?.fid ??
data?.getId?.() ??
data?.id_ ??
"",
);
export const isGisEditPreviewFeatureData = (data: any) =>
Boolean(String(data?.get?.("changeId") ?? data?.changeId ?? "").trim());
export const highlightGisEditSelectedFeature = (featureData: any) => {
if (
!featureData ||
typeof featureData.getGeometry !== "function" ||
typeof featureData.setStyle !== "function"
) {
return;
}
const select = GisApp.Module.select as any;
select?.selectFeature?.(featureData, true);
};
export const isVisibleGisEditSessionChange = (
change: GisEditSessionSnapshot["changes"][number],
) => !change.attributeOnly || Boolean(change.basicInfoSavedAt);
export const getLatestNumericEditSn = (histories: any[]) =>
histories.reduce<number | null>((latest, history) => {
const editSn = String(history?.editSn ?? "").trim();
if (!/^\d+$/.test(editSn)) return latest;
const value = Number(editSn);
return latest === null || value > latest ? value : latest;
}, null);
export const getGisEditAttributeText = (
attributes: Record<string, unknown> | null | undefined,
keys: string[],
) => {
for (const key of keys) {
const value = String(attributes?.[key] ?? "").trim();
if (value) return value;
}
return "";
};
export const buildGisEditHiddenPipeFilter = (fids: string[]) => {
const normalizedFids = Array.from(
new Set(
fids
.map((fid) => normalizeGisEditFid(fid))
.filter((fid) => /^\d+$/.test(fid)),
),
);
if (normalizedFids.length === 0) return null;
return `fid NOT IN (${normalizedFids.join(",")})`;
};
export const findInvalidPipeRequiredChange = (
changes: Array<{
changeId: string;
editType: string;
clientTempId?: string;
attributes?: Record<string, unknown> | null;
}>,
) =>
changes.find((change) => {
const attributes = change.attributes ?? {};
const hasAttributePayload = Object.keys(attributes).length > 0;
if (change.editType !== "ADD" && !hasAttributePayload) return false;
const mngInstCd = getGisEditAttributeText(attributes, ["mngInstCd"]);
const fcltMngNo = getGisEditAttributeText(attributes, ["fcltMngNo"]);
const instMngNo = getGisEditAttributeText(attributes, ["instMngNo"]);
const usgCd = getGisEditAttributeText(attributes, ["usgCd"]);
return (
!mngInstCd ||
!fcltMngNo ||
/^NEW-/i.test(fcltMngNo) ||
!instMngNo ||
/^NEW-/i.test(instMngNo) ||
!usgCd
);
});
@@ -0,0 +1,22 @@
import { SINKHOLE_TREE_ICON_NAME } from "../model/map-constants";
export const normalizeTreeRowSpacing = (data: any[]) => {
if (!Array.isArray(data)) return data;
return data.map((item) => {
if (item?.lyrNm !== "지반침하정보") {
return item;
}
return {
...item,
iconNm: item.iconNm || SINKHOLE_TREE_ICON_NAME,
gobjTy: "",
};
});
};
export {
extractUsageCodesFromLayerInfo,
isLayerVisibleAtZoom,
extractCheckedPipeUsageStyles,
} from "@/features/map-layers/lib/flow-direction-selection";
@@ -0,0 +1,26 @@
/** 지도 화면의 응답·날짜·키보드 입력 판별. 기존 값과 처리 순서를 유지한다. */
/** 침수예측 오버레이용 12자리 분석시각을 화면 표시 형식으로 변환한다. */
export const formatFloodPredictionDateTime = (value: unknown) => {
const text = String(value ?? "").trim();
return /^\d{12}$/.test(text)
? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)} ${text.slice(8, 10)}:${text.slice(10, 12)}`
: text || "-";
};
export const geometryObjectToKey = (
geometry: Record<string, unknown> | null,
) => (geometry ? JSON.stringify(geometry) : "");
export const isTypingElement = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return (
tagName === "input" ||
tagName === "textarea" ||
tagName === "select" ||
target.isContentEditable
);
};
export { getFeatureCollection } from "@/features/map-layers/lib/geojson-response";
@@ -0,0 +1,146 @@
/** 시설 선택 결과의 우측 탭 식별·표시 변환. 기존 값과 처리 순서를 유지한다. */
import { type RightPanelTabCategory } from "@/app/(map-layout)/map/components/right-panel/right-panel-bookmark-tabs";
import { FLDNG_HSTRY_LAYER_NAME } from "@/entities/flood-history/model/flood-history-types";
export const getSelectedFeatureLayerTypeName = (data: any) => {
const previewLayerId = String(data?.get?.("layerId") ?? "").trim();
if (String(data?.get?.("changeId") ?? "").trim() && previewLayerId) {
return previewLayerId;
}
if (typeof data?.getId === "function") {
return String(data.getId() ?? "").split(".")[0];
}
if (typeof data?.id_ === "string") {
return String(data.id_).split(".")[0];
}
return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim();
};
export const getRightPanelFeatureIdentifier = (data: any) => {
const rawId =
data?.get?.("changeId") ??
data?.get?.("fid") ??
data?.values_?.fid ??
data?.fid ??
data?.getId?.() ??
data?.id_ ??
data?.fcltMngNo ??
data?.values_?.fclt_mng_no ??
data?.swtrmtFcltCd ??
data?.values_?.swtrmt_fclt_cd ??
"";
return String(rawId).trim();
};
export const getRightPanelFeatureTitle = (data: any) => {
const layerTypeName = getSelectedFeatureLayerTypeName(data);
switch (layerTypeName) {
case "tb_ms_swage_ppl":
return "하수관로";
case "tb_ms_swage_mhl":
return "하수맨홀";
case "tb_ms_swage_pmstn":
return "펌프장";
case "tb_ms_stofcbr":
return "우수토실";
case "tb_il_swage_prcs_fclt":
return "처리시설";
case "tb_ms_swage_prcs_zone":
return "처리구역";
case "tb_ms_fldng_wtlv_map":
return FLDNG_HSTRY_LAYER_NAME;
case "tb_ms_fldng_dpth_map":
return "침수흔적심선";
case "om_dt_untrt_swg_rain":
return "중점우수";
default:
return (
String(data?.lyrNm ?? data?.values_?.lyrNm ?? "상세").trim() || "상세"
);
}
};
// OpenLayers Feature와 plain object 양쪽에서 탭 표시값 후보를 같은 순서로 읽는다.
export const getRightPanelFeatureProperty = (data: any, keys: string[]) => {
for (const key of keys) {
const value = data?.get?.(key) ?? data?.values_?.[key] ?? data?.[key];
const normalizedValue = String(value ?? "").trim();
if (normalizedValue) return normalizedValue;
}
return "";
};
export const getRightPanelTabCategory = (data: any): RightPanelTabCategory => {
const surveyOverlayType = getRightPanelFeatureProperty(data, [
"surveyOverlayType",
]);
if (surveyOverlayType === "EXMN_RESULT") return "exmnSurvey";
const layerTypeName = getSelectedFeatureLayerTypeName(data);
if (layerTypeName === "tb_ms_swage_ppl") return "pipe";
if (layerTypeName === "tb_il_swage_prcs_fclt") return "treatmentFacility";
if (layerTypeName === "tb_ms_swage_prcs_zone") return "treatmentZone";
return "defaultFeature";
};
export const getRightPanelFeatureDisplayLabel = (
data: any,
category: RightPanelTabCategory,
) => {
if (category === "exmnSurvey") {
const managementNo =
getRightPanelFeatureProperty(data, [
"gisManagementNo",
"fclt_mng_no",
"fcltMngNo",
]) || "-";
const jobType = getRightPanelFeatureProperty(data, ["jobType"]);
const surveyLabel =
jobType === "PPL"
? "관로조사"
: jobType === "MNHL"
? "맨홀조사"
: "조사사업";
return `${surveyLabel}(${managementNo})`;
}
if (category === "pipe") {
const managementNo =
getRightPanelFeatureProperty(data, [
"fclt_mng_no",
"fcltMngNo",
"pipeId",
"fid",
]) ||
getRightPanelFeatureIdentifier(data) ||
"-";
return `관로(${managementNo})`;
}
if (category === "treatmentFacility") {
const facilityName =
getRightPanelFeatureProperty(data, ["swtrmt_fclt_nm", "swtrmtFcltNm"]) ||
getRightPanelFeatureProperty(data, ["swtrmt_fclt_cd", "swtrmtFcltCd"]) ||
"-";
return `처리시설(${facilityName})`;
}
if (category === "treatmentZone") {
const zoneName =
getRightPanelFeatureProperty(data, ["swtrmt_zone_nm", "swtrmtZoneNm"]) ||
getRightPanelFeatureProperty(data, [
"swtrmt_zone_sn",
"swtrmtZoneSn",
"swtrmt_fclt_cd",
"swtrmtFcltCd",
]) ||
"-";
return `처리구역(${zoneName})`;
}
return getRightPanelFeatureTitle(data);
};
@@ -0,0 +1,312 @@
/** 지반침하 선택 데이터와 도형·표시 스타일 변환. 기존 값과 처리 순서를 유지한다. */
import Fill from "ol/style/Fill.js";
import CircleStyle from "ol/style/Circle.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import Text from "ol/style/Text.js";
import {
SINKHOLE_CONTROL_NODE_ID,
SINKHOLE_LAYER_NAME,
SINKHOLE_LAYER_TYPE,
type SinkholeDetail,
type SinkholeListItem,
} from "@/app/(map-layout)/map/components/sinkhole/sinkhole-types";
import {
SINKHOLE_POINT_PALETTE,
SINKHOLE_TREE_ICON_NAME,
} from "../model/map-constants";
// 지반침하 목록 API가 정상일 때는 x/y를 바로 Point feature로 바꿔 geometry 전용 API 장애를 우회할 수 있다.
export const buildSinkholeFeatureCollectionFromItems = (
items: SinkholeListItem[],
) => ({
type: "FeatureCollection",
features: items
.map((item) => {
const x = Number(item?.x);
const y = Number(item?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null;
}
return {
type: "Feature",
id: `${SINKHOLE_LAYER_TYPE}.${String(item?.acdntNo ?? "").trim()}`,
geometry: {
type: "Point",
coordinates: [x, y],
},
properties: {
...item,
lyrTypeNm: SINKHOLE_LAYER_TYPE,
x,
y,
},
};
})
.filter(Boolean),
});
export type SinkholePointVariant = keyof typeof SINKHOLE_POINT_PALETTE;
export const resolveSinkholePointVariant = (
_ocrnYmd?: string | null,
): SinkholePointVariant => "blue";
export const getSinkholeSelectionValue = (data: any, key: string) =>
data?.[key] ??
data?.values_?.[key] ??
data?.get?.(key) ??
data?.properties?.[key];
export const getSinkholeAccidentId = (data: any) =>
String(getSinkholeSelectionValue(data, "acdntNo") ?? "").trim();
export const getSinkholeFallbackDetailFromSelection = (
data: any,
): Partial<SinkholeDetail> | null => {
const acdntNo = getSinkholeAccidentId(data);
if (!acdntNo) return null;
const point = getSinkholePointFromSelection(data);
return {
acdntNo,
ctpvNm: String(getSinkholeSelectionValue(data, "ctpvNm") ?? ""),
sggNm: String(getSinkholeSelectionValue(data, "sggNm") ?? ""),
emdNm: String(getSinkholeSelectionValue(data, "emdNm") ?? ""),
daddr: String(getSinkholeSelectionValue(data, "daddr") ?? ""),
fullAddress: String(
getSinkholeSelectionValue(data, "fullAddress") ??
getSinkholeSelectionValue(data, "daddr") ??
"",
),
ocrnYmd: String(getSinkholeSelectionValue(data, "ocrnYmd") ?? ""),
restoreStatus: String(
getSinkholeSelectionValue(data, "restoreStatus") ?? "",
),
restoreCompletedYmd: String(
getSinkholeSelectionValue(data, "restoreCompletedYmd") ?? "",
),
coordStatus: String(
getSinkholeSelectionValue(data, "coordStatus") ??
getSinkholeSelectionValue(data, "geocodeStatus") ??
"",
),
geocodeStatus: String(
getSinkholeSelectionValue(data, "geocodeStatus") ??
getSinkholeSelectionValue(data, "coordStatus") ??
"",
),
geocodeSource: String(
getSinkholeSelectionValue(data, "geocodeSource") ?? "",
),
geocodeErrorMessage: String(
getSinkholeSelectionValue(data, "geocodeErrorMessage") ?? "",
),
latitude: getSinkholeSelectionValue(data, "latitude") ?? null,
longitude: getSinkholeSelectionValue(data, "longitude") ?? null,
x: point?.x ?? getSinkholeSelectionValue(data, "x") ?? null,
y: point?.y ?? getSinkholeSelectionValue(data, "y") ?? null,
sizeWidth: getSinkholeSelectionValue(data, "sizeWidth") ?? null,
sizeLength: getSinkholeSelectionValue(data, "sizeLength") ?? null,
sizeDepth: getSinkholeSelectionValue(data, "sizeDepth") ?? null,
detailCause: getSinkholeSelectionValue(data, "detailCause") ?? null,
deathCount: getSinkholeSelectionValue(data, "deathCount") ?? null,
injuryCount: getSinkholeSelectionValue(data, "injuryCount") ?? null,
vehicleDamageCount:
getSinkholeSelectionValue(data, "vehicleDamageCount") ?? null,
};
};
export const getSinkholePointFromSelection = (
data: any,
): { x: number; y: number } | null => {
const directX = Number(getSinkholeSelectionValue(data, "x"));
const directY = Number(getSinkholeSelectionValue(data, "y"));
if (Number.isFinite(directX) && Number.isFinite(directY)) {
return { x: directX, y: directY };
}
const geometry = data?.getGeometry?.();
if (geometry?.getType?.() === "Point") {
const coordinates = geometry.getCoordinates?.();
const x = Number(coordinates?.[0]);
const y = Number(coordinates?.[1]);
if (Number.isFinite(x) && Number.isFinite(y)) {
return { x, y };
}
}
return null;
};
export const createSinkholePointStyle = (
variant: SinkholePointVariant = "blue",
selected = false,
) =>
new Style({
image: new CircleStyle({
radius: selected ? 9 : 7,
fill: new Fill({
color: selected
? SINKHOLE_POINT_PALETTE[variant].fillSelected
: SINKHOLE_POINT_PALETTE[variant].fill,
}),
stroke: new Stroke({
color: selected
? SINKHOLE_POINT_PALETTE[variant].strokeSelected
: SINKHOLE_POINT_PALETTE[variant].stroke,
width: selected ? 2.6 : 2,
}),
}),
zIndex: selected ? 9997 : 9994,
});
export const createSinkholeRadiusStyle = (
variant: SinkholePointVariant = "blue",
) =>
new Style({
stroke: new Stroke({
color: SINKHOLE_POINT_PALETTE[variant].radiusStroke,
width: 2.2,
lineDash: [8, 6],
}),
fill: new Fill({
color: SINKHOLE_POINT_PALETTE[variant].radiusFill,
}),
zIndex: 9993,
});
// 반경 원 상단에 실제 적용 거리값을 표시하는 선택 전용 지도 라벨 스타일이다.
export const createSinkholeRadiusLabelStyle = (
radiusMeters: number,
variant: SinkholePointVariant = "blue",
) =>
new Style({
text: new Text({
text: `반경 ${radiusMeters.toLocaleString("ko-KR")}m`,
font: "600 13px Pretendard, sans-serif",
textAlign: "center",
offsetY: -14,
padding: [4, 7, 4, 7],
fill: new Fill({ color: SINKHOLE_POINT_PALETTE[variant].strokeSelected }),
stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 3 }),
backgroundFill: new Fill({ color: "rgba(255, 255, 255, 0.9)" }),
backgroundStroke: new Stroke({
color: SINKHOLE_POINT_PALETTE[variant].radiusStroke,
width: 1.5,
}),
}),
zIndex: 9998,
});
export const createSinkholeFocusedPipeStyle = () => [
new Style({
stroke: new Stroke({
color: "rgba(88, 28, 135, 0.22)",
width: 18,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9997,
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 255, 255, 0.96)",
width: 12,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9998,
}),
new Style({
stroke: new Stroke({
color: "rgba(37, 99, 235, 0.98)",
width: 8,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9999,
}),
];
export const appendSinkholeControlNode = (data: any[]) => {
if (!Array.isArray(data)) return data;
const existingRoot = data.find((item) => {
const nodeId = String(item?.lyrSn ?? item?.id ?? "");
const layerName = String(item?.lyrNm ?? item?.name ?? "").trim();
const layerTypeName = String(
item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "",
).trim();
return (
nodeId === SINKHOLE_CONTROL_NODE_ID ||
layerName === SINKHOLE_LAYER_NAME ||
layerTypeName === SINKHOLE_LAYER_TYPE
);
});
const existingRootId =
existingRoot?.lyrSn ?? existingRoot?.id ?? SINKHOLE_CONTROL_NODE_ID;
const filtered = data.filter((item) => {
const nodeId = String(item?.lyrSn ?? item?.id ?? "");
const upperId = String(item?.upperLyrSn ?? item?.upId ?? "");
const layerName = String(item?.lyrNm ?? item?.name ?? "").trim();
const layerTypeName = String(
item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "",
).trim();
if (nodeId === String(existingRootId)) return false;
if (upperId === String(existingRootId)) return false;
if (!existingRoot && nodeId === SINKHOLE_CONTROL_NODE_ID) return false;
if (!existingRoot && upperId === SINKHOLE_CONTROL_NODE_ID) return false;
if (!existingRoot && layerName === SINKHOLE_LAYER_NAME) return false;
if (!existingRoot && layerTypeName === SINKHOLE_LAYER_TYPE) return false;
return true;
});
const rootNode = existingRoot
? {
...existingRoot,
lyrSn: existingRootId,
id: existingRootId,
gobjTy: "",
iconNm: existingRoot.iconNm || SINKHOLE_TREE_ICON_NAME,
clickPsblYn: "N",
actlLyrYn: "N",
children: null,
}
: {
lyrSn: SINKHOLE_CONTROL_NODE_ID,
id: SINKHOLE_CONTROL_NODE_ID,
lyrNm: SINKHOLE_LAYER_NAME,
upperLyrSn: null,
upId: null,
sortOrdr: "3",
drawOrdr: null,
flterSql: null,
gobjTy: "",
lyrTyNm: SINKHOLE_LAYER_TYPE,
useYn: "Y",
minViewSize: null,
iconNm: SINKHOLE_TREE_ICON_NAME,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
fontIntnColor: null,
fontExtnColor: null,
fontExtnWdth: null,
lnWdth: null,
lnColor: null,
polgnColor: null,
initEprssYn: "Y",
clickPsblYn: "N",
exprsYn: "Y",
cmncNm: "all",
srvcNm: "api",
actlLyrYn: "N",
clckZoom: null,
children: null,
};
return [...filtered, rootNode];
};
@@ -0,0 +1,96 @@
"use client";
import type * as React from "react";
import type VectorLayer from "ol/layer/Vector.js";
import type VectorSource from "ol/source/Vector.js";
import type Geometry from "ol/geom/Geometry.js";
import { useEffect } from "react";
import { unByKey } from "ol/Observable.js";
import { type FldngHstryItem } from "@/entities/flood-history/model/flood-history-types";
/** 침수 이력 강조 도형 클릭과 상세 카드 위치를 연결하고 이벤트를 해제한다. */
type Options = {
mapReady: boolean;
gisMaps: React.RefObject<any>;
selectedFldngHstryDetail: FldngHstryItem | null;
isFldngHstryChecked: boolean;
fldngHstryAreaLayerRef: React.RefObject<VectorLayer<
VectorSource<Geometry>
> | null>;
fldngHstryDepthLayerRef: React.RefObject<VectorLayer<
VectorSource<Geometry>
> | null>;
setFldngHstryDetailPopupPosition: React.Dispatch<
React.SetStateAction<{ left: number; top: number } | null>
>;
setIsSearchModalOpen: (open: boolean) => void;
};
/** 침수 이력 강조 도형 클릭과 상세 카드 위치를 연결하고 이벤트를 해제한다. */
export function useFloodHistoryClick({
mapReady,
gisMaps,
selectedFldngHstryDetail,
isFldngHstryChecked,
fldngHstryAreaLayerRef,
fldngHstryDepthLayerRef,
setFldngHstryDetailPopupPosition,
setIsSearchModalOpen,
}: Options) {
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const map = gisMaps.current;
const clickKey = map.on("singleclick", (event: any) => {
if (!selectedFldngHstryDetail || !isFldngHstryChecked) return;
let clickedFldngHstry = false;
map.forEachFeatureAtPixel(
event.pixel,
(feature: any, layer: any) => {
if (
layer === fldngHstryAreaLayerRef.current ||
layer === fldngHstryDepthLayerRef.current
) {
clickedFldngHstry = true;
return feature;
}
return undefined;
},
{ hitTolerance: 4 },
);
if (!clickedFldngHstry) return;
const size = map.getSize?.() ?? [1200, 800];
const popupWidth = 220;
const popupHeight = 210;
const nextLeft = Math.min(
Math.max(event.pixel[0] + 12, 12),
size[0] - popupWidth - 12,
);
const nextTop = Math.min(
Math.max(event.pixel[1] - 12, 12),
size[1] - popupHeight - 12,
);
setFldngHstryDetailPopupPosition({
left: nextLeft,
top: nextTop,
});
setIsSearchModalOpen(false);
});
return () => {
unByKey(clickKey);
};
}, [
gisMaps,
fldngHstryAreaLayerRef,
fldngHstryDepthLayerRef,
setFldngHstryDetailPopupPosition,
isFldngHstryChecked,
mapReady,
selectedFldngHstryDetail,
setIsSearchModalOpen,
]);
return {};
}
@@ -0,0 +1,154 @@
"use client";
import { fitMapSelection } from "@/features/map-search/lib/map-navigation";
import type * as React from "react";
import { useCallback, useEffect, useRef } from "react";
import Feature from "ol/Feature.js";
import Polygon from "ol/geom/Polygon.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import {
FLDNG_HSTRY_AREA_LAYER_ID,
FLDNG_HSTRY_DEPTH_LAYER_ID,
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
FLDNG_HSTRY_LAYER_NAME,
type FldngHstryItem,
} from "@/entities/flood-history/model/flood-history-types";
import { FLDNG_HSTRY_SELECTION_PADDING } from "../model/map-constants";
import {
createFldngHstryAreaStyle,
createFldngHstryDepthStyle,
} from "./analysis-layer-styles";
/** 침수 이력 선택 도형과 침수심 강조 레이어를 소유한다. */
type Options = {
mapReady: boolean;
gisMaps: React.RefObject<any>;
selectedFldngHstryDetail: FldngHstryItem | null;
};
/** 침수 이력 선택 도형과 침수심 강조 레이어를 소유한다. */
export function useFloodHistorySelection({
mapReady,
gisMaps,
selectedFldngHstryDetail,
}: Options) {
// 침수발생이력 overlay layer는 지도 드래그 중에도 유지돼야 하므로 React state 대신 ref로 보관한다.
const fldngHstryAreaLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const fldngHstryDepthLayerRef = useRef<VectorLayer<VectorSource> | null>(
null,
);
const ensureFldngHstryAreaSource = useCallback(() => {
if (!mapReady || !gisMaps.current) return null;
if (!fldngHstryAreaLayerRef.current) {
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9993,
});
layer.set("id", FLDNG_HSTRY_AREA_LAYER_ID);
layer.set("lyrNm", FLDNG_HSTRY_LAYER_NAME);
layer.set("lyrTyNm", FLDNG_HSTRY_PREVIEW_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
fldngHstryAreaLayerRef.current = layer;
}
return fldngHstryAreaLayerRef.current.getSource();
}, [gisMaps, mapReady]);
const ensureFldngHstryDepthSource = useCallback(() => {
if (!mapReady || !gisMaps.current) return null;
if (!fldngHstryDepthLayerRef.current) {
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9992,
});
layer.set("id", FLDNG_HSTRY_DEPTH_LAYER_ID);
layer.set("lyrNm", FLDNG_HSTRY_LAYER_NAME);
layer.set("lyrTyNm", FLDNG_HSTRY_PREVIEW_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
fldngHstryDepthLayerRef.current = layer;
}
return fldngHstryDepthLayerRef.current.getSource();
}, [gisMaps, mapReady]);
const clearFldngHstryLayers = useCallback(() => {
fldngHstryAreaLayerRef.current?.getSource?.()?.clear?.();
fldngHstryDepthLayerRef.current?.getSource?.()?.clear?.();
}, []);
const renderSelectedFldngHstry = useCallback(
(detail: FldngHstryItem | null) => {
const areaSource = ensureFldngHstryAreaSource();
const depthSource = ensureFldngHstryDepthSource();
if (!areaSource || !depthSource) return;
areaSource.clear();
depthSource.clear();
if (!detail) return;
const areaFeature = new Feature({
geometry: new Polygon([detail.areaRing]),
});
areaFeature.setStyle(
createFldngHstryAreaStyle(
`${detail.floodYear}년 ${detail.districtName}`,
),
);
areaSource.addFeature(areaFeature);
detail.depthZones.forEach((zone) => {
const zoneFeature = new Feature({
geometry: new Polygon([zone.ring]),
});
zoneFeature.setStyle(
createFldngHstryDepthStyle(zone.depthLabel, zone.color),
);
depthSource.addFeature(zoneFeature);
});
},
[ensureFldngHstryAreaSource, ensureFldngHstryDepthSource],
);
const focusFldngHstryOnMap = useCallback(
(detail: FldngHstryItem) => {
if (!gisMaps.current) return;
const geometry = new Polygon([detail.areaRing]);
fitMapSelection(gisMaps.current, geometry.getExtent(), {
duration: 350,
padding: FLDNG_HSTRY_SELECTION_PADDING,
maxZoom: 15,
constrainResolution: true,
nearest: true,
});
},
[gisMaps],
);
useEffect(() => {
renderSelectedFldngHstry(selectedFldngHstryDetail);
}, [renderSelectedFldngHstry, selectedFldngHstryDetail]);
useEffect(() => {
return () => {
clearFldngHstryLayers();
};
}, [clearFldngHstryLayers]);
return {
fldngHstryAreaLayerRef,
fldngHstryDepthLayerRef,
clearFldngHstryLayers,
renderSelectedFldngHstry,
focusFldngHstryOnMap,
};
}
@@ -0,0 +1,225 @@
"use client";
import type * as React from "react";
import { useEffect, useRef } from "react";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import {
FLDNG_HSTRY_DEPTH_LAYER_NAME,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_LAYER_NAME,
type FldngHstryYearRange,
} from "@/entities/flood-history/model/flood-history-types";
import { buildFldngHstryYearCql } from "@/entities/flood-history/lib/flood-history-utils";
import {
FLDNG_HSTRY_WFS_LAYER_ID,
FLDNG_DEPTH_WFS_LAYER_ID_PREFIX,
} from "../model/map-constants";
/** 침수 이력 WFS 레이어의 연도 조건과 표시 상태를 동기화한다. */
type Options = {
mapReady: boolean;
gisMaps: React.RefObject<any>;
fldngHstryYearRange: FldngHstryYearRange;
isFldngHstryYearFilterEnabled: boolean;
clearFldngHstrySelection: () => void;
isFldngHstryWtlvChecked: boolean;
isFldngHstryDepthChecked: boolean;
checkedFldngHstryDepthLayerKeys: string[];
isFldngHstryChecked: boolean;
};
/** 침수 이력 WFS 레이어의 연도 조건과 표시 상태를 동기화한다. */
export function useFloodHistoryVisibility({
mapReady,
gisMaps,
fldngHstryYearRange,
isFldngHstryYearFilterEnabled,
clearFldngHstrySelection,
isFldngHstryWtlvChecked,
isFldngHstryDepthChecked,
checkedFldngHstryDepthLayerKeys,
isFldngHstryChecked,
}: Options) {
// 체크 등급 변경과 연도 필터 변경을 구분해 연도 변경 때만 기존 선택 상세를 정리한다.
const fldngHstryYearFilterSignatureRef = useRef("");
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const core = GisApp.Module.core as any;
const yearCql = buildFldngHstryYearCql(
fldngHstryYearRange,
isFldngHstryYearFilterEnabled,
);
const yearFilterSignature = `${fldngHstryYearRange.startYear}:${fldngHstryYearRange.endYear}:${isFldngHstryYearFilterEnabled}`;
if (
fldngHstryYearFilterSignatureRef.current &&
fldngHstryYearFilterSignatureRef.current !== yearFilterSignature
) {
clearFldngHstrySelection();
}
fldngHstryYearFilterSignatureRef.current = yearFilterSignature;
/** 기준 레이어 필터를 보존하면서 선택한 침수년도 조건을 결합한다. */
const resolveFldngHstryFilter = (baseLayerCode: any) => {
const baseFilter = String(baseLayerCode?.flterSql ?? "").trim();
return [baseFilter && `(${baseFilter})`, yearCql]
.filter(Boolean)
.join(" AND ");
};
type FldngHstryLayerSpec = {
id: string;
layerType: string;
layerName: string;
baseLayerCode: any;
filterSql: string;
};
const desiredLayers: FldngHstryLayerSpec[] = [];
const layerCodes = Object.values(GisApp.layerCode ?? {}) as any[];
if (isFldngHstryWtlvChecked) {
const baseLayerCode = layerCodes.find(
(item) =>
item?.lyrTyNm === FLDNG_HSTRY_LAYER_TYPE && item?.actlLyrYn === "Y",
);
if (baseLayerCode) {
desiredLayers.push({
id: FLDNG_HSTRY_WFS_LAYER_ID,
layerType: FLDNG_HSTRY_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLDNG_HSTRY_LAYER_NAME,
baseLayerCode,
filterSql: resolveFldngHstryFilter(baseLayerCode),
});
}
}
if (isFldngHstryDepthChecked) {
const selectedDepthLayerKeys = new Set(checkedFldngHstryDepthLayerKeys);
layerCodes
.filter((item) => {
const layerKey = String(item?.lyrSn ?? item?.id ?? "").trim();
return (
item?.lyrTyNm === FLDNG_HSTRY_DEPTH_LAYER_TYPE &&
item?.actlLyrYn === "Y" &&
selectedDepthLayerKeys.has(layerKey)
);
})
.forEach((baseLayerCode) => {
const layerKey = String(
baseLayerCode?.lyrSn ?? baseLayerCode?.id,
).trim();
desiredLayers.push({
id: `${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_${layerKey}`,
layerType: FLDNG_HSTRY_DEPTH_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLDNG_HSTRY_DEPTH_LAYER_NAME,
baseLayerCode,
filterSql: resolveFldngHstryFilter(baseLayerCode),
});
});
}
const desiredLayerIds = new Set(desiredLayers.map((item) => item.id));
const mapLayers = [...(gisMaps.current?.getLayers?.()?.getArray?.() ?? [])];
const isManagedFldngHstryLayerId = (id: string) =>
id === FLDNG_HSTRY_WFS_LAYER_ID ||
id.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`);
// 체크 해제된 등급만 삭제하고, 계속 체크된 등급은 지도에 그대로 유지한다.
mapLayers.forEach((mapLayer: any) => {
const layerId = String(mapLayer?.get?.("id") ?? "");
if (
isManagedFldngHstryLayerId(layerId) &&
!desiredLayerIds.has(layerId)
) {
gisMaps.current?.removeLayer?.(mapLayer);
}
});
desiredLayers.forEach((spec) => {
const filterSignature = `${spec.layerType}|${spec.filterSql}`;
// 공통 getLayerById는 ID를 Number로 비교하므로 문자열 ID인 필터 레이어를 찾지 못한다.
// 지도 컬렉션에서 문자열 ID를 직접 비교해 기존 레이어와 WFS source를 재사용한다.
const existingLayer = gisMaps.current
?.getLayers?.()
?.getArray?.()
?.find(
(mapLayer: any) => String(mapLayer?.get?.("id") ?? "") === spec.id,
);
if (
existingLayer?.get?.("fldngHstryFilterSignature") === filterSignature
) {
existingLayer?.setVisible?.(true);
return;
}
// 연도나 적용 여부가 달라진 레이어만 교체하고, 동일 필터 레이어는 재사용한다.
core?.deleteLayer?.(spec.id);
const layerCode = {
...spec.baseLayerCode,
id: spec.id,
lyrSn: spec.id,
lyrNm: spec.layerName,
lyrTyNm: spec.layerType,
actlLyrYn: "Y",
exprsYn: "Y",
clickPsblYn: "Y",
cmncNm: "bbox",
srvcNm: "wfs",
flterSql: spec.filterSql,
};
const layer = core?.addWfsLayer?.(layerCode);
layer?.set?.("id", spec.id);
layer?.set?.("lyrNm", spec.layerName);
layer?.set?.("lyrTyNm", spec.layerType);
layer?.set?.("fldngHstryFilterSignature", filterSignature);
});
// 전용 필터 레이어가 담당하는 유형의 원본 레이어만 숨겨 중복 표출을 막는다.
const activeFilteredTypes = new Set(
desiredLayers.map((item) => item.layerType),
);
gisMaps.current
?.getLayers?.()
?.getArray?.()
?.forEach((mapLayer: any) => {
const layerId = String(mapLayer?.get?.("id") ?? "");
const layerType = String(mapLayer?.get?.("lyrTyNm") ?? "");
if (
activeFilteredTypes.has(layerType) &&
!isManagedFldngHstryLayerId(layerId)
) {
mapLayer?.setVisible?.(false);
}
});
}, [
checkedFldngHstryDepthLayerKeys,
clearFldngHstrySelection,
fldngHstryYearRange,
gisMaps,
isFldngHstryChecked,
isFldngHstryDepthChecked,
isFldngHstryWtlvChecked,
isFldngHstryYearFilterEnabled,
mapReady,
]);
// 페이지가 닫힐 때만 관리 대상 필터 레이어 전체를 정리한다.
useEffect(() => {
if (!mapReady) return;
const map = gisMaps.current;
return () => {
const layers = [...(map?.getLayers?.()?.getArray?.() ?? [])];
layers.forEach((layer: any) => {
const layerId = String(layer?.get?.("id") ?? "");
if (
layerId === FLDNG_HSTRY_WFS_LAYER_ID ||
layerId.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`)
) {
map?.removeLayer?.(layer);
}
});
};
}, [gisMaps, mapReady]);
return {};
}
@@ -0,0 +1,270 @@
"use client";
import type Geometry from "ol/geom/Geometry.js";
import type * as React from "react";
import { createGisEditPreviewStyle } from "@/features/gis-edit/lib/preview-styles";
import { applyGeometryVertexSnaps } from "@/features/gis-edit/lib/geometry-mutations";
import { useCallback, useEffect, useRef } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
import Draw from "ol/interaction/Draw.js";
import Snap from "ol/interaction/Snap.js";
import VectorSource from "ol/source/Vector.js";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { useGisEdit } from "../components/gis-edit/use-gis-edit";
import { FLOW_DIRECTION_SRS } from "../model/map-constants";
/** 신규 도형 Draw·Snap interaction의 생성과 종료를 소유한다. */
type Options = {
gisMaps: React.RefObject<any>;
gisEdit: ReturnType<typeof useGisEdit>;
mapReady: boolean;
removeGisEditInteractions: () => void;
ensureGisEditPreviewLayer: () => VectorSource<Geometry> | null;
shouldUsePipeSnapSource: boolean;
ensureGisEditPipeSnapSource: () => VectorSource<Geometry>;
setGisEditPreviewCursor: (cursor: string) => void;
gisEditPreviewLayerRef: React.RefObject<any>;
openGisEditPipeDetail: (changeId: string | null) => void;
setFeatureData: React.Dispatch<any>;
setGisEditDetailChangeId: React.Dispatch<React.SetStateAction<string | null>>;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsSearchModalOpen: (open: boolean) => void;
};
/** 신규 도형 Draw·Snap interaction의 생성과 종료를 소유한다. */
export function useGisEditDraw({
gisMaps,
gisEdit,
mapReady,
removeGisEditInteractions,
ensureGisEditPreviewLayer,
shouldUsePipeSnapSource,
ensureGisEditPipeSnapSource,
setGisEditPreviewCursor,
gisEditPreviewLayerRef,
openGisEditPipeDetail,
setFeatureData,
setGisEditDetailChangeId,
setIsOpen,
setIsSearchModalOpen,
}: Options) {
// 편집 세션 전체 객체 대신 실제 사용하는 상태와 명령만 의존성으로 추적한다.
const {
selectedLayer: editSelectedLayer,
isEditMode: editIsEditMode,
activeTool: editActiveTool,
registerAddGeometry: editRegisterAddGeometry,
deactivateTool: editDeactivateTool,
announceStatus: editAnnounceStatus,
isSnapEnabled: editIsSnapEnabled,
} = gisEdit;
const gisEditDrawRef = useRef<any | null>(null);
const gisEditDrawSnapRef = useRef<any | null>(null);
const removeGisEditDrawInteraction = useCallback(() => {
const map = gisMaps.current;
if (!map) return;
if (gisEditDrawSnapRef.current) {
map.removeInteraction(gisEditDrawSnapRef.current);
gisEditDrawSnapRef.current = null;
}
if (gisEditDrawRef.current) {
map.removeInteraction(gisEditDrawRef.current);
gisEditDrawRef.current = null;
}
}, [gisMaps]);
const gisEditSelectedLayerId = editSelectedLayer?.layerId ?? "";
const gisEditSelectedLayerGeometryType =
editSelectedLayer?.geometryType ?? null;
const gisEditSelectedLayerPreviewColors =
editSelectedLayer?.previewColors ?? null;
useEffect(() => {
if (!mapReady || !gisMaps.current || !editIsEditMode) return;
const map = gisMaps.current;
if (
editActiveTool !== "ADD" ||
!gisEditSelectedLayerId ||
!gisEditSelectedLayerGeometryType
) {
removeGisEditDrawInteraction();
return;
}
removeGisEditInteractions();
removeGisEditDrawInteraction();
const drawSource = ensureGisEditPreviewLayer();
if (!drawSource) return;
const drawType =
gisEditSelectedLayerGeometryType === "Point"
? "Point"
: gisEditSelectedLayerGeometryType === "Polygon"
? "Polygon"
: "LineString";
const draw = new Draw({
source: drawSource,
type: drawType,
});
const pipeSnapSource = shouldUsePipeSnapSource
? ensureGisEditPipeSnapSource()
: null;
draw.on("drawstart", () => {
setGisEditPreviewCursor("crosshair");
});
draw.on("drawend", (event: any) => {
try {
const pipeSnapSource = shouldUsePipeSnapSource
? ensureGisEditPipeSnapSource()
: null;
if (pipeSnapSource?.getFeatures?.().length) {
applyGeometryVertexSnaps(
event.feature.getGeometry(),
pipeSnapSource,
map,
20,
);
}
const geometryObject = new GeoJSON().writeGeometryObject(
event.feature.getGeometry(),
{
featureProjection: GisApp.TO_PROJ,
dataProjection: FLOW_DIRECTION_SRS,
},
) as Record<string, unknown>;
const changeId = editRegisterAddGeometry(geometryObject);
if (changeId) {
event.feature.setId(`gis-edit:${changeId}`);
event.feature.set("fid", changeId);
event.feature.set("editType", "ADD");
event.feature.set("objectId", changeId);
event.feature.set("layerId", gisEditSelectedLayerId);
event.feature.set("changeId", changeId);
event.feature.set(
"layerPointColor",
gisEditSelectedLayerPreviewColors?.pointColor ?? "",
);
event.feature.set(
"layerLineColor",
gisEditSelectedLayerPreviewColors?.lineColor ?? "",
);
event.feature.set(
"layerFillColor",
gisEditSelectedLayerPreviewColors?.fillColor ?? "",
);
event.feature.set("isInteractionTarget", false);
event.feature.set("isDragging", false);
event.feature.set("previewLabel", "신규 추가");
event.feature.setStyle(createGisEditPreviewStyle(event.feature));
gisMaps.current?.renderSync?.();
window.setTimeout(() => {
const source = ensureGisEditPreviewLayer();
if (!source) return;
if (!source.getFeatureById?.(`gis-edit:${changeId}`)) {
source.addFeature(event.feature);
}
event.feature.changed?.();
source.changed?.();
gisEditPreviewLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
}, 0);
} else {
window.setTimeout(() => {
drawSource.removeFeature(event.feature);
}, 0);
}
if (changeId && gisEditSelectedLayerId === "tb_ms_swage_ppl") {
openGisEditPipeDetail(changeId);
setFeatureData({
lyrTypeNm: "tb_ms_swage_ppl",
fcltMngNo: "",
mngInstCd: "",
fclt_mng_no: "",
inst_mng_no: "",
});
window.setTimeout(() => {
setGisEditDetailChangeId(changeId);
setIsOpen(true);
}, 0);
setIsSearchModalOpen(false);
window.setTimeout(() => {
editDeactivateTool();
removeGisEditDrawInteraction();
}, 0);
editAnnounceStatus(
"하수관로 신규 객체를 지도에 추가했습니다. 우측 상세정보를 입력한 뒤 저장하세요.",
);
}
} catch (error) {
console.error("GIS 편집 ADD geometry 변환 실패:", error);
editAnnounceStatus(
"신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요.",
);
} finally {
setGisEditPreviewCursor("crosshair");
}
});
map.addInteraction(draw);
gisEditDrawRef.current = draw;
if (pipeSnapSource) {
const drawSnap = new Snap({
source: pipeSnapSource,
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(drawSnap);
gisEditDrawSnapRef.current = drawSnap;
}
return () => {
removeGisEditDrawInteraction();
};
}, [
ensureGisEditPreviewLayer,
ensureGisEditPipeSnapSource,
editActiveTool,
editAnnounceStatus,
editDeactivateTool,
editIsEditMode,
editIsSnapEnabled,
editRegisterAddGeometry,
gisEditSelectedLayerGeometryType,
gisEditSelectedLayerId,
gisEditSelectedLayerPreviewColors,
mapReady,
openGisEditPipeDetail,
removeGisEditDrawInteraction,
removeGisEditInteractions,
setGisEditPreviewCursor,
shouldUsePipeSnapSource,
gisMaps,
gisEditPreviewLayerRef,
setFeatureData,
setIsSearchModalOpen,
setGisEditDetailChangeId,
setIsOpen,
]);
useEffect(() => {
if (!editIsEditMode || editActiveTool !== "ADD") {
removeGisEditDrawInteraction();
}
}, [editActiveTool, editIsEditMode, removeGisEditDrawInteraction]);
useEffect(
() => () => removeGisEditDrawInteraction(),
[removeGisEditDrawInteraction],
);
return { removeGisEditDrawInteraction };
}
@@ -0,0 +1,172 @@
"use client";
import type { RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useGisEdit } from "../components/gis-edit/use-gis-edit";
import {
normalizeGisEditFid,
getGisEditFeatureFid,
buildGisEditHiddenPipeFilter,
} from "./gis-edit-features";
/** 편집 중 원본 도형의 필터와 스타일을 보관하고 종료 시 복원한다. */
type Options = {
gisMaps: RefObject<any>;
gisEdit: ReturnType<typeof useGisEdit>;
mapReady: boolean;
};
/** 편집 중 원본 도형의 필터와 스타일을 보관하고 종료 시 복원한다. */
export function useGisEditOriginalMask({
gisMaps,
gisEdit,
mapReady,
}: Options) {
// 편집 세션 전체 객체 대신 실제 사용하는 상태와 명령만 의존성으로 추적한다.
const { changes: editChanges, isEditMode: editIsEditMode } = gisEdit;
const gisEditOriginalLayerFilterRef = useRef<
globalThis.Map<any, string | null>
>(new globalThis.Map());
const gisEditOriginalFeatureStyleRef = useRef<globalThis.Map<any, any>>(
new globalThis.Map(),
);
const restoreGisEditOriginalPipeMask = useCallback(() => {
gisEditOriginalLayerFilterRef.current.forEach((originalFilter, layer) => {
const source = layer?.getSource?.();
const params = { ...(source?.getParams?.() ?? {}) };
if (originalFilter) {
params.CQL_FILTER = originalFilter;
} else {
delete params.CQL_FILTER;
}
params._t = Date.now();
source?.updateParams?.(params);
source?.refresh?.();
source?.changed?.();
layer?.changed?.();
});
gisEditOriginalLayerFilterRef.current.clear();
gisEditOriginalFeatureStyleRef.current.forEach((originalStyle, feature) => {
feature?.setStyle?.(originalStyle);
feature?.changed?.();
});
gisEditOriginalFeatureStyleRef.current.clear();
gisMaps.current?.renderSync?.();
}, [gisMaps]);
const applyGisEditOriginalPipeMask = useCallback(
(hiddenFids: string[]) => {
const hiddenFidSet = new Set(
hiddenFids.map((fid) => normalizeGisEditFid(fid)).filter(Boolean),
);
if (hiddenFidSet.size === 0) {
restoreGisEditOriginalPipeMask();
return;
}
const map = gisMaps.current;
if (!map) return;
const filter = buildGisEditHiddenPipeFilter(Array.from(hiddenFidSet));
map.getAllLayers?.().forEach((layer: any) => {
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
if (layerTypeName !== "tb_ms_swage_ppl") return;
const source = layer.getSource?.();
if (source?.updateParams && filter) {
const params = { ...(source.getParams?.() ?? {}) };
if (!gisEditOriginalLayerFilterRef.current.has(layer)) {
gisEditOriginalLayerFilterRef.current.set(
layer,
String(params.CQL_FILTER ?? "").trim() || null,
);
}
const originalFilter =
gisEditOriginalLayerFilterRef.current.get(layer);
params.CQL_FILTER = originalFilter
? `(${originalFilter}) AND (${filter})`
: filter;
params._t = Date.now();
source.updateParams(params);
source.refresh?.();
source.changed?.();
layer.changed?.();
}
source?.getFeatures?.().forEach((feature: any) => {
const fid = getGisEditFeatureFid(feature);
const shouldHide = hiddenFidSet.has(fid);
if (
shouldHide &&
!gisEditOriginalFeatureStyleRef.current.has(feature)
) {
gisEditOriginalFeatureStyleRef.current.set(
feature,
feature.getStyle?.(),
);
feature.setStyle?.(() => []);
feature.changed?.();
}
if (
!shouldHide &&
gisEditOriginalFeatureStyleRef.current.has(feature)
) {
feature.setStyle?.(
gisEditOriginalFeatureStyleRef.current.get(feature),
);
gisEditOriginalFeatureStyleRef.current.delete(feature);
feature.changed?.();
}
});
});
gisMaps.current?.renderSync?.();
},
[gisMaps, restoreGisEditOriginalPipeMask],
);
const gisEditMaskedPipeFidKey = useMemo(() => {
const fids = editChanges
.filter((change) => change.layerId === "tb_ms_swage_ppl")
.filter((change) => !change.attributeOnly)
.filter(
(change) => change.editType !== "ADD" && change.editType !== "DELETE",
)
.map((change) =>
normalizeGisEditFid(
change.fid ?? change.featureRef?.fid ?? change.objectId,
),
)
.filter((fid) => /^\d+$/.test(fid));
return Array.from(new Set(fids)).sort().join(",");
}, [editChanges]);
useEffect(() => {
if (!mapReady || !editIsEditMode) {
restoreGisEditOriginalPipeMask();
return;
}
applyGisEditOriginalPipeMask(
gisEditMaskedPipeFidKey ? gisEditMaskedPipeFidKey.split(",") : [],
);
}, [
applyGisEditOriginalPipeMask,
editIsEditMode,
gisEditMaskedPipeFidKey,
mapReady,
restoreGisEditOriginalPipeMask,
]);
useEffect(
() => () => {
restoreGisEditOriginalPipeMask();
},
[restoreGisEditOriginalPipeMask],
);
return {};
}
@@ -0,0 +1,290 @@
"use client";
import type * as React from "react";
import { useCallback, useEffect, useRef } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
import { unByKey } from "ol/Observable.js";
import VectorSource from "ol/source/Vector.js";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getRawJson } from "@/shared/api/sewerage-api";
import { useGisEdit } from "../components/gis-edit/use-gis-edit";
import { FLOW_DIRECTION_SRS } from "../model/map-constants";
import { getFeatureCollection } from "./map-values";
import {
expandGisEditPipeSnapExtent,
normalizeGisEditFid,
getGisEditFeatureFid,
} from "./gis-edit-features";
/** 편집 스냅용 관로의 영역 조회와 취소 수명을 소유한다. */
type Options = {
gisEdit: ReturnType<typeof useGisEdit>;
gisMaps: React.RefObject<any>;
mapReady: boolean;
};
/** 편집 스냅용 관로의 영역 조회와 취소 수명을 소유한다. */
export function useGisEditPipeSnap({ gisEdit, gisMaps, mapReady }: Options) {
// 편집 세션 전체 객체 대신 실제 사용하는 상태와 명령만 의존성으로 추적한다.
const {
changes: editChanges,
isEditMode: editIsEditMode,
isSnapEnabled: editIsSnapEnabled,
selectedLayer: editSelectedLayer,
pendingInteraction: editPendingInteraction,
} = gisEdit;
const gisEditPipeSnapSourceRef = useRef<VectorSource | null>(null);
const gisEditPipeSnapAbortControllerRef = useRef<AbortController | null>(
null,
);
const gisEditPipeSnapRequestSeqRef = useRef(0);
const ensureGisEditPipeSnapSource = useCallback(() => {
if (gisEditPipeSnapSourceRef.current) {
return gisEditPipeSnapSourceRef.current;
}
const source = new VectorSource();
gisEditPipeSnapSourceRef.current = source;
return source;
}, []);
const clearGisEditPipeSnapSource = useCallback(() => {
gisEditPipeSnapAbortControllerRef.current?.abort();
gisEditPipeSnapAbortControllerRef.current = null;
gisEditPipeSnapSourceRef.current?.clear();
}, []);
const buildGisEditPipeSnapPreviewFeatures = useCallback(
(excludedChangeId?: string | null) => {
const normalizedExcludedChangeId = String(excludedChangeId ?? "").trim();
const geoJson = new GeoJSON();
return editChanges
.filter((change) => change.layerId === "tb_ms_swage_ppl")
.filter(
(change) =>
!change.attributeOnly &&
change.editType !== "DELETE" &&
Boolean(change.afterGeometry),
)
.filter((change) => change.changeId !== normalizedExcludedChangeId)
.map((change) => {
const feature = geoJson.readFeature(
{
type: "Feature",
geometry: change.afterGeometry,
properties: {
fid:
change.fid ??
change.featureRef?.fid ??
change.objectId ??
change.changeId,
layerId: change.layerId,
changeId: change.changeId,
},
},
{
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
},
);
feature.setId(`gis-edit-snap:${change.changeId}`);
return feature;
});
},
[editChanges],
);
const loadGisEditPipeSnapSource = useCallback(
async (options?: {
excludedFid?: string | null;
excludedChangeId?: string | null;
}) => {
const map = gisMaps.current;
const source = ensureGisEditPipeSnapSource();
if (!map || !source) return;
const size = map.getSize?.();
const view = map.getView?.();
if (!size || !view?.calculateExtent) return;
const normalizedExcludedFid = normalizeGisEditFid(
options?.excludedFid ?? "",
);
const normalizedExcludedChangeId = String(
options?.excludedChangeId ?? "",
).trim();
const hiddenPipeFidSet = new Set(
editChanges
.filter((change) => change.layerId === "tb_ms_swage_ppl")
.filter(
(change) => !change.attributeOnly && change.editType !== "DELETE",
)
.map((change) =>
normalizeGisEditFid(
change.fid ?? change.featureRef?.fid ?? change.objectId,
),
)
.filter((fid) => /^\d+$/.test(fid)),
);
if (/^\d+$/.test(normalizedExcludedFid)) {
hiddenPipeFidSet.add(normalizedExcludedFid);
}
const previewSnapFeatures = buildGisEditPipeSnapPreviewFeatures(
normalizedExcludedChangeId,
);
source.clear();
if (previewSnapFeatures.length > 0) {
source.addFeatures(previewSnapFeatures);
}
const screenExtent = view.calculateExtent(size);
const requestExtent = expandGisEditPipeSnapExtent(screenExtent);
const [minX, minY, maxX, maxY] = requestExtent;
const bboxFilter = `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${GisApp.TO_PROJ}')`;
const cqlFilter = bboxFilter;
const requestSeq = gisEditPipeSnapRequestSeqRef.current + 1;
gisEditPipeSnapRequestSeqRef.current = requestSeq;
gisEditPipeSnapAbortControllerRef.current?.abort();
const abortController = new AbortController();
gisEditPipeSnapAbortControllerRef.current = abortController;
try {
const layerModule = GisApp.Module.layer as any;
const storageName = String(layerModule?.storageNm ?? "hasudo:");
const params = new URLSearchParams({
SERVICE: "WFS",
VERSION: "1.1.0",
REQUEST: "GetFeature",
TYPENAME: `${storageName}tb_ms_swage_ppl`,
SRSNAME: GisApp.TO_PROJ,
OUTPUTFORMAT: "application/json",
CQL_FILTER: cqlFilter,
});
const response = await getRawJson(
`${GisApp.GEOSERVER_URL}?${params.toString()}`,
{
signal: abortController.signal,
},
);
if (requestSeq !== gisEditPipeSnapRequestSeqRef.current) return;
if (abortController.signal.aborted) return;
const featureCollection = getFeatureCollection(response);
const features = new GeoJSON()
.readFeatures(featureCollection, {
dataProjection: GisApp.TO_PROJ,
featureProjection: GisApp.TO_PROJ,
})
.filter(
(feature: any) =>
!hiddenPipeFidSet.has(getGisEditFeatureFid(feature)),
);
source.clear();
source.addFeatures(features);
if (previewSnapFeatures.length > 0) {
source.addFeatures(previewSnapFeatures);
}
} catch (error) {
if ((error as Error)?.name === "AbortError") return;
console.error("GIS 편집 스냅용 관로 조회 실패:", error);
source.clear();
if (previewSnapFeatures.length > 0) {
source.addFeatures(previewSnapFeatures);
}
} finally {
if (gisEditPipeSnapAbortControllerRef.current === abortController) {
gisEditPipeSnapAbortControllerRef.current = null;
}
}
},
[
gisMaps,
ensureGisEditPipeSnapSource,
editChanges,
buildGisEditPipeSnapPreviewFeatures,
],
);
const shouldUsePipeSnapSource =
editIsEditMode &&
editIsSnapEnabled &&
editSelectedLayer?.layerId === "tb_ms_swage_ppl";
useEffect(
() => () => {
clearGisEditPipeSnapSource();
},
[clearGisEditPipeSnapSource],
);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
if (!shouldUsePipeSnapSource) {
clearGisEditPipeSnapSource();
return;
}
const excludedFid =
editPendingInteraction?.editType === "VERTEX_EDIT" ||
editPendingInteraction?.editType === "MOVE"
? normalizeGisEditFid(
editChanges.find(
(change) => change.changeId === editPendingInteraction?.changeId,
)?.fid ?? "",
)
: "";
void loadGisEditPipeSnapSource({
excludedFid,
excludedChangeId: editPendingInteraction?.changeId ?? null,
});
}, [
clearGisEditPipeSnapSource,
editChanges,
editPendingInteraction,
gisMaps,
loadGisEditPipeSnapSource,
mapReady,
shouldUsePipeSnapSource,
]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
if (!shouldUsePipeSnapSource) return;
const key = gisMaps.current.on("moveend", () => {
const excludedFid =
editPendingInteraction?.editType === "VERTEX_EDIT" ||
editPendingInteraction?.editType === "MOVE"
? normalizeGisEditFid(
editChanges.find(
(change) =>
change.changeId === editPendingInteraction?.changeId,
)?.fid ?? "",
)
: "";
void loadGisEditPipeSnapSource({
excludedFid,
excludedChangeId: editPendingInteraction?.changeId ?? null,
});
});
return () => {
unByKey(key);
};
}, [
editChanges,
editPendingInteraction,
gisMaps,
loadGisEditPipeSnapSource,
mapReady,
shouldUsePipeSnapSource,
]);
return { ensureGisEditPipeSnapSource, shouldUsePipeSnapSource };
}
@@ -0,0 +1,301 @@
"use client";
import type * as React from "react";
import { createGisEditPreviewStyle } from "@/features/gis-edit/lib/preview-styles";
import { useCallback, useEffect, useRef } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { useGisEdit } from "../components/gis-edit/use-gis-edit";
import {
FLOW_DIRECTION_SRS,
GIS_EDIT_PREVIEW_LAYER_ID,
} from "../model/map-constants";
/** 편집 미리보기 도형의 표시와 선택 강조 수명을 소유한다. */
type Options = {
gisMaps: React.RefObject<any>;
mapReady: boolean;
gisEdit: ReturnType<typeof useGisEdit>;
focusGisEditChangeOnMap: (changeId: string) => void;
};
/** 편집 미리보기 도형의 표시와 선택 강조 수명을 소유한다. */
export function useGisEditPreview({
gisMaps,
mapReady,
gisEdit,
focusGisEditChangeOnMap,
}: Options) {
// 편집 세션 전체 객체 대신 실제 사용하는 상태와 명령만 의존성으로 추적한다.
const {
changes: editChanges,
editableLayers: editEditableLayers,
pendingInteraction: editPendingInteraction,
focusedChangeId: editFocusedChangeId,
isEditMode: editIsEditMode,
} = gisEdit;
const gisEditPreviewLayerRef = useRef<any | null>(null);
const gisEditPreviewSourceRef = useRef<VectorSource | null>(null);
// 신규 관로마다 한 번만 우측 상세 패널을 피한 가시 영역으로 이동하기 위한 change 식별자다.
const gisEditAutoFocusedAddChangeIdRef = useRef("");
const setGisEditPreviewCursor = useCallback(
(cursor: string) => {
const targetElement = gisMaps.current?.getTargetElement?.();
if (targetElement?.style) {
targetElement.style.cursor = cursor;
}
},
[gisMaps],
);
const ensureGisEditPreviewLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (gisEditPreviewLayerRef.current && gisEditPreviewSourceRef.current) {
const layers = map.getLayers?.().getArray?.() ?? [];
if (!layers.includes(gisEditPreviewLayerRef.current)) {
map.addLayer(gisEditPreviewLayerRef.current);
}
gisEditPreviewLayerRef.current.setVisible?.(true);
gisEditPreviewLayerRef.current.setZIndex?.(10001);
return gisEditPreviewSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
style: createGisEditPreviewStyle,
zIndex: 10001,
updateWhileAnimating: true,
updateWhileInteracting: true,
});
layer.set("id", GIS_EDIT_PREVIEW_LAYER_ID);
layer.set("lyrNm", "GIS 편집 미리보기");
layer.set("lyrTyNm", GIS_EDIT_PREVIEW_LAYER_ID);
// Preview selection is handled by map.forEachFeatureAtPixel; keep it out of the common Select styler.
layer.set("clickPsblYn", "N");
layer.setVisible?.(true);
map.addLayer(layer);
gisEditPreviewLayerRef.current = layer;
gisEditPreviewSourceRef.current = source;
return source;
}, [gisMaps]);
const clearGisEditPreviewLayer = useCallback(() => {
gisEditPreviewSourceRef.current?.clear();
setGisEditPreviewCursor("");
}, [setGisEditPreviewCursor]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const source = ensureGisEditPreviewLayer();
if (!source) return;
if (editChanges.length === 0) {
clearGisEditPreviewLayer();
return;
}
const geoJson = new GeoJSON();
const layerPreviewColorMap = new globalThis.Map<string, any>(
editEditableLayers.map(
(layer) => [layer.layerId, layer.previewColors] as const,
),
);
const existingFeatureMap = new globalThis.Map<string, any>(
source
.getFeatures()
.map(
(feature: any) =>
[String(feature.get("changeId") ?? ""), feature] as const,
),
);
const nextChangeIdSet = new Set<string>();
editChanges.forEach((change) => {
if (change.attributeOnly) return;
const geometry =
change.editType === "DELETE"
? change.beforeGeometry
: change.afterGeometry;
if (!geometry) return;
const previewEditType =
editPendingInteraction?.changeId === change.changeId
? editPendingInteraction.editType
: change.editType;
const previewLabel =
editFocusedChangeId === change.changeId && previewEditType === "MOVE"
? "드래그 이동"
: editFocusedChangeId === change.changeId &&
previewEditType === "VERTEX_EDIT"
? "꼭지점 편집"
: previewEditType === "DELETE"
? "삭제 예정"
: previewEditType === "MOVE"
? "이동 예정"
: previewEditType === "VERTEX_EDIT"
? "편집 예정"
: "신규 추가";
let feature = existingFeatureMap.get(change.changeId);
const previewColors = layerPreviewColorMap.get(change.layerId);
const originalProperties = change.featureProperties ?? {};
const previewObjectId = change.objectId ?? change.objectLabel;
const previewFid =
originalProperties["fid"] ??
change.fid ??
change.featureRef?.fid ??
previewObjectId;
if (!feature) {
feature = geoJson.readFeature(
{
type: "Feature",
geometry,
properties: {
...originalProperties,
fid: previewFid,
editType: previewEditType,
objectId: previewObjectId,
layerId: change.layerId,
changeId: change.changeId,
},
},
{
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
},
);
feature.setId(`gis-edit:${change.changeId}`);
source.addFeature(feature);
} else {
feature.setGeometry(
geoJson.readGeometry(geometry, {
dataProjection: FLOW_DIRECTION_SRS,
featureProjection: GisApp.TO_PROJ,
}),
);
}
Object.entries(originalProperties).forEach(([key, value]) => {
if (key === "geometry") return;
feature.set(key, value);
});
feature.set("fid", previewFid);
feature.set("editType", previewEditType);
feature.set("objectId", previewObjectId);
feature.set("layerId", change.layerId);
feature.set("changeId", change.changeId);
feature.set("layerPointColor", previewColors?.pointColor ?? "");
feature.set("layerLineColor", previewColors?.lineColor ?? "");
feature.set("layerFillColor", previewColors?.fillColor ?? "");
feature.set(
"isInteractionTarget",
editFocusedChangeId === change.changeId,
);
feature.set("isDragging", Boolean(feature.get("isDragging")));
feature.set("previewLabel", previewLabel);
feature.setStyle(createGisEditPreviewStyle(feature));
nextChangeIdSet.add(change.changeId);
});
existingFeatureMap.forEach((feature: any, changeId: string) => {
if (!nextChangeIdSet.has(changeId)) {
source.removeFeature(feature);
}
});
source.changed?.();
gisEditPreviewLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
}, [
clearGisEditPreviewLayer,
ensureGisEditPreviewLayer,
editChanges,
editEditableLayers,
editFocusedChangeId,
editIsEditMode,
editPendingInteraction,
mapReady,
gisMaps,
]);
useEffect(() => {
if (!mapReady || !editIsEditMode || editChanges.length === 0) return;
ensureGisEditPreviewLayer();
gisEditPreviewSourceRef.current?.changed?.();
gisEditPreviewLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
}, [
ensureGisEditPreviewLayer,
editChanges.length,
editIsEditMode,
mapReady,
gisMaps,
]);
// ADD 완료 직후 열린 상세 패널 아래에 신규 선형이 가려지지 않도록 최초 1회 지도 가시 영역에 맞춘다.
useEffect(() => {
if (!mapReady || !editIsEditMode || !editFocusedChangeId) return;
const focusedChange = editChanges.find(
(change) => change.changeId === editFocusedChangeId,
);
if (
focusedChange?.editType !== "ADD" ||
!focusedChange.afterGeometry ||
gisEditAutoFocusedAddChangeIdRef.current === focusedChange.changeId
) {
return;
}
gisEditAutoFocusedAddChangeIdRef.current = focusedChange.changeId;
const focusTimer = window.setTimeout(() => {
const map = gisMaps.current;
if (!map) return;
ensureGisEditPreviewLayer();
map.updateSize?.();
focusGisEditChangeOnMap(focusedChange.changeId);
gisEditPreviewSourceRef.current?.changed?.();
gisEditPreviewLayerRef.current?.changed?.();
map.renderSync?.();
}, 120);
return () => window.clearTimeout(focusTimer);
}, [
ensureGisEditPreviewLayer,
focusGisEditChangeOnMap,
editChanges,
editFocusedChangeId,
editIsEditMode,
mapReady,
gisMaps,
]);
useEffect(
() => () => {
setGisEditPreviewCursor("");
if (gisEditPreviewLayerRef.current)
gisMaps.current?.removeLayer?.(gisEditPreviewLayerRef.current);
gisEditPreviewSourceRef.current?.clear();
gisEditPreviewLayerRef.current = null;
gisEditPreviewSourceRef.current = null;
},
[gisMaps, setGisEditPreviewCursor],
);
return {
gisEditPreviewLayerRef,
gisEditPreviewSourceRef,
setGisEditPreviewCursor,
ensureGisEditPreviewLayer,
clearGisEditPreviewLayer,
};
}
@@ -0,0 +1,236 @@
"use client";
import type * as React from "react";
import { type VertexHandleMeta } from "@/features/gis-edit/model/vertex-handles";
import { getClosestGeometryVertexMeta } from "@/features/gis-edit/lib/geometry-mutations";
import { useEffect } from "react";
import VectorLayer from "ol/layer/Vector.js";
import { unByKey } from "ol/Observable.js";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { useGisEdit } from "../components/gis-edit/use-gis-edit";
import { getSelectedFeatureLayerTypeName } from "./right-panel-features";
/** 편집 중 지도 클릭 우선순위와 공통 선택 interaction 활성 상태를 연결한다. */
type Options = {
mapReady: boolean;
gisMaps: React.RefObject<any>;
gisEdit: ReturnType<typeof useGisEdit>;
gisEditVertexLayerRef: React.RefObject<VectorLayer<any> | null>;
clearSelectedVertexHandle: () => void;
gisEditSelectedVertexHandleKeyRef: React.RefObject<string>;
gisEditSelectedVertexMetaListRef: React.RefObject<VertexHandleMeta[] | null>;
applySelectedVertexHandleStyle: () => void;
setGisEditDetailChangeId: React.Dispatch<React.SetStateAction<string | null>>;
setFeatureData: React.Dispatch<any>;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsSearchModalOpen: (open: boolean) => void;
gisEditMoveGripMetaRef: React.RefObject<{
changeId: string;
meta: VertexHandleMeta | null;
} | null>;
openGisEditPipeDetail: (changeId: string | null) => void;
};
/** 편집 중 지도 클릭 우선순위와 공통 선택 interaction 활성 상태를 연결한다. */
export function useGisEditSelection({
mapReady,
gisMaps,
gisEdit,
gisEditVertexLayerRef,
clearSelectedVertexHandle,
gisEditSelectedVertexHandleKeyRef,
gisEditSelectedVertexMetaListRef,
applySelectedVertexHandleStyle,
setGisEditDetailChangeId,
setFeatureData,
setIsOpen,
setIsSearchModalOpen,
gisEditMoveGripMetaRef,
openGisEditPipeDetail,
}: Options) {
// 편집 세션 전체 객체 대신 실제 사용하는 상태와 명령만 의존성으로 추적한다.
const {
isEditMode: editIsEditMode,
pendingInteraction: editPendingInteraction,
announceStatus: editAnnounceStatus,
activeTool: editActiveTool,
selectedLayer: editSelectedLayer,
registerFeatureSelection: editRegisterFeatureSelection,
changes: editChanges,
} = gisEdit;
useEffect(() => {
if (!mapReady || !gisMaps.current || !editIsEditMode) return;
const map = gisMaps.current;
const clickKey = map.on("singleclick", (event: any) => {
const vertexHandleFeature =
editPendingInteraction?.editType === "VERTEX_EDIT"
? map.forEachFeatureAtPixel(
event.pixel,
(feature: any, layer: any) =>
layer === gisEditVertexLayerRef.current ? feature : null,
{ hitTolerance: 14 },
)
: null;
if (vertexHandleFeature) {
event.preventDefault?.();
const handleKind = String(
vertexHandleFeature.get?.("handleKind") ?? "",
);
if (handleKind !== "vertex") {
clearSelectedVertexHandle();
editAnnounceStatus(
"흰색 midpoint는 드래그해서 새 꼭지점을 추가할 수 있습니다. Delete 삭제 대상은 주황색 실꼭지점입니다.",
);
return;
}
gisEditSelectedVertexHandleKeyRef.current = String(
vertexHandleFeature.get?.("handleSelectionKey") ?? "",
);
gisEditSelectedVertexMetaListRef.current =
(vertexHandleFeature.get?.("handleMetaList") as
| VertexHandleMeta[]
| undefined) ?? null;
applySelectedVertexHandleStyle();
editAnnounceStatus(
"주황색 실꼭지점을 선택했습니다. Delete 키를 누르면 해당 꼭지점을 삭제합니다.",
);
return;
}
const previewFeature = map.forEachFeatureAtPixel(
event.pixel,
(feature: any) =>
String(feature?.get?.("changeId") ?? "").trim() ? feature : null,
{ hitTolerance: 12 },
);
// 편집 도구가 켜진 동안에는 공통 Select를 우회하므로 원본 WFS 피처도 여기서 직접 판정한다.
const editableSourceFeature = previewFeature
? null
: editActiveTool && editActiveTool !== "ADD"
? map.forEachFeatureAtPixel(
event.pixel,
(feature: any) => {
if (String(feature?.get?.("changeId") ?? "").trim())
return null;
return getSelectedFeatureLayerTypeName(feature) ===
editSelectedLayer?.layerId
? feature
: null;
},
{ hitTolerance: 12 },
)
: null;
const selectedFeature = previewFeature ?? editableSourceFeature;
if (!selectedFeature) return;
clearSelectedVertexHandle();
const editSelection = editRegisterFeatureSelection(selectedFeature);
if (!editSelection.handled) return;
event.preventDefault?.();
if (editSelection.editType === "DELETE") {
const select = GisApp.Module.select as any;
select?.resetSelectedFeature?.();
setGisEditDetailChangeId(null);
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
if (editActiveTool === "MOVE" || editActiveTool === "VERTEX_EDIT") {
if (
editActiveTool === "MOVE" &&
typeof editSelection.changeId === "string" &&
editSelection.changeId
) {
gisEditMoveGripMetaRef.current = {
changeId: String(editSelection.changeId),
meta: getClosestGeometryVertexMeta(
selectedFeature.getGeometry?.(),
map,
event.coordinate,
),
};
}
setGisEditDetailChangeId(null);
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
const selectedChangeId =
typeof editSelection.changeId === "string"
? editSelection.changeId
: "";
const selectedChange = editChanges.find(
(change) => change.changeId === selectedChangeId,
);
const canOpenPipeDetail =
selectedChangeId && editSelection.editType !== "DELETE";
if (canOpenPipeDetail) {
openGisEditPipeDetail(selectedChangeId);
setFeatureData(
selectedChange?.editType === "ADD"
? {
lyrTypeNm: "tb_ms_swage_ppl",
fcltMngNo: "",
mngInstCd: "",
fclt_mng_no: "",
inst_mng_no: "",
}
: null,
);
window.setTimeout(() => {
setGisEditDetailChangeId(selectedChangeId);
setIsOpen(true);
}, 0);
setIsSearchModalOpen(false);
}
});
return () => {
unByKey(clickKey);
};
}, [
applySelectedVertexHandleStyle,
clearSelectedVertexHandle,
editActiveTool,
editChanges,
editIsEditMode,
editPendingInteraction,
editAnnounceStatus,
editRegisterFeatureSelection,
editSelectedLayer,
mapReady,
openGisEditPipeDetail,
gisMaps,
gisEditVertexLayerRef,
gisEditSelectedVertexHandleKeyRef,
gisEditSelectedVertexMetaListRef,
setGisEditDetailChangeId,
setFeatureData,
setIsOpen,
setIsSearchModalOpen,
gisEditMoveGripMetaRef,
]);
useEffect(() => {
const select = GisApp.Module.select as any;
if (!select?.setActive) return;
if (editIsEditMode && editActiveTool) {
select.resetSelectedFeature?.();
select.setActive(false);
return;
}
select.setActive(true);
}, [editActiveTool, editIsEditMode, mapReady]);
return {};
}
@@ -0,0 +1,821 @@
"use client";
import type Geometry from "ol/geom/Geometry.js";
import type * as React from "react";
import {
type VertexHandleMeta,
type VertexHandleCandidate,
} from "@/features/gis-edit/model/vertex-handles";
import {
createGisEditPreviewStyle,
GIS_EDIT_VERTEX_DRAGGING_STYLE,
getGisEditVertexHandleStyle,
} from "@/features/gis-edit/lib/preview-styles";
import {
createVertexHandleFeature,
buildVertexHandleFeatures,
findHoveredMidpointCandidate,
} from "@/features/gis-edit/lib/vertex-handles";
import {
applyVertexHandleMove,
deleteSelectedVertexHandles,
applyGeometryMoveSnap,
getClosestGeometryVertexCoordinate,
getGeometryVertexCoordinateByMeta,
} from "@/features/gis-edit/lib/geometry-mutations";
import { useCallback, useEffect, useRef } from "react";
import Collection from "ol/Collection.js";
import Feature from "ol/Feature.js";
import GeoJSON from "ol/format/GeoJSON.js";
import Snap from "ol/interaction/Snap.js";
import Translate from "ol/interaction/Translate.js";
import VectorLayer from "ol/layer/Vector.js";
import { unByKey } from "ol/Observable.js";
import VectorSource from "ol/source/Vector.js";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { useGisEdit } from "../components/gis-edit/use-gis-edit";
import {
FLOW_DIRECTION_SRS,
GIS_EDIT_VERTEX_LAYER_ID,
} from "../model/map-constants";
import { geometryObjectToKey, isTypingElement } from "./map-values";
import { normalizeGisEditFid, getGisEditFeatureFid } from "./gis-edit-features";
import { getSelectedFeatureLayerTypeName } from "./right-panel-features";
/** 이동·꼭지점 편집 interaction과 선택 핸들의 생성·정리를 소유한다. */
type Options = {
gisMaps: React.RefObject<any>;
gisEditPreviewSourceRef: React.RefObject<VectorSource<Geometry> | null>;
gisEdit: ReturnType<typeof useGisEdit>;
gisEditPreviewLayerRef: React.RefObject<any>;
setGisEditPreviewCursor: (cursor: string) => void;
mapReady: boolean;
ensureGisEditPreviewLayer: () => VectorSource<Geometry> | null;
shouldUsePipeSnapSource: boolean;
ensureGisEditPipeSnapSource: () => VectorSource<Geometry>;
gisEditMoveGripMetaRef: React.RefObject<{
changeId: string;
meta: VertexHandleMeta | null;
} | null>;
};
/** 이동·꼭지점 편집 interaction과 선택 핸들의 생성·정리를 소유한다. */
export function useGisEditTransform({
gisMaps,
gisEditPreviewSourceRef,
gisEdit,
gisEditPreviewLayerRef,
setGisEditPreviewCursor,
mapReady,
ensureGisEditPreviewLayer,
shouldUsePipeSnapSource,
ensureGisEditPipeSnapSource,
gisEditMoveGripMetaRef,
}: Options) {
// 편집 세션 전체 객체 대신 실제 사용하는 상태와 명령만 의존성으로 추적한다.
const {
selectedLayer: editSelectedLayer,
changes: editChanges,
isEditMode: editIsEditMode,
pendingInteraction: editPendingInteraction,
clearPendingInteraction: editClearPendingInteraction,
isSnapEnabled: editIsSnapEnabled,
updateChangeGeometry: editUpdateChangeGeometry,
announceStatus: editAnnounceStatus,
activeTool: editActiveTool,
} = gisEdit;
// vertex Delete는 현재 선택된 주황색 실꼭지점 묶음을 ref로 기억해 키보드 이벤트에서 즉시 사용한다.
const gisEditSelectedVertexHandleKeyRef = useRef("");
const gisEditSelectedVertexMetaListRef = useRef<VertexHandleMeta[] | null>(
null,
);
const gisEditVertexLayerRef = useRef<VectorLayer<any> | null>(null);
const gisEditVertexSourceRef = useRef<VectorSource | null>(null);
const gisEditTranslateRef = useRef<any | null>(null);
const gisEditModifyRef = useRef<any | null>(null);
// 스냅 interaction은 MOVE/VERTEX_EDIT 진입마다 다시 붙이므로 별도 ref로 정리한다.
const gisEditSnapRef = useRef<any | null>(null);
const ensureGisEditVertexLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (gisEditVertexLayerRef.current && gisEditVertexSourceRef.current) {
return gisEditVertexSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 10005,
updateWhileAnimating: true,
updateWhileInteracting: true,
});
layer.set("id", GIS_EDIT_VERTEX_LAYER_ID);
layer.set("lyrNm", "GIS 편집 꼭지점");
layer.set("lyrTyNm", GIS_EDIT_VERTEX_LAYER_ID);
layer.set("clickPsblYn", "N");
const core = GisApp.Module.core as any;
core?.deleteLayer?.(GIS_EDIT_VERTEX_LAYER_ID);
map.addLayer(layer);
gisEditVertexLayerRef.current = layer;
gisEditVertexSourceRef.current = source;
return source;
}, [gisMaps]);
const clearGisEditVertexLayer = useCallback(() => {
gisEditVertexSourceRef.current?.clear();
gisEditSelectedVertexHandleKeyRef.current = "";
gisEditSelectedVertexMetaListRef.current = null;
}, []);
const applySelectedVertexHandleStyle = useCallback(() => {
const selectedHandleKey = gisEditSelectedVertexHandleKeyRef.current;
const vertexSource = gisEditVertexSourceRef.current;
if (!vertexSource) return;
vertexSource.getFeatures().forEach((feature: any) => {
const handleKind = String(
feature?.get?.("handleKind") ?? "",
) as VertexHandleMeta["kind"];
const handleSelectionKey = String(
feature?.get?.("handleSelectionKey") ?? "",
);
const isSelected =
Boolean(selectedHandleKey) && handleSelectionKey === selectedHandleKey;
feature.setStyle(getGisEditVertexHandleStyle(handleKind, isSelected));
});
vertexSource.changed?.();
}, []);
const clearSelectedVertexHandle = useCallback(() => {
gisEditSelectedVertexHandleKeyRef.current = "";
gisEditSelectedVertexMetaListRef.current = null;
applySelectedVertexHandleStyle();
}, [applySelectedVertexHandleStyle]);
const collectGisEditSnapFeatures = useCallback(
(targetFeature: any) => {
const map = gisMaps.current;
const previewSource = gisEditPreviewSourceRef.current;
const selectedLayerId = editSelectedLayer?.layerId;
if (!map || !previewSource || !selectedLayerId) return [] as any[];
const hiddenFidSet = new Set(
editChanges
.filter((change) => change.editType === "DELETE")
.map((change) =>
normalizeGisEditFid(
change.fid ?? change.featureRef?.fid ?? change.objectId,
),
)
.filter(Boolean),
);
const seen = new Set<string>();
const snapFeatures: any[] = [];
const targetChangeId = String(
targetFeature?.get?.("changeId") ?? "",
).trim();
const registerFeature = (feature: any) => {
if (!feature || feature === targetFeature) return;
if (!feature.getGeometry?.()) return;
if (getSelectedFeatureLayerTypeName(feature) !== selectedLayerId)
return;
const changeId = String(feature.get?.("changeId") ?? "").trim();
if (changeId && changeId === targetChangeId) return;
if (String(feature.get?.("editType") ?? "").trim() === "DELETE") return;
const fid = getGisEditFeatureFid(feature);
if (fid && hiddenFidSet.has(fid)) return;
const objectKey =
changeId ||
fid ||
String(feature.get?.("objectId") ?? feature.getId?.() ?? "");
const featureKey = `${selectedLayerId}:${objectKey}`;
if (!objectKey || seen.has(featureKey)) return;
seen.add(featureKey);
snapFeatures.push(feature);
};
previewSource.getFeatures().forEach(registerFeature);
map.getAllLayers?.().forEach((layer: any) => {
if (
layer === gisEditPreviewLayerRef.current ||
layer === gisEditVertexLayerRef.current
)
return;
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
if (layerTypeName !== selectedLayerId) return;
layer.getSource?.()?.getFeatures?.().forEach(registerFeature);
});
return snapFeatures;
},
[
editChanges,
editSelectedLayer?.layerId,
gisEditPreviewLayerRef,
gisEditPreviewSourceRef,
gisMaps,
],
);
const removeGisEditInteractions = useCallback(() => {
const map = gisMaps.current;
if (!map) return;
if (gisEditSnapRef.current) {
map.removeInteraction(gisEditSnapRef.current);
gisEditSnapRef.current = null;
}
if (gisEditTranslateRef.current) {
map.removeInteraction(gisEditTranslateRef.current);
gisEditTranslateRef.current = null;
}
if (gisEditModifyRef.current) {
const modifyInteractions = Array.isArray(gisEditModifyRef.current)
? gisEditModifyRef.current
: [gisEditModifyRef.current];
modifyInteractions.forEach((interaction: any) => {
if (interaction) {
map.removeInteraction(interaction);
}
});
gisEditModifyRef.current = null;
}
clearGisEditVertexLayer();
setGisEditPreviewCursor("");
}, [clearGisEditVertexLayer, gisMaps, setGisEditPreviewCursor]);
useEffect(() => {
if (
!mapReady ||
!gisMaps.current ||
!editIsEditMode ||
!editPendingInteraction
)
return;
const source = ensureGisEditPreviewLayer();
const map = gisMaps.current;
if (!source || !map) return;
const targetFeature = source
.getFeatures()
.find(
(feature: any) =>
feature.get("changeId") === editPendingInteraction?.changeId,
);
if (!targetFeature) {
editClearPendingInteraction();
return;
}
removeGisEditInteractions();
const features = new Collection([targetFeature]);
let cleanupPointerMove: (() => void) | undefined;
const writeGeometryObject = () => {
const geometry = targetFeature.getGeometry();
if (!geometry) return null;
return new GeoJSON().writeGeometryObject(geometry, {
featureProjection: GisApp.TO_PROJ,
dataProjection: FLOW_DIRECTION_SRS,
}) as Record<string, unknown>;
};
const usePipeSnapSource =
shouldUsePipeSnapSource &&
(editPendingInteraction.editType === "MOVE" ||
editPendingInteraction.editType === "VERTEX_EDIT");
const pipeSnapSource = usePipeSnapSource
? ensureGisEditPipeSnapSource()
: null;
const pipeSnapSourceHasFeatures = Boolean(
pipeSnapSource?.getFeatures?.().length,
);
const snapFeatures =
(!usePipeSnapSource || !pipeSnapSourceHasFeatures) && editIsSnapEnabled
? collectGisEditSnapFeatures(targetFeature)
: [];
if (editPendingInteraction.editType === "MOVE") {
const translate = new Translate({ features, hitTolerance: 10 });
let lastTranslateCoordinate: number[] | null = null;
let translateStartCoordinate: number[] | null = null;
let originalMoveGeometrySnapshot =
targetFeature.getGeometry?.()?.clone?.() ?? null;
let moveGripCoordinate: number[] | null = null;
const moveGripMeta =
gisEditMoveGripMetaRef.current?.changeId ===
editPendingInteraction.changeId
? gisEditMoveGripMetaRef.current.meta
: null;
translate.on("translatestart", (event: any) => {
lastTranslateCoordinate = Array.isArray(event?.coordinate)
? event.coordinate
: null;
translateStartCoordinate = Array.isArray(event?.coordinate)
? event.coordinate
: null;
originalMoveGeometrySnapshot =
targetFeature.getGeometry?.()?.clone?.() ?? null;
moveGripCoordinate =
getGeometryVertexCoordinateByMeta(
originalMoveGeometrySnapshot,
moveGripMeta,
) ??
getClosestGeometryVertexCoordinate(
originalMoveGeometrySnapshot,
map,
translateStartCoordinate,
);
targetFeature.set("isDragging", true);
targetFeature.set("previewLabel", "이동 중");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("grabbing");
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
translate.on("translating", (event: any) => {
lastTranslateCoordinate = Array.isArray(event?.coordinate)
? event.coordinate
: lastTranslateCoordinate;
if (
originalMoveGeometrySnapshot?.clone &&
Array.isArray(lastTranslateCoordinate) &&
Array.isArray(translateStartCoordinate)
) {
const geometry = originalMoveGeometrySnapshot.clone();
const deltaX =
Number(lastTranslateCoordinate[0] ?? 0) -
Number(translateStartCoordinate[0] ?? 0);
const deltaY =
Number(lastTranslateCoordinate[1] ?? 0) -
Number(translateStartCoordinate[1] ?? 0);
geometry.translate(deltaX, deltaY);
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
const movedGripCoordinate = Array.isArray(moveGripCoordinate)
? [
Number(moveGripCoordinate[0] ?? 0) + deltaX,
Number(moveGripCoordinate[1] ?? 0) + deltaY,
]
: lastTranslateCoordinate;
applyGeometryMoveSnap(
geometry,
pipeSnapSource,
map,
movedGripCoordinate,
28,
);
}
targetFeature.setGeometry(geometry);
}
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
translate.on("translateend", (event: any) => {
const objectId = String(targetFeature.get("objectId") ?? "");
const layerName = editSelectedLayer?.layerName ?? "선택 레이어";
lastTranslateCoordinate = Array.isArray(event?.coordinate)
? event.coordinate
: lastTranslateCoordinate;
targetFeature.set("isDragging", false);
targetFeature.set("previewLabel", "드래그 이동");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("grab");
if (
originalMoveGeometrySnapshot?.clone &&
Array.isArray(lastTranslateCoordinate) &&
Array.isArray(translateStartCoordinate)
) {
const geometry = originalMoveGeometrySnapshot.clone();
const deltaX =
Number(lastTranslateCoordinate[0] ?? 0) -
Number(translateStartCoordinate[0] ?? 0);
const deltaY =
Number(lastTranslateCoordinate[1] ?? 0) -
Number(translateStartCoordinate[1] ?? 0);
geometry.translate(deltaX, deltaY);
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
const movedGripCoordinate = Array.isArray(moveGripCoordinate)
? [
Number(moveGripCoordinate[0] ?? 0) + deltaX,
Number(moveGripCoordinate[1] ?? 0) + deltaY,
]
: lastTranslateCoordinate;
applyGeometryMoveSnap(
geometry,
pipeSnapSource,
map,
movedGripCoordinate,
28,
);
}
targetFeature.setGeometry(geometry);
}
editUpdateChangeGeometry(
editPendingInteraction!.changeId,
writeGeometryObject(),
`${layerName} ${objectId} 객체 위치를 지도에서 이동했습니다.`,
{ keepMoveToolActive: true },
);
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
map.addInteraction(translate);
gisEditTranslateRef.current = translate;
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
const snap = new Snap({
source: pipeSnapSource,
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(snap);
gisEditSnapRef.current = snap;
} else if (editIsSnapEnabled && snapFeatures.length > 0) {
const snap = new Snap({
features: new Collection(snapFeatures),
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(snap);
gisEditSnapRef.current = snap;
}
setGisEditPreviewCursor("grab");
map.renderSync?.();
}
if (editPendingInteraction.editType === "VERTEX_EDIT") {
const geometry = targetFeature.getGeometry?.();
const geometryType = geometry?.getType?.();
const objectId = String(targetFeature.get("objectId") ?? "");
const layerName = editSelectedLayer?.layerName ?? "선택 레이어";
if (
!geometry ||
!geometryType ||
geometryType === "Point" ||
geometryType === "MultiPoint"
) {
editAnnounceStatus(
`${layerName} ${objectId} 객체는 꼭지점편집 대상 geometry가 아닙니다.`,
);
editClearPendingInteraction();
return;
}
const vertexSource = ensureGisEditVertexLayer();
const vertexLayer = gisEditVertexLayerRef.current;
if (!vertexSource || !vertexLayer) {
editAnnounceStatus("꼭지점 편집 레이어를 준비하지 못했습니다.");
editClearPendingInteraction();
return;
}
const renderVertexHandles = (
hoveredMidpoint: VertexHandleCandidate | null = null,
) => {
vertexSource.clear();
buildVertexHandleFeatures(targetFeature.getGeometry?.(), map).forEach(
(handleFeature: Feature) => {
vertexSource.addFeature(handleFeature);
},
);
if (hoveredMidpoint) {
vertexSource.addFeature(
createVertexHandleFeature(
hoveredMidpoint.coordinate,
hoveredMidpoint.meta,
),
);
}
vertexSource.changed?.();
applySelectedVertexHandleStyle();
};
let geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject());
let activeMidpointKey = "";
let isDraggingHandle = false;
let originalGeometrySnapshot =
targetFeature.getGeometry?.()?.clone?.() ?? null;
renderVertexHandles();
targetFeature.set("previewLabel", "꼭지점 편집");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("crosshair");
const pointerMoveKey = map.on("pointermove", (event: any) => {
if (isDraggingHandle || event?.dragging) return;
const hoveredMidpoint = findHoveredMidpointCandidate(
map,
targetFeature.getGeometry?.(),
event.coordinate,
);
const nextMidpointKey = hoveredMidpoint
? `${hoveredMidpoint.meta.geometryType}:${hoveredMidpoint.meta.partIndex ?? -1}:${hoveredMidpoint.meta.polygonIndex ?? -1}:${hoveredMidpoint.meta.ringIndex ?? -1}:${hoveredMidpoint.meta.coordIndex}`
: "";
if (nextMidpointKey === activeMidpointKey) return;
activeMidpointKey = nextMidpointKey;
renderVertexHandles(hoveredMidpoint);
setGisEditPreviewCursor(hoveredMidpoint ? "copy" : "crosshair");
map.renderSync?.();
});
const vertexTranslate = new Translate({
layers: [vertexLayer],
hitTolerance: 14,
});
vertexTranslate.on("translatestart", (event: any) => {
isDraggingHandle = true;
geometryBeforeModifyKey = geometryObjectToKey(writeGeometryObject());
originalGeometrySnapshot =
targetFeature.getGeometry?.()?.clone?.() ?? null;
clearSelectedVertexHandle();
const handleFeature = event.features?.item?.(0);
if (handleFeature) {
handleFeature.setStyle(GIS_EDIT_VERTEX_DRAGGING_STYLE);
}
targetFeature.set("previewLabel", "꼭지점 편집 중");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("grabbing");
map.renderSync?.();
});
vertexTranslate.on("translating", (event: any) => {
const handleFeature = event.features?.item?.(0);
const handleGeometry = handleFeature?.getGeometry?.();
const nextCoordinate = handleGeometry?.getCoordinates?.();
const handleMeta = handleFeature?.get?.("handleMeta") as
| VertexHandleMeta
| undefined;
const handleMetaList =
(handleFeature?.get?.("handleMetaList") as
| VertexHandleMeta[]
| undefined) ?? (handleMeta ? [handleMeta] : []);
if (
handleMetaList.length === 0 ||
!Array.isArray(nextCoordinate) ||
!originalGeometrySnapshot?.clone
) {
return;
}
targetFeature.setGeometry(originalGeometrySnapshot.clone());
handleMetaList.forEach((meta) => {
applyVertexHandleMove(
targetFeature.getGeometry?.(),
meta,
nextCoordinate,
);
});
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
vertexTranslate.on("translateend", (event: any) => {
isDraggingHandle = false;
const handleFeature = event.features?.item?.(0);
const handleGeometry = handleFeature?.getGeometry?.();
const nextCoordinate = handleGeometry?.getCoordinates?.();
const handleMeta = handleFeature?.get?.("handleMeta") as
| VertexHandleMeta
| undefined;
const handleMetaList =
(handleFeature?.get?.("handleMetaList") as
| VertexHandleMeta[]
| undefined) ?? (handleMeta ? [handleMeta] : []);
targetFeature.set("previewLabel", "꼭지점 편집");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
setGisEditPreviewCursor("crosshair");
activeMidpointKey = "";
if (handleMetaList.length === 0 || !Array.isArray(nextCoordinate)) {
renderVertexHandles();
map.renderSync?.();
return;
}
handleMetaList.forEach((meta) => {
applyVertexHandleMove(
targetFeature.getGeometry?.(),
meta,
nextCoordinate,
);
});
const nextGeometry = writeGeometryObject();
const geometryChanged =
geometryBeforeModifyKey !== geometryObjectToKey(nextGeometry);
renderVertexHandles();
if (!geometryChanged) {
editAnnounceStatus(
`${layerName} ${objectId} 객체는 아직 형상이 바뀌지 않았습니다. 주황색 꼭지점 또는 가상 꼭지점을 직접 드래그해 주세요.`,
);
map.renderSync?.();
return;
}
editUpdateChangeGeometry(
editPendingInteraction!.changeId,
nextGeometry,
`${layerName} ${objectId} 객체 꼭지점을 지도에서 조정했습니다.`,
{ keepVertexEditActive: true },
);
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
});
map.addInteraction(vertexTranslate);
gisEditModifyRef.current = vertexTranslate;
if (pipeSnapSource && pipeSnapSourceHasFeatures) {
const snap = new Snap({
source: pipeSnapSource,
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(snap);
gisEditSnapRef.current = snap;
} else if (editIsSnapEnabled && snapFeatures.length > 0) {
const snap = new Snap({
features: new Collection(snapFeatures),
edge: true,
vertex: true,
pixelTolerance: 14,
});
map.addInteraction(snap);
gisEditSnapRef.current = snap;
}
gisMaps.current?.renderSync?.();
cleanupPointerMove = () => {
unByKey(pointerMoveKey);
};
}
return cleanupPointerMove;
}, [
applySelectedVertexHandleStyle,
clearGisEditVertexLayer,
clearSelectedVertexHandle,
collectGisEditSnapFeatures,
ensureGisEditPipeSnapSource,
ensureGisEditVertexLayer,
ensureGisEditPreviewLayer,
editIsSnapEnabled,
editPendingInteraction,
editSelectedLayer,
editUpdateChangeGeometry,
editAnnounceStatus,
editClearPendingInteraction,
editIsEditMode,
mapReady,
removeGisEditInteractions,
shouldUsePipeSnapSource,
gisMaps,
gisEditMoveGripMetaRef,
setGisEditPreviewCursor,
]);
useEffect(() => {
if (
!mapReady ||
!gisMaps.current ||
!editIsEditMode ||
editPendingInteraction?.editType !== "VERTEX_EDIT"
) {
clearSelectedVertexHandle();
return;
}
const pendingInteraction = editPendingInteraction;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Delete") return;
if (
isTypingElement(event.target) ||
isTypingElement(document.activeElement)
)
return;
const source = ensureGisEditPreviewLayer();
const map = gisMaps.current;
if (!source || !map) return;
const targetFeature = source
.getFeatures()
.find(
(feature: any) =>
feature.get("changeId") === editPendingInteraction?.changeId,
);
if (!targetFeature) return;
const selectedMetaList = gisEditSelectedVertexMetaListRef.current ?? [];
if (selectedMetaList.length === 0) {
editAnnounceStatus(
"Delete로 삭제할 주황색 실꼭지점을 먼저 클릭하세요.",
);
return;
}
const deleteResult = deleteSelectedVertexHandles(
targetFeature.getGeometry?.(),
selectedMetaList,
);
if (!deleteResult.ok) {
editAnnounceStatus(
deleteResult.message ?? "꼭지점을 삭제하지 못했습니다.",
);
return;
}
event.preventDefault();
clearSelectedVertexHandle();
const objectId = String(targetFeature.get("objectId") ?? "");
const layerName = editSelectedLayer?.layerName ?? "선택 레이어";
const geometry = targetFeature.getGeometry();
if (!geometry) {
editAnnounceStatus("꼭지점 삭제 후 geometry를 확인하지 못했습니다.");
return;
}
targetFeature.set("previewLabel", "꼭지점 편집");
targetFeature.setStyle(createGisEditPreviewStyle(targetFeature));
editUpdateChangeGeometry(
pendingInteraction.changeId,
new GeoJSON().writeGeometryObject(geometry, {
featureProjection: GisApp.TO_PROJ,
dataProjection: FLOW_DIRECTION_SRS,
}) as Record<string, unknown>,
`${layerName} ${objectId} 객체 꼭지점을 삭제했습니다.`,
{ keepVertexEditActive: true },
);
targetFeature.changed?.();
source.changed?.();
map.renderSync?.();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [
clearSelectedVertexHandle,
ensureGisEditPreviewLayer,
editIsEditMode,
editPendingInteraction,
editSelectedLayer,
editUpdateChangeGeometry,
editAnnounceStatus,
mapReady,
gisMaps,
]);
useEffect(() => {
if (
!editIsEditMode ||
(editActiveTool !== "MOVE" && editActiveTool !== "VERTEX_EDIT")
) {
removeGisEditInteractions();
}
}, [editActiveTool, editIsEditMode, removeGisEditInteractions]);
useEffect(
() => () => {
removeGisEditInteractions();
if (gisEditVertexLayerRef.current)
gisMaps.current?.removeLayer?.(gisEditVertexLayerRef.current);
gisEditVertexLayerRef.current = null;
gisEditVertexSourceRef.current = null;
},
[gisMaps, removeGisEditInteractions],
);
return {
gisEditSelectedVertexHandleKeyRef,
gisEditSelectedVertexMetaListRef,
gisEditVertexLayerRef,
clearGisEditVertexLayer,
applySelectedVertexHandleStyle,
clearSelectedVertexHandle,
removeGisEditInteractions,
};
}
@@ -0,0 +1,139 @@
"use client";
import { fitMapSelection, mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
import type { useFloodPredictionMap } from "@/features/fldng-predc-prst/lib/use-flood-prediction-map";
import {
useEffect,
type MutableRefObject,
type Dispatch,
type SetStateAction,
} from "react";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
type MapRuntimeBridgeOptions = {
/** 공통 Gis가 생성한 지도 참조. 생성/파괴 소유권은 Gis에 유지한다. */
gisMaps: MutableRefObject<any>;
setMapReady: Dispatch<SetStateAction<boolean>>;
handleFloodPredictionPopupSelection: ReturnType<
typeof useFloodPredictionMap
>["handleFloodPredictionPopupSelection"];
};
/** 지도 준비 감지와 정보검색/GIS 업로드 메시지 연결을 소유한다.
* 기존 300ms 준비 polling 및 callback 변경 시 구독 갱신/정리 순서를 보존한다.
* 지도 객체 자체를 별도로 만들거나 전역 store에 저장하지 않는다.
*/
export function useMapRuntimeBridge({
gisMaps,
setMapReady,
handleFloodPredictionPopupSelection,
}: MapRuntimeBridgeOptions) {
useEffect(() => {
const timer = window.setInterval(() => {
if (gisMaps.current) {
setMapReady(true);
window.clearInterval(timer);
}
}, 300);
const handlePopupMessage = async (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
const core = GisApp.Module.layer as any;
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE) {
const layerNm = data.payload?.layerNm;
const fid = data.payload?.fid;
if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return;
await core.showSearchResultsFromGeoJSON(layerNm, fid, mapSearchNavigationOptions(gisMaps.current));
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE) {
const layerNm = data.payload?.layerNm;
const fidsRaw = data.payload?.fids;
if (!layerNm || !fidsRaw || !core?.showMultipleSearchResultsFromGeoJSON)
return;
let fids: string[] = [];
try {
fids = JSON.parse(fidsRaw);
} catch (error) {
console.error("다중 시설 메시지 파싱 실패:", error);
return;
}
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids, mapSearchNavigationOptions(gisMaps.current));
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FIT_EXTENT) {
const extent = Array.isArray(data.payload?.extent)
? data.payload.extent.map(Number)
: [];
const map = gisMaps.current;
if (
extent.length !== 4 ||
extent.some((value: number) => !Number.isFinite(value)) ||
!map?.getView
) {
return;
}
fitMapSelection(map, extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
return;
}
if (
data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP
) {
await handleFloodPredictionPopupSelection(data.payload, {
source: event.source as Window | null,
origin: event.origin,
});
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED) {
const rawLayerTypeNames = data.payload?.refreshLayerTypeNames;
let layerTypeNames = ["tb_ms_swage_ppl"];
if (rawLayerTypeNames) {
try {
const parsed = JSON.parse(rawLayerTypeNames);
if (Array.isArray(parsed) && parsed.length > 0) {
layerTypeNames = parsed
.map((item) => String(item ?? "").trim())
.filter(Boolean);
}
} catch (error) {
console.error("GIS 업로드 레이어 refresh 메시지 파싱 실패:", error);
}
}
const refreshTargetNames = new Set(layerTypeNames);
gisMaps.current?.getAllLayers?.().forEach((layer: any) => {
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
if (!refreshTargetNames.has(layerTypeName)) return;
const source = layer.getSource?.();
source?.updateParams?.({ _t: Date.now() });
source?.refresh?.();
source?.changed?.();
layer.changed?.();
});
}
};
window.addEventListener("message", handlePopupMessage);
return () => {
window.clearInterval(timer);
window.removeEventListener("message", handlePopupMessage);
};
}, [gisMaps, setMapReady, handleFloodPredictionPopupSelection]);
}
@@ -0,0 +1,368 @@
"use client";
import type { SelectDataType } from "@common/business_components";
import type { SinkholeScope } from "../components/sinkhole/sinkhole-types";
import type * as React from "react";
import { useCallback, useEffect, useRef } from "react";
import Feature from "ol/Feature.js";
import GeoJSON from "ol/format/GeoJSON.js";
import CircleGeom from "ol/geom/Circle.js";
import Point from "ol/geom/Point.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getRawJson } from "@/shared/api/sewerage-api";
import {
buildSinkholeListQueryString,
normalizeSinkholeFilters,
SINKHOLE_LAYER_ID,
SINKHOLE_LAYER_NAME,
SINKHOLE_LAYER_TYPE,
SINKHOLE_SELECTED_LAYER_ID,
type SinkholeDetail,
type SinkholeFilters,
type SinkholeListItem,
} from "../components/sinkhole/sinkhole-types";
import { SINKHOLE_DEFAULT_POINT_LIMIT } from "../model/map-constants";
import {
buildSinkholeFeatureCollectionFromItems,
resolveSinkholePointVariant,
createSinkholePointStyle,
createSinkholeRadiusStyle,
createSinkholeRadiusLabelStyle,
} from "./sinkhole-features";
/** 지반침하 사고·반경·주변관로 도형의 조회와 표시를 소유한다. */
type Options = {
gisMaps: React.RefObject<any>;
ctpv: SelectDataType[];
sgg: SelectDataType[];
appliedSinkholeFilters: SinkholeFilters;
mapReady: boolean;
isSinkholeChecked: boolean;
sinkholeScope: SinkholeScope;
sinkholeSelectionVersion: number;
selectedSinkholeDetail: SinkholeDetail | null;
sinkholeRadiusMeters: number;
};
/** 지반침하 사고·반경·주변관로 도형의 조회와 표시를 소유한다. */
export function useSinkholeLayers({
gisMaps,
ctpv,
sgg,
appliedSinkholeFilters,
mapReady,
isSinkholeChecked,
sinkholeScope,
sinkholeSelectionVersion,
selectedSinkholeDetail,
sinkholeRadiusMeters,
}: Options) {
const sinkholeLayerRef = useRef<VectorLayer<any> | null>(null);
// 검색 변경·레이어 해제 후 늦은 응답이 지도를 덮어쓰지 않도록 요청 수명을 보관한다.
const requestSequence = useRef(0);
const requestController = useRef<AbortController | null>(null);
const sinkholeSourceRef = useRef<VectorSource | null>(null);
const sinkholeSelectedLayerRef = useRef<VectorLayer<any> | null>(null);
const sinkholeSelectedSourceRef = useRef<VectorSource | null>(null);
const sinkholeFocusedPipeLayerRef = useRef<VectorLayer<any> | null>(null);
const sinkholeFocusedPipeSourceRef = useRef<VectorSource | null>(null);
const removeSinkholeLayer = useCallback(() => {
requestSequence.current += 1;
requestController.current?.abort();
requestController.current = null;
const core = GisApp.Module.core as any;
core?.deleteLayer?.(SINKHOLE_LAYER_ID);
sinkholeLayerRef.current = null;
sinkholeSourceRef.current = null;
}, []);
const ensureSinkholeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (sinkholeLayerRef.current && sinkholeSourceRef.current) {
return sinkholeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9992,
updateWhileAnimating: false,
updateWhileInteracting: false,
});
layer.set("id", SINKHOLE_LAYER_ID);
layer.set("lyrNm", SINKHOLE_LAYER_NAME);
layer.set("lyrTyNm", SINKHOLE_LAYER_TYPE);
layer.set("clickPsblYn", "Y");
layer.set("chcYn", "N");
const core = GisApp.Module.core as any;
core?.deleteLayer?.(SINKHOLE_LAYER_ID);
map.addLayer(layer);
sinkholeLayerRef.current = layer;
sinkholeSourceRef.current = source;
return source;
}, [gisMaps]);
const clearSinkholeSelectionLayer = useCallback(() => {
const core = GisApp.Module.core as any;
core?.deleteLayer?.(SINKHOLE_SELECTED_LAYER_ID);
sinkholeSelectedLayerRef.current = null;
sinkholeSelectedSourceRef.current = null;
}, []);
const clearSinkholeFocusedPipeLayer = useCallback(() => {
const core = GisApp.Module.core as any;
core?.deleteLayer?.("SN_SINKHOLE_FOCUSED_PIPE_LAYER");
sinkholeFocusedPipeLayerRef.current = null;
sinkholeFocusedPipeSourceRef.current = null;
}, []);
const ensureSinkholeSelectionLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (sinkholeSelectedLayerRef.current && sinkholeSelectedSourceRef.current) {
return sinkholeSelectedSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9996,
updateWhileAnimating: false,
updateWhileInteracting: false,
});
layer.set("id", SINKHOLE_SELECTED_LAYER_ID);
layer.set("lyrNm", `${SINKHOLE_LAYER_NAME}-선택`);
layer.set("lyrTyNm", SINKHOLE_LAYER_TYPE);
layer.set("clickPsblYn", "N");
const core = GisApp.Module.core as any;
core?.deleteLayer?.(SINKHOLE_SELECTED_LAYER_ID);
map.addLayer(layer);
sinkholeSelectedLayerRef.current = layer;
sinkholeSelectedSourceRef.current = source;
return source;
}, [gisMaps]);
const ensureSinkholeFocusedPipeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (
sinkholeFocusedPipeLayerRef.current &&
sinkholeFocusedPipeSourceRef.current
) {
return sinkholeFocusedPipeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9999,
updateWhileAnimating: false,
updateWhileInteracting: false,
});
layer.set("id", "SN_SINKHOLE_FOCUSED_PIPE_LAYER");
layer.set("lyrNm", "지반침하-관로강조");
layer.set("lyrTyNm", "tb_ms_swage_ppl");
layer.set("clickPsblYn", "N");
const core = GisApp.Module.core as any;
core?.deleteLayer?.("SN_SINKHOLE_FOCUSED_PIPE_LAYER");
map.addLayer(layer);
sinkholeFocusedPipeLayerRef.current = layer;
sinkholeFocusedPipeSourceRef.current = source;
return source;
}, [gisMaps]);
const renderSelectedSinkhole = useCallback(
(detail: SinkholeDetail | null, radiusMeters: number) => {
if (!detail || detail.x == null || detail.y == null) {
clearSinkholeSelectionLayer();
return;
}
const source = ensureSinkholeSelectionLayer();
if (!source) return;
source.clear();
const pointFeature = new Feature({
geometry: new Point([detail.x, detail.y]),
});
const pointVariant = resolveSinkholePointVariant(detail.ocrnYmd);
pointFeature.setStyle(createSinkholePointStyle(pointVariant, true));
const radiusFeature = new Feature({
geometry: new CircleGeom([detail.x, detail.y], radiusMeters),
});
radiusFeature.setStyle(createSinkholeRadiusStyle(pointVariant));
// 원의 북쪽 경계에 라벨을 두고 같은 source에서 관리해 선택 해제 시 함께 정리한다.
const radiusLabelFeature = new Feature({
geometry: new Point([detail.x, detail.y + radiusMeters]),
});
radiusLabelFeature.set("sinkholeRadiusLabel", true);
radiusLabelFeature.setStyle(
createSinkholeRadiusLabelStyle(radiusMeters, pointVariant),
);
source.addFeatures([radiusFeature, radiusLabelFeature, pointFeature]);
},
[clearSinkholeSelectionLayer, ensureSinkholeSelectionLayer],
);
const focusSinkholeOnMap = useCallback(
(detail: SinkholeDetail, radiusMeters: number) => {
if (!gisMaps.current || detail.x == null || detail.y == null) return;
const radiusGeometry = new CircleGeom([detail.x, detail.y], radiusMeters);
gisMaps.current.getView?.().fit?.(radiusGeometry.getExtent(), {
duration: 350,
padding: [100, 380, 100, 120],
maxZoom: 17,
constrainResolution: true,
nearest: true,
});
},
[gisMaps],
);
const buildSinkholeQueryString = useCallback(
(filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT) =>
buildSinkholeListQueryString(filters, ctpv, sgg, page, size),
[ctpv, sgg],
);
const loadSinkholeLayer = useCallback(
async (filters = appliedSinkholeFilters) => {
if (!mapReady || !gisMaps.current || !isSinkholeChecked) return;
const source = ensureSinkholeLayer();
if (!source) return;
const effectiveFilters = normalizeSinkholeFilters(filters, sinkholeScope);
const sequence = ++requestSequence.current;
requestController.current?.abort();
const controller = new AbortController();
requestController.current = controller;
try {
const params = new URLSearchParams(
buildSinkholeQueryString(
effectiveFilters,
0,
SINKHOLE_DEFAULT_POINT_LIMIT,
),
);
const rawResponse = await getRawJson(
`/sewerage-service/api/v1/sinkholes?${params.toString()}`,
{ signal: controller.signal },
);
if (controller.signal.aborted || sequence !== requestSequence.current)
return;
const items = Array.isArray(rawResponse?.data?.items)
? (rawResponse.data.items as SinkholeListItem[])
: Array.isArray(rawResponse?.items)
? (rawResponse.items as SinkholeListItem[])
: [];
const featureCollection =
buildSinkholeFeatureCollectionFromItems(items);
if (!featureCollection) {
console.warn(
"지반침하 목록 응답을 feature collection으로 변환하지 못했습니다.",
rawResponse,
);
source.clear();
return;
}
const features = new GeoJSON().readFeatures(featureCollection, {
dataProjection: "EPSG:5179",
featureProjection: GisApp.TO_PROJ,
});
source.clear();
features.forEach((feature: any) => {
const variant = resolveSinkholePointVariant(
String(feature?.get?.("ocrnYmd") ?? ""),
);
feature.setStyle(createSinkholePointStyle(variant, false));
});
source.addFeatures(features);
} catch (error) {
if (controller.signal.aborted || sequence !== requestSequence.current)
return;
console.error("지반침하 레이어 조회 실패:", error);
source.clear();
} finally {
if (requestController.current === controller)
requestController.current = null;
}
},
[
appliedSinkholeFilters,
buildSinkholeQueryString,
ensureSinkholeLayer,
gisMaps,
isSinkholeChecked,
mapReady,
sinkholeScope,
],
);
useEffect(() => {
if (!mapReady || !isSinkholeChecked) {
removeSinkholeLayer();
return;
}
void loadSinkholeLayer(appliedSinkholeFilters);
}, [
appliedSinkholeFilters,
isSinkholeChecked,
loadSinkholeLayer,
mapReady,
removeSinkholeLayer,
sinkholeSelectionVersion,
]);
useEffect(() => {
renderSelectedSinkhole(selectedSinkholeDetail, sinkholeRadiusMeters);
}, [renderSelectedSinkhole, selectedSinkholeDetail, sinkholeRadiusMeters]);
// 이 hook이 만든 레이어만 해제한다. 다른 지도 인스턴스의 공용 레이어를 지우지 않는다.
useEffect(
() => () => {
requestSequence.current += 1;
requestController.current?.abort();
[
sinkholeLayerRef,
sinkholeSelectedLayerRef,
sinkholeFocusedPipeLayerRef,
].forEach((ref) => {
if (ref.current) gisMaps.current?.removeLayer?.(ref.current);
ref.current = null;
});
},
[gisMaps],
);
return {
removeSinkholeLayer,
clearSinkholeSelectionLayer,
clearSinkholeFocusedPipeLayer,
ensureSinkholeFocusedPipeLayer,
renderSelectedSinkhole,
focusSinkholeOnMap,
};
}
@@ -0,0 +1,492 @@
"use client";
import { beginMapNavigation } from "@/features/map-search/lib/map-navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Feature from "ol/Feature.js";
import GeoJSON from "ol/format/GeoJSON.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import {
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
type WaterRiskMapView,
type WaterRiskResultRow,
type WaterRiskSessionSnapshot,
} from "../components/water-risk/water-risk-types";
import {
WATER_RISK_AREA_LAYER_ID,
WATER_RISK_PIPE_LAYER_ID,
WATER_RISK_MANHOLE_LAYER_ID,
} from "../model/map-constants";
import {
createWaterRiskAreaStyle,
createWaterRiskPipeStyle,
createWaterRiskManholeStyle,
} from "./water-risk-styles";
import type { MutableRefObject } from "react";
type Options = {
gisMaps: MutableRefObject<any>;
mapReady: boolean;
onSelectResult: () => void;
};
/** 통수위험 결과 레이어·정보 카드·팝업 수명을 소유한다. 부모 화면 선택 정리는 명령 callback으로 요청한다. */
export function useWaterRiskLayer({
gisMaps,
mapReady,
onSelectResult,
}: Options) {
/** 팝업의 조회 시작부터 지도 이동까지 같은 요청 세대를 사용한다. */
const selectionNavigationRef = useRef<ReturnType<typeof beginMapNavigation> | null>(null);
const selectionRequestRef = useRef<string | undefined>(undefined);
const waterRiskPopupWindowRef = useRef<Window | null>(null);
const waterRiskPopupCloseWatcherRef = useRef<number | null>(null);
const waterRiskAreaLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskAreaSourceRef = useRef<VectorSource | null>(null);
const waterRiskPipeLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskPipeSourceRef = useRef<VectorSource | null>(null);
const waterRiskManholeLayerRef = useRef<VectorLayer<any> | null>(null);
const waterRiskManholeSourceRef = useRef<VectorSource | null>(null);
const [waterRiskSession, setWaterRiskSession] =
useState<WaterRiskSessionSnapshot>(EMPTY_WATER_RISK_SESSION_SNAPSHOT);
// 팝업에서 실제 API로 조회한 결과 행과 GeoJSON을 지도·정보카드의 단일 원본으로 보관한다.
const [selectedWaterRiskApiResult, setSelectedWaterRiskApiResult] =
useState<WaterRiskResultRow | null>(null);
const [selectedWaterRiskMapView, setSelectedWaterRiskMapView] =
useState<WaterRiskMapView | null>(null);
const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<
[number, number] | null
>(null);
const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState<Record<
string,
number
> | null>(null);
const waterRiskSessionSnapshot = useMemo<WaterRiskSessionSnapshot>(
() => waterRiskSession,
[waterRiskSession],
);
const syncWaterRiskPopup = useCallback(() => {
const popup = waterRiskPopupWindowRef.current;
if (!popup || popup.closed) {
waterRiskPopupWindowRef.current = null;
return;
}
popup.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC,
payload: waterRiskSessionSnapshot,
},
window.location.origin,
);
}, [waterRiskSessionSnapshot]);
const selectedWaterRiskResultRow = useMemo(
() => selectedWaterRiskApiResult,
[selectedWaterRiskApiResult],
);
// 지도카드는 mapView 위험상태를 우선하고, mapView 응답 전에는 목록 상태를 쓴다.
// 2026-10-20부터 mapView 위험상태는 목록과 같은 대표결과 판정값이므로 두 값이 다르지 않다.
const selectedWaterRiskDisplayStatus = useMemo<
WaterRiskResultRow["riskStatus"]
>(
() =>
selectedWaterRiskMapView?.riskStatus ??
selectedWaterRiskResultRow?.riskStatus ??
"NORMAL",
[
selectedWaterRiskMapView?.riskStatus,
selectedWaterRiskResultRow?.riskStatus,
],
);
/** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */
const syncWaterRiskInfoCardPosition = useCallback(() => {
const map = gisMaps.current;
if (!map || !waterRiskInfoAnchor) {
setWaterRiskInfoCardStyle(null);
return;
}
const [mapWidth = 0, mapHeight = 0] = map.getSize?.() ?? [];
const [pixelX = 0, pixelY = 0] =
map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? [];
const cardWidth = 244;
const cardHeight = 190;
const left = pixelX + 22;
const top = pixelY - cardHeight / 2;
if (
pixelX < 0 ||
pixelY < 0 ||
pixelX > mapWidth ||
pixelY > mapHeight ||
left > mapWidth ||
top > mapHeight ||
left + cardWidth < 0 ||
top + cardHeight < 0
) {
setWaterRiskInfoCardStyle(null);
return;
}
setWaterRiskInfoCardStyle({ left, top });
}, [waterRiskInfoAnchor]);
/** 정보 카드만 닫고 지도 결과·범례는 유지하며, 다음 결과 선택 시 다시 표시한다. */
const dismissWaterRiskInfoCard = useCallback(() => {
setWaterRiskInfoAnchor(null);
setWaterRiskInfoCardStyle(null);
}, []);
const clearWaterRiskPopupCloseWatcher = useCallback(() => {
if (waterRiskPopupCloseWatcherRef.current !== null) {
window.clearInterval(waterRiskPopupCloseWatcherRef.current);
waterRiskPopupCloseWatcherRef.current = null;
}
}, []);
const ensureWaterRiskAreaLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (waterRiskAreaLayerRef.current && waterRiskAreaSourceRef.current) {
const layers = map.getLayers?.().getArray?.() ?? [];
if (!layers.includes(waterRiskAreaLayerRef.current)) {
map.addLayer(waterRiskAreaLayerRef.current);
}
waterRiskAreaLayerRef.current.setZIndex?.(9996);
return waterRiskAreaSourceRef.current;
}
// 통수위험 overlay 초기화는 자신의 stale layer만 정리해 GIS 편집 preview를 보존한다.
const layers = map.getLayers?.().getArray?.() ?? [];
layers.forEach((existingLayer: any) => {
if (existingLayer?.get?.("id") === WATER_RISK_AREA_LAYER_ID) {
map.removeLayer(existingLayer);
}
});
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9996,
});
layer.set("id", WATER_RISK_AREA_LAYER_ID);
layer.set("lyrNm", "통수위험 중점관리지역");
layer.set("lyrTyNm", WATER_RISK_AREA_LAYER_ID);
map.addLayer(layer);
waterRiskAreaLayerRef.current = layer;
waterRiskAreaSourceRef.current = source;
return source;
}, []);
const ensureWaterRiskPipeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (waterRiskPipeLayerRef.current && waterRiskPipeSourceRef.current) {
return waterRiskPipeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 9998,
});
layer.set("id", WATER_RISK_PIPE_LAYER_ID);
layer.set("lyrNm", "통수위험 유출관로");
layer.set("lyrTyNm", WATER_RISK_PIPE_LAYER_ID);
map.addLayer(layer);
waterRiskPipeLayerRef.current = layer;
waterRiskPipeSourceRef.current = source;
return source;
}, []);
const ensureWaterRiskManholeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (waterRiskManholeLayerRef.current && waterRiskManholeSourceRef.current) {
return waterRiskManholeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({ source, zIndex: 9999 });
layer.set("id", WATER_RISK_MANHOLE_LAYER_ID);
layer.set("lyrNm", "통수위험 맨홀 Flooding");
layer.set("lyrTyNm", WATER_RISK_MANHOLE_LAYER_ID);
map.addLayer(layer);
waterRiskManholeLayerRef.current = layer;
waterRiskManholeSourceRef.current = source;
return source;
}, []);
const clearWaterRiskLayers = useCallback(() => {
waterRiskAreaSourceRef.current?.clear();
waterRiskPipeSourceRef.current?.clear();
waterRiskManholeSourceRef.current?.clear();
}, []);
/** 통수위험 팝업 종료 시 지도 강조·범례·정보카드 선택상태를 모두 초기화한다. */
const clearWaterRiskSelection = useCallback(() => {
clearWaterRiskLayers();
setWaterRiskSession(EMPTY_WATER_RISK_SESSION_SNAPSHOT);
setSelectedWaterRiskApiResult(null);
setSelectedWaterRiskMapView(null);
setWaterRiskInfoAnchor(null);
setWaterRiskInfoCardStyle(null);
}, [clearWaterRiskLayers]);
const renderWaterRiskSelection = useCallback(
(areaId: string | null, mapView: WaterRiskMapView | null) => {
const map = gisMaps.current;
if (!map || !selectionNavigationRef.current?.isCurrent()) return;
const areaSource = ensureWaterRiskAreaLayer();
if (!areaSource) return;
areaSource.clear();
const pipeSource = ensureWaterRiskPipeLayer();
pipeSource?.clear();
const manholeSource = ensureWaterRiskManholeLayer();
manholeSource?.clear();
if (!areaId || !mapView || mapView.areaId !== areaId) return;
const geoJson = new GeoJSON();
const projection = mapView.projection || "EPSG:5179";
let focusGeometry: any | null = null;
if (mapView.areaGeometry) {
const geometry = geoJson.readGeometry(mapView.areaGeometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const areaFeature = new Feature({
geometry,
areaId: mapView.areaId,
areaName: mapView.areaName,
riskStatus: mapView.riskStatus,
});
areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature));
areaSource.addFeature(areaFeature);
focusGeometry = geometry;
}
mapView.pipes.forEach((pipe) => {
if (!pipeSource || !pipe.geometry) return;
const geometry = geoJson.readGeometry(pipe.geometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const pipeFeature = new Feature({
geometry,
pipeId: pipe.pipeId,
displayLabel: pipe.displayLabel || pipe.pipeId,
capacityRatio: pipe.capacityRatio ?? 0,
});
pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature));
pipeSource.addFeature(pipeFeature);
if (!focusGeometry) focusGeometry = geometry;
});
mapView.manholes.forEach((manhole) => {
if (!manholeSource || !manhole.geometry) return;
const geometry = geoJson.readGeometry(manhole.geometry as any, {
dataProjection: projection,
featureProjection: "EPSG:5179",
});
const manholeFeature = new Feature({
geometry,
manholeId: manhole.manholeId,
displayLabel: manhole.displayLabel || manhole.manholeId,
floodingFlow: manhole.floodingFlow ?? 0,
});
manholeFeature.setStyle(createWaterRiskManholeStyle(manholeFeature));
manholeSource.addFeature(manholeFeature);
if (!focusGeometry) focusGeometry = geometry;
});
if (!focusGeometry) return;
const extent = focusGeometry.getExtent();
const [, minY = 0, maxX = 0, maxY = 0] = extent;
setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]);
if (!selectionNavigationRef.current?.commit(extent)) return;
map.getView().fit(extent, {
duration: 300,
padding: [120, 120, 120, 120],
maxZoom: 15,
constrainResolution: true,
nearest: true,
});
},
[
ensureWaterRiskAreaLayer,
ensureWaterRiskManholeLayer,
ensureWaterRiskPipeLayer,
],
);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
if (!waterRiskSession.selectedAreaId) {
clearWaterRiskLayers();
setWaterRiskInfoAnchor(null);
setWaterRiskInfoCardStyle(null);
return;
}
renderWaterRiskSelection(
waterRiskSession.selectedAreaId,
selectedWaterRiskMapView,
);
}, [
clearWaterRiskLayers,
mapReady,
renderWaterRiskSelection,
selectedWaterRiskMapView,
waterRiskSession.selectedAreaId,
]);
useEffect(() => {
if (!selectedWaterRiskMapView) setWaterRiskInfoAnchor(null);
}, [selectedWaterRiskMapView]);
useEffect(() => {
const popup = waterRiskPopupWindowRef.current;
if (!popup || popup.closed) return;
syncWaterRiskPopup();
}, [syncWaterRiskPopup, waterRiskSession]);
useEffect(() => {
if (!mapReady || !gisMaps.current || !waterRiskInfoAnchor) return;
const map = gisMaps.current;
const syncDuringMapRender = () => syncWaterRiskInfoCardPosition();
syncWaterRiskInfoCardPosition();
map.on?.("postrender", syncDuringMapRender);
map.on?.("moveend", syncDuringMapRender);
return () => {
map.un?.("postrender", syncDuringMapRender);
map.un?.("moveend", syncDuringMapRender);
};
}, [mapReady, syncWaterRiskInfoCardPosition, waterRiskInfoAnchor]);
useEffect(() => {
const handleWaterRiskPopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY) {
syncWaterRiskPopup();
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) {
if (data.payload?.phase === "loading") {
selectionRequestRef.current = data.payload.requestId;
selectionNavigationRef.current = beginMapNavigation(gisMaps.current);
return;
}
if (data.payload?.requestId) {
if (data.payload.requestId !== selectionRequestRef.current ||
!selectionNavigationRef.current?.isCurrent()) return;
} else {
selectionNavigationRef.current = beginMapNavigation(gisMaps.current);
}
const areaId = String(data.payload?.areaId ?? "").trim();
if (!areaId) return;
const resultRow = data.payload?.resultRow as
| WaterRiskResultRow
| undefined;
const mapView = data.payload?.mapView as WaterRiskMapView | undefined;
if (!resultRow || !mapView || mapView.areaId !== areaId) return;
// 지도 응답이 누른 행과 다른 실행이면 표시하지 않는다(실행 ID 기준 조회의 안전장치).
if (
mapView.executionId &&
resultRow.executionId &&
mapView.executionId !== resultRow.executionId
) {
return;
}
setSelectedWaterRiskApiResult(resultRow);
setSelectedWaterRiskMapView(mapView);
setWaterRiskSession((current) => ({
...current,
selectedAreaId: areaId,
selectedExecutionId: resultRow.executionId || null,
}));
onSelectResult();
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLOSED) {
clearWaterRiskPopupCloseWatcher();
waterRiskPopupWindowRef.current = null;
clearWaterRiskSelection();
return;
}
};
window.addEventListener("message", handleWaterRiskPopupMessage);
return () => {
window.removeEventListener("message", handleWaterRiskPopupMessage);
};
}, [
clearWaterRiskPopupCloseWatcher,
clearWaterRiskSelection,
onSelectResult,
syncWaterRiskPopup,
]);
useEffect(() => {
return () => {
clearWaterRiskPopupCloseWatcher();
const popup = waterRiskPopupWindowRef.current;
if (popup && !popup.closed) {
popup.close();
}
waterRiskPopupWindowRef.current = null;
};
}, [clearWaterRiskPopupCloseWatcher]);
const watchPopup = useCallback(
(popup: Window) => {
clearWaterRiskPopupCloseWatcher();
waterRiskPopupWindowRef.current = popup;
waterRiskPopupCloseWatcherRef.current = window.setInterval(() => {
if (!popup.closed) return;
clearWaterRiskPopupCloseWatcher();
waterRiskPopupWindowRef.current = null;
clearWaterRiskSelection();
}, 300);
},
[clearWaterRiskPopupCloseWatcher, clearWaterRiskSelection],
);
useEffect(() => () => clearWaterRiskLayers(), [clearWaterRiskLayers]);
return {
selectedWaterRiskResultRow,
waterRiskInfoCardStyle,
selectedWaterRiskMapView,
selectedWaterRiskDisplayStatus,
dismissWaterRiskInfoCard,
watchPopup,
};
}
@@ -0,0 +1,96 @@
/** 통수위험 결과의 도형별 스타일. 기존 값과 처리 순서를 유지한다. */
import Fill from "ol/style/Fill.js";
import CircleStyle from "ol/style/Circle.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import Text from "ol/style/Text.js";
import {
buildWaterRiskStatusLabel,
isWaterRiskDanger,
type WaterRiskStatus,
} from "@/app/(map-layout)/map/components/water-risk/water-risk-types";
/** 위험·위험(매핑관로없음)은 빨강, 정상은 파랑 반투명으로 중점관리지역 면을 채운다(화면설계서 21쪽). */
export const buildWaterRiskStatusFillColor = (riskStatus: WaterRiskStatus) =>
isWaterRiskDanger(riskStatus)
? "rgba(198, 40, 40, 0.24)"
: "rgba(21, 101, 192, 0.2)";
/** 중점관리지역 경계선·라벨 배경색. 위험 계열 판정은 면 색과 같다. */
export const buildWaterRiskStatusStrokeColor = (riskStatus: WaterRiskStatus) =>
isWaterRiskDanger(riskStatus)
? "rgba(198, 40, 40, 0.92)"
: "rgba(21, 101, 192, 0.92)";
/** capacity를 첨부 범례의 다섯 고정 구간색으로 변환한다. */
export const buildWaterRiskCapacityColor = (capacityRatio: number) => {
if (capacityRatio <= 0.25) return "#0000ff";
if (capacityRatio <= 0.5) return "#00d9ff";
if (capacityRatio <= 0.75) return "#00d95f";
if (capacityRatio < 1) return "#ffe100";
return "#ff1c15";
};
export const createWaterRiskAreaStyle = (feature: any) => {
const areaName = String(feature.get("areaName") ?? "");
const riskStatus = String(
feature.get("riskStatus") ?? "NORMAL",
) as WaterRiskStatus;
return new Style({
fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }),
stroke: new Stroke({
color: buildWaterRiskStatusStrokeColor(riskStatus),
width: 3,
}),
text: new Text({
text: `${areaName}\n${buildWaterRiskStatusLabel(riskStatus)}`,
font: "700 13px sans-serif",
fill: new Fill({ color: "#ffffff" }),
backgroundFill: new Fill({
color: buildWaterRiskStatusStrokeColor(riskStatus),
}),
padding: [5, 8, 5, 8],
overflow: true,
}),
zIndex: 9996,
});
};
export const createWaterRiskPipeStyle = (feature: any) => {
const capacityRatio = Number(feature.get("capacityRatio") ?? 0);
const capacityColor = buildWaterRiskCapacityColor(capacityRatio);
return [
new Style({
stroke: new Stroke({
color: "rgba(255,255,255,0.98)",
width: 9,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9997,
}),
new Style({
stroke: new Stroke({
color: capacityColor,
width: 4,
lineCap: "round",
lineJoin: "round",
}),
zIndex: 9998,
}),
];
};
/** 맨홀 Flooding이 0이면 파랑, 양수이면 빨강으로 표시한다. */
export const createWaterRiskManholeStyle = (feature: any) => {
const floodingFlow = Number(feature.get("floodingFlow") ?? 0);
const isFlooding = floodingFlow > 0;
return new Style({
image: new CircleStyle({
radius: isFlooding ? 7 : 4,
fill: new Fill({ color: isFlooding ? "#ff1c15" : "#1677ff" }),
stroke: new Stroke({ color: "#ffffff", width: 1.5 }),
}),
zIndex: 9999,
});
};
@@ -0,0 +1,100 @@
/** 지도 조립에서 사용하는 기존 레이어·메시지·표시 상수. 기존 값과 처리 순서를 유지한다. */
export const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE";
export const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
// 노후관로 지도 강조선과 시설물 트리의 선형 범례가 동일하게 사용하는 빨간색이다.
export const AGED_PIPE_HIGHLIGHT_COLOR = "rgba(235, 40, 40, 0.82)";
export const FLDNG_HSTRY_WFS_LAYER_ID = "SN_FLDNG_HSTRY_WFS";
// 심선은 동일 레이어 유형에 6개 깊이 등급 설정이 있어 prefix 뒤에 lyrSn을 붙여 각각 생성한다.
export const FLDNG_DEPTH_WFS_LAYER_ID_PREFIX = "SN_FLDNG_DEPTH_WFS";
export const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
export const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
export const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
export const SINKHOLE_DEFAULT_POINT_LIMIT = 2000;
export const EXMN_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
// 지반침하 지도 포인트와 같은 파란 계열의 기존 레이어 트리 심볼 자산을 재사용한다.
export const SINKHOLE_TREE_ICON_NAME = "SN_SINKHOLE_SPOT_BLUE.png";
// EPSG:5179의 남한·도서 주변 여유 범위 [minX, minY, maxX, maxY].
// 화면 비율 때문에 전체 범위가 잘리지 않도록 Gis의 viewShowFullExtent와 함께 사용한다.
export const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
600000, 1300000, 1500000, 2500000,
];
export const EXMN_GRADE_COLORS: Record<number, string> = {
1: "#22c55e",
2: "#84cc16",
3: "#facc15",
4: "#f97316",
5: "#dc2626",
};
export const EXMN_RESULT_MANHOLE_ICON_SRC =
"/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png?v=3";
export const FLOOD_PREDICTION_GRID_LAYER_ID = "SN_FLDNG_PREDICTION_GRID_LABEL";
/** 선택한 도시침수지도 중앙에 침수예측 분석값을 표시하는 오버레이 스타일이다. */
// 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다.
export { FLOW_DIRECTION_PIPE_LAYER_TYPE, FLOW_DIRECTION_SRS } from "@/features/map-layers/model/flow-direction-runtime";
export const GIS_EDIT_PIPE_SNAP_BBOX_BUFFER_RATIO = 0.15;
export const GIS_EDIT_PREVIEW_LAYER_ID = "SN_GIS_EDIT_PREVIEW";
export const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES";
export const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA";
export const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE";
export const WATER_RISK_MANHOLE_LAYER_ID = "SN_WATER_RISK_MANHOLE";
export const SHORT_TERM_FORECAST_GRID_LAYER_NAME = "초단기예보격자망";
export const FLDNG_HSTRY_SELECTION_PADDING: [number, number, number, number] = [
100, 380, 120, 120,
];
export const RIGHT_PANEL_TAB_LIMIT = 5;
// Only pipes opened from sinkhole details share one slot within the parent-return flow.
export const RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID =
"feature:tb_ms_swage_ppl:sinkhole-singleton";
export const AGED_PIPE_FOCUS_PADDING: [number, number, number, number] = [
120, 120, 120, 120,
];
export const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [
50, 50, 50, 50,
];
export const SINKHOLE_POINT_PALETTE = {
orange: {
fill: "rgba(214, 102, 31, 0.88)",
fillSelected: "rgba(255, 138, 0, 0.92)",
stroke: "rgba(255, 255, 255, 0.95)",
strokeSelected: "rgba(120, 53, 15, 0.98)",
radiusStroke: "rgba(214, 102, 31, 0.75)",
radiusFill: "rgba(251, 146, 60, 0.12)",
},
blue: {
fill: "rgba(37, 99, 235, 0.82)",
fillSelected: "rgba(37, 99, 235, 0.9)",
stroke: "rgba(255, 255, 255, 0.95)",
strokeSelected: "rgba(30, 64, 175, 0.98)",
radiusStroke: "rgba(37, 99, 235, 0.7)",
radiusFill: "rgba(96, 165, 250, 0.12)",
},
} as const;
File diff suppressed because it is too large Load Diff
@@ -11,15 +11,15 @@
import React from "react";
import { SelectBox } from "@common/business_components/ui/select";
import { Label } from "@common/components";
import SwagePpl from "./swagePpl";
import SwagePrcsFclt from "./swagePrcsFclt";
import SwagePrcsZone from "./swagePrcsZone";
import FldngWtlvMap from "./fldngWtlvMap";
import SwageMhl from "./swageMhl";
import SwagePmstn from "./swagePmstn";
import SwagePpl from "./swage-ppl";
import SwagePrcsFclt from "./swage-prcs-fclt";
import SwagePrcsZone from "./swage-prcs-zone";
import FldngWtlvMap from "./fldng-wtlv-map";
import SwageMhl from "./swage-mhl";
import SwagePmstn from "./swage-pmstn";
import Stofcbr from "./stofcbr";
import useSearchModalStore from "@/store/useSearchModalStore";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import useSearchModalStore from "@/features/map-search/model/search-store";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
// 기본정보 '구분' 선택 목록
export const BASIC_TYPES = [
@@ -1,8 +1,8 @@
import React from "react";
import { Button, Input, Label } from "@common/components";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import useSearchModalStore from "@/features/map-search/model/search-store";
import useSearchModal from "./use-search-modal";
// 침수흔적도용 CQL 필터 생성
export const fldngWtlvMapCqlFilters = () => {
@@ -12,14 +12,14 @@
* - Enter 키 입력 시 자동 조회 지원
*/
import React from "react";
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { YearRangeSelectBox } from "@/components/ui/year-range-select-box";
import { SelectBox } from "@common/business_components/ui/select";
import { Button, Input, Label } from "@common/components";
import useStore from "@/store/useStore";
import useSearchModalStore from "@/store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
import useOperationTab from "./useOperationTab";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import useStore from "@/entities/reference-data/model/reference-data-store";
import useSearchModalStore from "@/features/map-search/model/search-store";
import useSearchModal from "./use-search-modal";
import useOperationTab from "./use-operation-tab";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
const OperationTab: React.FC = () => {
const { BIZ_TY_CD } = useStore();
@@ -15,16 +15,16 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, Label } from "@common/components";
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { YearRangeSelectBox } from "@/components/ui/year-range-select-box";
import { SelectBox } from "@common/business_components/ui/select";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import useReportTab from "./useReportTab";
import useStore from "@/store/useStore";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "@/store/useSearchModalStore";
import { useReportTabStore } from "@/store/useReportTabStore";
import type { FlattenedFolder, ReportDataFile } from "./useReportTab";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import useReportTab from "./use-report-tab";
import useStore from "@/entities/reference-data/model/reference-data-store";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import useSearchModalStore from "@/features/map-search/model/search-store";
import { useReportTabStore } from "@/features/map-search/model/report-store";
import type { FlattenedFolder, ReportDataFile } from "@/features/map-search/model/report-types";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
// 하수도정비 기본계획 보고서
export const reportTableHeadlist = [
@@ -405,7 +405,7 @@ const ReportTab: React.FC<ReportTabProps> = ({
.slice(pageStart, pageEnd)
.map((item, index) => ({
...item,
no: totalCount - (pageOffset + index),
no: pageOffset + index + 1,
}));
reportRows = await enrichReportRows(pagedReportData);
@@ -12,32 +12,30 @@
* - 로딩 오버레이 및 선택 초기화 지원
*/
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import LoadingOverlay from "@/components/ui/loading-bar";
import { UITab } from "@common/business_components/ui/tab";
import { StandardPopupLayout } from "@common/business_components";
import { Button } from "@common/components/ui";
import React, { useEffect, useMemo, useRef } from "react";
import useSearchModalStore from "@/store/useSearchModalStore";
import useSearchModalStore from "@/features/map-search/model/search-store";
import { useAlertStore } from "@/shared/lib";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import {
ExclnSewagePumpCol2,
FldngWtlvMapCol2,
MsSwageMhlCol2,
MsSwagePplCol2,
stofcbrCol2,
SwtrmtFcltCol2,
SwtrmtPrcsZoneCol2,
} from "../components/common/table";
import BasicTab, { BASIC_TYPES } from "./basicTab";
import OperationTab from "./operationTab";
import ReportTab from "./reportTab";
import useSearchModal from "./useSearchModal";
import useBasicTab from "./useBasicTab";
import useOperationTab from "./useOperationTab";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import { ExclnSewagePumpCol2 } from "@/entities/facility/model/pump-columns";
import { FldngWtlvMapCol2, FldngDpthMapCol2 } from "@/entities/facility/model/flood-columns";
import { MsSwageMhlCol2 } from "@/entities/facility/model/manhole-columns";
import { MsSwagePplCol2 } from "@/entities/facility/model/pipe-columns";
import { stofcbrCol2 } from "@/entities/facility/model/overflow-columns";
import { SwtrmtFcltCol2 } from "@/entities/facility/model/treatment-facility-columns";
import { SwtrmtPrcsZoneCol2 } from "@/entities/facility/model/treatment-zone-columns";
import BasicTab, { BASIC_TYPES } from "./basic-tab";
import OperationTab from "./operation-tab";
import ReportTab from "./report-tab";
import useSearchModal from "./use-search-modal";
import useBasicTab from "./use-basic-tab";
import useOperationTab from "./use-operation-tab";
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
import type { UiHeadProps } from "@common/business_components/ui/table";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
type SearchModalProps = {
renderMode?: "launcher" | "popup";
@@ -103,6 +101,8 @@ const SearchModal: React.FC<SearchModalProps> = ({
? SwtrmtPrcsZoneCol2
: basicType === "tb_ms_fldng_wtlv_map"
? FldngWtlvMapCol2
: basicType === "tb_ms_fldng_dpth_map"
? FldngDpthMapCol2
: basicType === "tb_ms_swage_mhl"
? MsSwageMhlCol2
: basicType === "tb_ms_swage_pmstn"
@@ -10,12 +10,12 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { YearRangeSelectBox } from "@/components/ui/year-range-select-box";
import { Button } from "@common/components";
import React from "react";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import useSearchModalStore from "@/features/map-search/model/search-store";
import useSearchModal from "./use-search-modal";
export const stofcbrCqlFilters = () => {
const {
@@ -12,13 +12,13 @@
* - Enter 키 입력 시 자동 조회 지원
*/
import React from "react";
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { YearRangeSelectBox } from "@/components/ui/year-range-select-box";
import { Button, Input, Label } from "@common/components";
import { MS_SWAGE_MHL_USE_CODES } from "../components/common/table";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
import { SelectBox } from "@common/business_components/ui/select";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import useSearchModalStore from "@/features/map-search/model/search-store";
import useSearchModal from "./use-search-modal";
export const swageMhlCqlFilters = () => {
const {
@@ -60,6 +60,7 @@ export const swageMhlCqlFilters = () => {
};
const SwageMhl: React.FC = () => {
const { MS_SWAGE_MHL_USE_CODES } = useFacilityCodeCatalog();
const {
var1,
setVar1,
@@ -11,12 +11,12 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
import { YearRangeSelectBox } from "@/components/ui/year-range-select-box";
import { Button, Input, Label } from "@common/components";
import React from "react";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import useSearchModalStore from "@/features/map-search/model/search-store";
import useSearchModal from "./use-search-modal";
export const swagePmstnCqlFilters = () => {
const {

Some files were not shown because too many files have changed in this diff Show More