Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev

This commit is contained in:
2026-10-02 21:26:43 +09:00
349 changed files with 15555 additions and 11561 deletions
+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 (
@@ -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;
@@ -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>;
@@ -1,24 +1,35 @@
/** 노후관로의 지도와 목록이 사용하는 DB 레이어 설정 계약이다. */
import { getRawJson } from "@/components/lib/api";
import { buildAgedPipeCqlFilters, isAgedPipeLayerNode, type AgedPipeFilters } from "./agedPipeUtils";
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[] =>
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 ");
next.flterSql = buildAgedPipeCqlFilters(filters, row.flterSql).join(
" AND ",
);
} catch (error) {
// 노후관로 DB 미전환이 다른 시설물 레이어까지 중단시키지 않도록 이 행만 차단한다.
next.agedPipeConfigError = error instanceof Error ? error.message : "노후관로 레이어 설정 오류";
next.agedPipeConfigError =
error instanceof Error ? error.message : "노후관로 레이어 설정 오류";
next.actlLyrYn = "N";
next.flterSql = "EXCLUDE";
}
}
if (Array.isArray(row.children)) next.children = prepareAgedPipeLayerRows(row.children, filters);
if (Array.isArray(row.children))
next.children = prepareAgedPipeLayerRows(row.children, filters);
return next;
});
@@ -26,7 +37,9 @@ export const prepareAgedPipeLayerRows = (rows: any[], filters: AgedPipeFilters):
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;
const child = Array.isArray(row.children)
? findAgedPipeLayer(row.children)
: undefined;
if (child) return child;
}
};
@@ -34,14 +47,24 @@ export const findAgedPipeLayer = (rows: any[]): any | undefined => {
/** 예전 제어용 행을 실제 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 템플릿으로 갱신해 주세요.");
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 rows = await getRawJson(
"/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK",
);
const row = findAgedPipeLayer(Array.isArray(rows) ? rows : []);
validateAgedPipeLayer(row);
return row;
@@ -3,15 +3,16 @@
* 노후관로를 검색하고 목록·엑셀 다운로드·지도 위치 확인 기능을 제공한다.
*/
"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_LIST_HEADLIST,
@@ -24,17 +25,18 @@ import {
type AgedPipeFilters,
type AgedPipeRow,
type AgedPipeManagementAgency,
} from "./agedPipeUtils";
import { downloadAgedPipeExcel } from "./agedPipeExport";
import { fetchAgedPipeLayer } from "./agedPipeLayer";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
} 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();
@@ -56,12 +58,15 @@ export default function AgedPipePopupPage() {
useState<AgedPipeFilters>(initialFilters);
const [rows, setRows] = useState<AgedPipeRow[]>([]);
// 관리기관명 조회가 늦게 완료되어도 기존 목록을 재조회하지 않고 표시만 갱신한다.
const [managementAgencies, setManagementAgencies] = useState<AgedPipeManagementAgency[]>([]);
const [managementAgencies, setManagementAgencies] = useState<
AgedPipeManagementAgency[]
>([]);
const displayRows = useMemo(
() => rows.map((row) => ({
...row,
mngInstNm: getAgedPipeManagementName(row, managementAgencies),
})),
() =>
rows.map((row) => ({
...row,
mngInstNm: getAgedPipeManagementName(row, managementAgencies),
})),
[rows, managementAgencies],
);
const [total, setTotal] = useState(0);
@@ -89,12 +94,16 @@ export default function AgedPipePopupPage() {
// 기존 GIS 관리 화면과 같은 기관 목록을 사용하고, 실패 시 관로 조회는 유지한다.
getData("v1/fcltPpl/mngInsts")
.then((response) => {
if (active) setManagementAgencies(Array.isArray(response) ? response : []);
if (active)
setManagementAgencies(Array.isArray(response) ? response : []);
})
.catch(() => {
if (active) alert("관리기관명을 불러오지 못해 관리기관 코드로 표시합니다.");
if (active)
alert("관리기관명을 불러오지 못해 관리기관 코드로 표시합니다.");
});
return () => { active = false; };
return () => {
active = false;
};
}, [alert]);
const fetchRows = useCallback(
@@ -118,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(
@@ -141,7 +150,11 @@ export default function AgedPipePopupPage() {
setAppliedFilters(normalizeAgedPipeFilters(nextFilters));
} catch (error) {
console.error("노후관로 목록 조회 중 오류:", error);
alert(error instanceof Error ? error.message : "노후관로 목록 조회 중 오류가 발생했습니다.");
alert(
error instanceof Error
? error.message
: "노후관로 목록 조회 중 오류가 발생했습니다.",
);
setRows([]);
setTotal(0);
} finally {
@@ -17,13 +17,13 @@ import {
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;
@@ -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,933 +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: '형상길이' },
];
/** 기본정보 목록 API의 camelCase 속성을 침수흔적심선 그리드에 연결한다. */
export const FldngDpthMapCol2 = [
{ id: 'rowNumber', title: '순번' },
{ id: 'fldngYr', title: '침수년도' },
{ id: 'ctpvNm', title: '시도명' },
{ id: 'sggNm', title: '시군구명' },
{ id: 'fldngDpth', title: '침수깊이(m)' },
{ id: 'fldngArea', 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;
@@ -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,264 @@
"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],
);
/** 지역 검색값 없이 전체를 요청하고, 선택 진입 context는 위치 이동 범위 조회에만 사용한다. */
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,
{},
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, 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;
@@ -7,31 +7,16 @@
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 { getRawJson } from "@/shared/api/sewerage-api";
import { requestGridExcel } from "@/shared/api/grid-export";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import { useAlertStore } from "@/shared/lib";
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_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 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;
@@ -7,12 +7,12 @@
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 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 +25,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;
@@ -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,15 +22,15 @@ 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]);
@@ -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 } =
@@ -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>;
@@ -15,21 +15,21 @@ 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;
@@ -77,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(
@@ -84,6 +86,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => {
JSON.stringify({
row,
timestamp: Date.now(),
requestId,
}),
);
} catch (error) {
@@ -96,6 +99,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => {
{
type: INFTWT_RESULT_ROW_SELECT_MESSAGE,
row,
requestId,
},
window.location.origin,
);
@@ -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);
}
@@ -9,15 +9,15 @@ import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { requestGridExcel } from "@/shared/api/grid-export";
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 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_HEADLIST,
@@ -34,7 +34,7 @@ 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;
@@ -47,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();
@@ -143,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(
@@ -227,17 +238,29 @@ export default function PipeAnalysisDetailPopupPage() {
setIsLoading(true);
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,
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`);
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(
@@ -264,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[] = [];
@@ -300,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,
@@ -310,6 +333,7 @@ export default function PipeAnalysisDetailPopupPage() {
filters.ctpvCd,
filters.sggCd,
filters.swtrmtZoneNm,
MS_SWAGE_PPL_CODES,
rows,
sgg,
]);
@@ -385,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,16 @@ 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 PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import {
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
PIPE_ANALYSIS_TYPE_OPTIONS,
@@ -35,7 +35,7 @@ import {
type PipeAnalysisSummaryResponse,
type PipeAnalysisSummaryViewRow,
type PipeAnalysisYearMaterialGroup,
} from "./pipeAnalysisUtils";
} from "./pipe-analysis-utils";
const SUMMARY_FILE_PREFIX: Record<string, string> = {
YEAR: "관로분석_경과년도별",
@@ -15,7 +15,7 @@ import type {
PipeAnalysisSummaryViewRow,
PipeAnalysisYearMaterialGroup,
PipeAnalysisYearMaterialGroupRow,
} from "./pipeAnalysisUtils";
} from "./pipe-analysis-utils";
type SummaryRow =
| {
@@ -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;
@@ -487,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 = (
@@ -539,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;
};
/**
@@ -628,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,23 +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 { 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;
@@ -79,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(
@@ -86,6 +88,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => {
JSON.stringify({
row,
timestamp: Date.now(),
requestId,
}),
);
} catch (error) {
@@ -98,6 +101,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => {
{
type: RDII_RESULT_ROW_SELECT_MESSAGE,
row,
requestId,
},
window.location.origin,
);
@@ -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);
}
@@ -11,23 +11,23 @@ import {
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,
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 =
@@ -163,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],
@@ -201,13 +202,24 @@ export default function RepairHistoryPopupPage({
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("보수이력 지도 도형을 불러오지 못했습니다. 조회를 다시 실행해주세요.");
});
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 fetchRepairHistoryPage(
@@ -228,7 +240,10 @@ export default function RepairHistoryPopupPage({
setTotal(0);
if (refreshMap) {
mapRequestId.current += 1;
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, { zoneId, rows: [] });
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{ zoneId, rows: [] },
);
}
setErrorMessage(
error instanceof Error
@@ -468,7 +483,9 @@ export default function RepairHistoryPopupPage({
total={total}
page={currentPage}
pageSize={REPAIR_HISTORY_PAGE_SIZE}
onPageChange={(page) => void searchRepairHistories(appliedFilters, page, false)}
onPageChange={(page) =>
void searchRepairHistories(appliedFilters, page, false)
}
onRowClick={(_, index) => {
const row = rows[index];
if (row) handleRowClick(row);
@@ -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,13 @@
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 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 +27,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;
@@ -6,10 +6,10 @@
import { useEffect, useMemo, 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";
type Props = {
detail: SinkholeDetail;
@@ -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,17 @@ 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 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 +35,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;
@@ -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,7 +8,7 @@ import type {
WaterRiskResultPage,
WaterRiskResultRow,
WaterRiskStatus,
} from "./waterRiskTypes";
} from "./water-risk-types";
export type WaterRiskResultQuery = {
startDate: string;
@@ -0,0 +1,54 @@
/** 백엔드의 양 끝 날짜 포함 최대 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);
};
/** 달력 제한을 우회한 직접 입력도 API 요청 전에 검증한다. */
export const validateWaterRiskDateRange = (
startDate: string,
endDate: string,
today: string,
): string => {
if (!startDate || !endDate) return "조회 시작일과 종료일을 입력해야 합니다.";
const start = parseDate(startDate);
const end = parseDate(endDate);
if (start === null || end === null)
return "올바른 조회 날짜를 입력해 주세요.";
if (endDate > today) return "조회 종료일은 오늘보다 늦을 수 없습니다.";
if (start > end) return "조회 시작일은 종료일보다 늦을 수 없습니다.";
if ((end - start) / DAY_MS + 1 > WATER_RISK_MAX_SEARCH_DAYS) {
return "강우 발생기간은 종료일 기준 최대 1년(366일)까지 조회할 수 있습니다.";
}
return "";
};
/** 종료일 변경으로 기존 시작일이 허용 범위를 벗어난 경우 최근 1년 시작일로 맞춘다. */
export const adjustWaterRiskStartDate = (
startDate: string,
endDate: string,
): string => {
const min = getWaterRiskMinStartDate(endDate);
if (!min) return startDate;
return !startDate || startDate < min || startDate > endDate ? min : startDate;
};
@@ -4,15 +4,15 @@
*/
"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 PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import {
StandardPopupLayout,
type UiHeadProps,
@@ -20,12 +20,17 @@ import {
} from "@common/business_components";
import type { UiTableSort } from "@common/business_components/ui/table";
import { Button } from "@common/components";
import {
adjustWaterRiskStartDate,
getWaterRiskMinStartDate,
validateWaterRiskDateRange,
} from "./water-risk-date-range";
import {
fetchWaterRiskAreas,
fetchWaterRiskMapView,
fetchWaterRiskResults,
} from "./waterRiskClient";
} from "./water-risk-client";
import {
buildWaterRiskStatusLabel,
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
@@ -33,9 +38,9 @@ import {
type WaterRiskResultPage,
type WaterRiskResultRow,
type WaterRiskSessionSnapshot,
} from "./waterRiskTypes";
} from "./water-risk-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import LoadingOverlay from "@/components/ui/loading-bar";
type WaterRiskResultMode = "current" | "history";
@@ -153,11 +158,10 @@ const formatAnalysisBaseAt = (value?: string | null) => {
/** 현재 위험 조회와 검색 초기화에 사용하는 최근 1년의 기본 조회조건이다. */
const buildDefaultResultFilters = (): ResultFilters => {
const endDate = new Date();
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - 365);
const endDateValue = formatLocalDate(endDate);
return {
startDate: formatLocalDate(startDate),
endDate: formatLocalDate(endDate),
startDate: getWaterRiskMinStartDate(endDateValue)!,
endDate: endDateValue,
ctpvCd: "ALL",
sggCd: "ALL",
areaId: "ALL",
@@ -191,12 +195,10 @@ const buildResultRowKey = (row: WaterRiskResultRow) =>
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
// 팝업 최초 진입에만 지정 기간을 사용하고 초기화는 기본 조회조건을 새로 만든다.
const initialFilters = useMemo(() => ({
...buildDefaultResultFilters(),
startDate: "2026-08-15",
endDate: "2026-08-17",
}), []);
// 최초 진입과 초기화 모두 오늘을 종료일로 하는 최근 1년을 사용한다.
const initialFilters = useMemo(() => buildDefaultResultFilters(), []);
// 과거분석은 오늘까지 선택 가능하며, 시작일의 범위는 선택 종료일에 따라 달라진다.
const today = formatLocalDate(new Date());
const [resultMode, setResultMode] = useState<WaterRiskResultMode>("current");
const [draftResultFilters, setDraftResultFilters] =
useState<ResultFilters>(initialFilters);
@@ -429,15 +431,17 @@ 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 dateError = validateWaterRiskDateRange(
draftResultFilters.startDate, draftResultFilters.endDate, today,
);
if (dateError) {
setErrorMessage(dateError);
return;
}
}
setErrorMessage("");
setResultCurrentPage(1);
setAppliedResultFilters({ ...draftResultFilters });
setReloadVersion((version) => version + 1);
@@ -458,26 +462,37 @@ 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);
}
};
@@ -524,6 +539,8 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
<input
type="date"
value={draftResultFilters.startDate}
min={getWaterRiskMinStartDate(draftResultFilters.endDate)}
max={draftResultFilters.endDate || today}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
@@ -537,9 +554,11 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
<input
type="date"
value={draftResultFilters.endDate}
max={today}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
startDate: adjustWaterRiskStartDate(current.startDate, event.target.value),
endDate: event.target.value,
}))
}
@@ -674,6 +693,8 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
setAppliedResultFilters(defaultFilters);
setResultCurrentPage(1);
setSelectedResultKey("");
setErrorMessage("");
setReloadVersion((version) => version + 1);
}}
>
초기화
@@ -681,6 +702,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}
@@ -715,11 +742,6 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</span>
}
/>
{errorMessage ? (
<div aria-live="polite" className="px-1 text-xl text-destructive">
{errorMessage}
</div>
) : null}
</section>
</StandardPopupLayout>
);
@@ -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,475 @@
"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],
);
// 지도카드는 실제 GIS geometry까지 결합된 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 = 180;
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 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;
setSelectedWaterRiskApiResult(resultRow);
setSelectedWaterRiskMapView(mapView);
setWaterRiskSession((current) => ({
...current,
selectedAreaId: areaId,
}));
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,
watchPopup,
};
}
@@ -0,0 +1,94 @@
/** 통수위험 결과의 도형별 스타일. 기존 값과 처리 순서를 유지한다. */
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 } from "@/app/(map-layout)/map/components/water-risk/water-risk-types";
export const buildWaterRiskStatusFillColor = (
riskStatus: "DANGER" | "NORMAL",
) =>
riskStatus === "DANGER"
? "rgba(198, 40, 40, 0.24)"
: "rgba(21, 101, 192, 0.2)";
export const buildWaterRiskStatusStrokeColor = (
riskStatus: "DANGER" | "NORMAL",
) =>
riskStatus === "DANGER"
? "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
| "DANGER"
| "NORMAL";
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,98 @@
/** 지도 조립에서 사용하는 기존 레이어·메시지·표시 상수. 기존 값과 처리 순서를 유지한다. */
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";
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 = [
@@ -12,33 +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,
FldngDpthMapCol2,
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";
@@ -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 {
@@ -11,14 +11,14 @@
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
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 React from "react";
import { MS_SWAGE_PPL_USE_CODES } from "../components/common/table";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
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 swagePplCqlFilters = () => {
@@ -67,6 +67,7 @@ export const swagePplCqlFilters = () => {
};
const SwagePpl: React.FC = () => {
const { MS_SWAGE_PPL_USE_CODES } = useFacilityCodeCatalog();
const {
var1,
setVar1,
@@ -12,11 +12,11 @@
* - 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 { 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 swagePrcsFcltCqlFilters = () => {
@@ -11,10 +11,10 @@
* - Enter 키 입력 시 자동 조회 지원
*/
import React from "react";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import { Button, Input, Label } from "@common/components";
import useSearchModalStore from "../../../store/useSearchModalStore";
import useSearchModal from "./useSearchModal";
import useSearchModalStore from "@/features/map-search/model/search-store";
import useSearchModal from "./use-search-modal";
// 하수처리구역용 CQL 필터 생성
export const swagePrcsZoneCqlFilters = () => {
@@ -1,3 +1,4 @@
import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
/**
* 📄 useBasicTab Hook
* 검색 모달 기본정보 탭 훅
@@ -13,30 +14,24 @@
* - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service)
*/
import { requestGridExcel } from "@/shared/api/grid-export";
import {
MS_SWAGE_PPL_USE_CODES,
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_SHAPE,
MS_SWAGE_MHL_CODES,
MS_SWAGE_MHL_USE_CODES,
} from "../components/common/table";
import { getData } from "@/components/lib/api";
import { BASIC_TYPES } from "./basicTab";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
import { getData } from "@/shared/api/sewerage-api";
import { BASIC_TYPES } from "./basic-tab";
import { formatDisplayData } from "@/components/lib/utils/format-display-value";
import useStore from "@/entities/reference-data/model/reference-data-store";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import useSearchModalStore from "@/store/useSearchModalStore";
import { swagePplCqlFilters } from "./swagePpl";
import { swagePrcsFcltCqlFilters } from "./swagePrcsFclt";
import { swagePrcsZoneCqlFilters } from "./swagePrcsZone";
import { fldngWtlvMapCqlFilters } from "./fldngWtlvMap";
import { swageMhlCqlFilters } from "./swageMhl";
import { swagePmstnCqlFilters } from "./swagePmstn";
import useSearchModalStore from "@/features/map-search/model/search-store";
import { swagePplCqlFilters } from "./swage-ppl";
import { swagePrcsFcltCqlFilters } from "./swage-prcs-fclt";
import { swagePrcsZoneCqlFilters } from "./swage-prcs-zone";
import { fldngWtlvMapCqlFilters } from "./fldng-wtlv-map";
import { swageMhlCqlFilters } from "./swage-mhl";
import { swagePmstnCqlFilters } from "./swage-pmstn";
import { stofcbrCqlFilters } from "./stofcbr";
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { postPopupMessageAndClose } from "@/features/window-popup/lib/popup-message";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
const isPopupWindow = () =>
typeof window !== "undefined" &&
@@ -44,6 +39,7 @@ const isPopupWindow = () =>
window.location.pathname.startsWith("/popup/");
export default function useBasicTab() {
const { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES } = useFacilityCodeCatalog();
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
const {
@@ -194,7 +190,7 @@ export default function useBasicTab() {
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(layerNm, fid);
await core.showSearchResultsFromGeoJSON(layerNm, fid, mapSearchNavigationOptions((GisApp.Module.core as any).map));
setIsLoading(false);
setIsSearchModalOpen(false); // SearchModal 닫기
}
@@ -1,3 +1,4 @@
import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
/**
* 📄 useOperationTab Hook
* 검색 모달 유지관리 정보 탭 훅
@@ -16,19 +17,16 @@
*/
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
} from "../components/common/table";
import { getData } from "@/components/lib/api";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
import { getData } from "@/shared/api/sewerage-api";
import { formatDisplayData } from "@/components/lib/utils/format-display-value";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useSearchModalStore from "@/features/map-search/model/search-store";
import useStore from "@/entities/reference-data/model/reference-data-store";
import { postPopupMessageAndClose } from "@/features/window-popup/lib/popup-message";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import { useRef } from "react";
const isPopupWindow = () =>
@@ -37,6 +35,8 @@ const isPopupWindow = () =>
window.location.pathname.startsWith("/popup/");
export default function useOperationTab() {
const { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } =
useFacilityCodeCatalog();
const OPERATION_TABLE_SIZE = 5;
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
@@ -173,8 +173,7 @@ export default function useOperationTab() {
);
return {
...formattedItem,
rowNumber:
(curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
};
},
@@ -286,7 +285,7 @@ export default function useOperationTab() {
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(layerNm, fid);
await core.showSearchResultsFromGeoJSON(layerNm, fid, mapSearchNavigationOptions((GisApp.Module.core as any).map));
setIsLoading(false);
setIsSearchModalOpen(false); // SearchModal 닫기
}
@@ -13,33 +13,14 @@
* - 보고서 파일 조회: GET /api/v1/file (dc-service)
* - 파일 다운로드: GET /api/v1/fileDownload (dc-service)
*/
import { getDcData } from '@/components/lib/api';
import { getDcData } from "@/shared/api/external-service-api";
import { fileDownload } from '@/utils';
import { useReportTabStore } from '@/store/useReportTabStore';
import useSearchModalStore from '@/store/useSearchModalStore';
import { useReportTabStore } from "@/features/map-search/model/report-store";
import useSearchModalStore from "@/features/map-search/model/search-store";
import { useAlertStore } from '@/shared/lib';
export type Folder = {
clsfSystmSn: string;
upClsfSystmSn: string;
clsfSystmNm: string;
children: Folder[];
};
export type FlattenedFolder = {
[key: string]: string | number | undefined;
};
export type SelectDataType = {
text: string;
val: string;
sido?: string; // 시도 코드 (선택 사항)
}
export type ReportDataFile = {
no: number;
fileTitle: string;
regDt: string;
fileSn: string;
filePath: string;
}
import type { Folder, FlattenedFolder, SelectDataType, ReportDataFile } from "@/features/map-search/model/report-types";
const getStringValue = (value: string | number | undefined) => String(value ?? '');
@@ -1,3 +1,4 @@
import { mapSearchNavigationOptions } from "@/features/map-search/lib/map-navigation";
/**
* 📄 useSearchModal Hook
* 검색 모달 훅
@@ -12,18 +13,15 @@
* 🔗 사용 API
* - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service)
*/
import { getData } from "@/components/lib/api";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "../../../store/useStore";
import { getData } from "@/shared/api/sewerage-api";
import { formatDisplayData } from "@/components/lib/utils/format-display-value";
import useStore from "@/entities/reference-data/model/reference-data-store";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import useSearchModalStore, {
SEARCH_MODAL_DEFAULT_BASIC_TYPE,
SEARCH_MODAL_DEFAULT_WORK_TYPE,
} from "../../../store/useSearchModalStore";
import useBasicTab from "./useBasicTab";
import useOperationTab from "./useOperationTab";
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useSearchModalStore, { SEARCH_MODAL_DEFAULT_BASIC_TYPE, SEARCH_MODAL_DEFAULT_WORK_TYPE } from "@/features/map-search/model/search-store";
import useBasicTab from "./use-basic-tab";
import useOperationTab from "./use-operation-tab";
import { postPopupMessageAndClose } from "@/features/window-popup/lib/popup-message";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
const isPopupWindow = () =>
typeof window !== "undefined" &&
@@ -201,7 +199,7 @@ export default function useSearchModal() {
const layerNm = row.lyrTypeNm;
const fids = tableData.map((item) => item.fid);
const core = GisApp.Module.layer as any;
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids, mapSearchNavigationOptions((GisApp.Module.core as any).map));
setIsLoading(false);
setIsSearchModalOpen(false);
}
@@ -225,7 +223,7 @@ export default function useSearchModal() {
const layerNm = row.lyrTypeNm;
const fids = detailTableData.map((item) => item.fid);
const core = GisApp.Module.layer as any;
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids, mapSearchNavigationOptions((GisApp.Module.core as any).map));
setIsLoading(false);
setIsSearchModalOpen(false);
}
@@ -0,0 +1,61 @@
"use client";
/** 지도 route에만 적용하는 기존 전역 컨트롤 CSS. 선택자와 스타일 값은 유지한다. */
export default function MapControlStyles() {
return (
<style jsx global>{`
.sn-map-control-layout .mapbtn {
top: 16px;
right: 120px;
bottom: auto;
}
.sn-map-control-layout .map-type-tabs,
.sn-map-control-layout .tool-bar ul,
.sn-map-control-layout .zoom-level-panel,
.sn-map-control-layout .zoom-level-summary {
border: 0;
box-shadow:
0 1px 2px rgba(60, 64, 67, 0.3),
0 1px 3px 1px rgba(60, 64, 67, 0.15);
}
.sn-map-control-layout .custom-toolbar {
position: static;
}
.sn-map-control-layout .tool-bar {
top: 16px;
right: 16px;
bottom: auto;
}
.sn-map-control-layout .tool-bar-vector-icons ul li div {
display: flex;
align-items: center;
justify-content: center;
background-image: none !important;
color: #666;
line-height: 1;
}
.sn-map-control-layout .tool-bar-vector-icons ul li div:hover {
color: #fff;
}
.sn-map-control-layout .zoom-level-control {
right: 16px;
bottom: 16px;
}
.sn-map-control-layout .zoom-level-summary {
right: 56px;
bottom: 0;
}
.sn-map-control-layout .ol-scale-line {
display: none;
}
`}</style>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
"use client";
import { useCallback, useEffect, useState, useSyncExternalStore, type ComponentProps } from "react";
import { getMapSearchRevision, invalidateMapNavigation, subscribeMapSearch } from "@/features/map-search/lib/map-navigation";
import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
/** 지도 검색바의 Radix Select는 브라우저 마운트 이후 생성한다.
* 서버와 최초 클라이언트 렌더는 동일한 자리표시자를 사용하여 aria-controls ID의
* hydration 불일치를 피한다. 공통 Select 및 다른 앱의 렌더링 방식은 변경하지 않는다.
*/
export default function MapSearchBar(props: ComponentProps<typeof ProcSearchBar>) {
/** 부모의 지도 준비 렌더에 맞춰 해당 인스턴스만 구독한다. */
const map = props.gisMaps.current;
const subscribe = useCallback((listener: () => void) => subscribeMapSearch(map, listener), [map]);
const getSnapshot = useCallback(() => getMapSearchRevision(map), [map]);
const resetRevision = useSyncExternalStore(subscribe, getSnapshot, () => 0);
useEffect(() => () => invalidateMapNavigation(map), [map]);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return (
<div
className="h-[44px] w-[656px] rounded-[8px] bg-form"
role="status"
aria-label="지도 검색조건 준비 중"
aria-busy="true"
/>
);
}
return <ProcSearchBar {...props} resetRevision={resetRevision} onSearchInteraction={() => invalidateMapNavigation(map)} />;
}
@@ -1,10 +1,10 @@
import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { isWindowPopupKey } from "@/features/window-popup/model/popup-definitions";
import {
resolveWindowPopupTheme,
WINDOW_POPUP_THEME_PARAM,
} from "@/features/windowPopup/model/popupTheme";
import PopupThemeSync from "@/features/windowPopup/ui/PopupThemeSync";
import WindowPopupRenderer from "@/features/windowPopup/ui/WindowPopupRenderer";
} from "@/features/window-popup/model/popup-theme";
import PopupThemeSync from "@/features/window-popup/ui/popup-theme-sync";
import WindowPopupRenderer from "@/features/window-popup/ui/window-popup-renderer";
type PopupPageProps = {
params: Promise<{
@@ -1,6 +1,6 @@
"use client";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
type AnalysisResultPopupKey = "inftwtResultPopup" | "rdiiResultPopup";
@@ -16,7 +16,7 @@ import { DateMonthPicker, Form } from "@common/business_components/ui";
import { Button, Label } from "@common/components/ui";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import type { ReactNode } from "react";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
type TitleName = {
mainTitle: string;
@@ -16,8 +16,8 @@
* - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service)
*/
"use client";
import { postMutiPart } from "@/components/lib/api";
import FileUploadForm from "@/components/ui/fileUploadForm";
import { postMutiPart } from "@/shared/api/file-api";
import FileUploadForm from "@/components/ui/file-upload-form";
import {
StandardFormField,
StandardFormSection,
@@ -34,15 +34,15 @@ import {
excelToTimePatternData,
excelToRcptrInfoData,
} from "@/components/lib/utils";
import BaseLayout from "../BaseLayout";
import { useAnalysisCompletionWatcher } from "../useAnalysisCompletionWatcher";
import { openAnalysisResultMapView } from "../analysisResultView";
import BaseLayout from "../base-layout";
import { useAnalysisCompletionWatcher } from "../use-analysis-completion-watcher";
import { openAnalysisResultMapView } from "../analysis-result-view";
import {
validateMultipleFiles,
FILE_EXTENSIONS,
type FileValidationConfig,
} from "../../fileValidation";
import { validateBasicSelections } from "../../selectionValidation";
} from "../../file-validation";
import { validateBasicSelections } from "../../selection-validation";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { useQuery } from "@tanstack/react-query";

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