From 516d93d6fadc6a1f3bb4f325a6ecf2c183317a9c Mon Sep 17 00:00:00 2001 From: kiminchan Date: Thu, 2 Jul 2026 11:38:40 +0900 Subject: [PATCH] =?UTF-8?q?RDII=20=EB=B6=84=EC=84=9D=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EC=A7=80=EB=8F=84=EC=97=B0=EA=B3=84=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=B0=EA=B3=BC=EC=A0=84=EC=9A=A9=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analysisMenu/AnalysisPopupMenu.tsx | 57 ++- .../popupComponent/chart/aplcnYms.ts | 345 ++++++++------- .../popupComponent/chart/rdiiChart.tsx | 401 +++++++++--------- .../rdiiResult/RdiiResultPopupPage.tsx | 327 ++++++++++++++ .../components/rdiiResult/rdiiResultTypes.ts | 46 ++ .../components/rdiiResult/useRdiiResultMap.ts | 349 +++++++++++++++ apps/sn/app/(map-layout)/map/page.tsx | 13 + .../001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md | 46 ++ apps/sn/src/app/styles/components.css | 4 + .../windowPopup/model/popupDefinitions.ts | 14 +- apps/sn/src/features/windowPopup/registry.tsx | 5 + .../windowPopup/ui/RdiiChartPopupContent.tsx | 16 +- .../windowPopup/ui/RdiiResultPopupContent.tsx | 19 + 13 files changed, 1263 insertions(+), 379 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx create mode 100644 apps/sn/app/(map-layout)/map/components/rdiiResult/rdiiResultTypes.ts create mode 100644 apps/sn/app/(map-layout)/map/components/rdiiResult/useRdiiResultMap.ts create mode 100644 apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md create mode 100644 apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index 6de94b71b..37061b293 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -1,11 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { ChartColumnBig, ChevronLeft, ChevronRight, - Droplets, PanelsTopLeft, PencilRuler, Search, @@ -23,10 +22,40 @@ type Props = { isInfoSearchActive?: boolean; }; +type MenuIconProps = { + isActive: boolean; +}; + +const InftwtMenuIcon = ({ isActive }: MenuIconProps) => ( + +); + const ANALYSIS_POPUP_ITEMS: Array<{ key: WindowPopupKey | "infoSearch"; label: string; - icon: typeof ChartColumnBig; + icon?: ComponentType<{ className?: string }>; + renderIcon?: (props: MenuIconProps) => ReactNode; }> = [ { key: "infoSearch", label: "정보검색", icon: Search }, { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig }, @@ -34,7 +63,8 @@ const ANALYSIS_POPUP_ITEMS: Array<{ { key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler }, { key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 }, { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, - { key: "inftwtResultPopup", label: "침입수 분석", icon: Droplets }, + { key: "inftwtResultPopup", label: "침입수 분석결과", renderIcon: InftwtMenuIcon }, + { key: "rdiiResultPopup", label: "RDII 분석결과", icon: ChartColumnBig }, ]; export default function AnalysisPopupMenu({ @@ -63,7 +93,7 @@ export default function AnalysisPopupMenu({
{isOpen ? ( -
+
@@ -116,12 +146,15 @@ export default function AnalysisPopupMenu({ }, 300); }} > - - {item.label} + {item.renderIcon ? item.renderIcon({ isActive }) : null} + {Icon ? ( + + ) : null} + {item.label} ); })} @@ -141,4 +174,4 @@ export default function AnalysisPopupMenu({
); -} \ No newline at end of file +} diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts index aba8d2eed..eab5b6e9d 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts @@ -1,9 +1,9 @@ -import {useState, useEffect} from 'react'; -import { getData } from '@/components/lib/api'; +import { useState, useEffect } from "react"; import moment from "moment"; +import { getData } from "@/components/lib/api"; export type TInftwtAnls = { - inftwtAnlsSn:number; + inftwtAnlsSn: number; aplcnYm: string; ctpvCd: string; sggCd: string; @@ -15,18 +15,20 @@ export type TInftwtAnls = { atflMpngNm: string; atflOilqNm: string; atflNm: string; - drtwSwtcRt:number; + drtwSwtcRt: number; anlsInfo: string; scsYn: string; }; + export type TInftwtAnlsRslt = { inftwtAnlsSn: number; pplMngNo: string; anlsTm: string; - predcOilq:number; -} + predcOilq: number; +}; + export type TRdiiAnls = { - rainInfwtAnlsSn:number; + rainInfwtAnlsSn: number; aplcnYm: string; ctpvCd: string; sggCd: string; @@ -38,234 +40,259 @@ export type TRdiiAnls = { atflMpngNm: string; atflRainNm: string; atflNm: string; - drtwSwtcRt:number; + drtwSwtcRt: number; anlsInfo: string; scsYn: string; - srcNm: string; // 출처이름 + srcNm: string; }; + export type TRdiiAnlsRslt = { rainInfwtAnlsSn: number; pplMngNo: string; anlsTm: string; - predcOilq:number; - minq:number; -} + predcOilq: number; + minq: number; +}; + export type TOilqData = { - inftwtAnlsSn: number; // 침입수분석 일련번호 - pplMngNo: string; // 관로관리번호 - mtrngDt: string; // 계측일시 - oilq: number; // 유량 -} + inftwtAnlsSn: number; + pplMngNo: string; + mtrngDt: string; + oilq: number; +}; + export type TRainData = { rainInfwtAnlsSn: number; mtrngDt: string; - amrf: number; // 강우량 -} + amrf: number; +}; function roundTo(n: number, digits: number): number { const factor = Math.pow(10, digits); return Math.round(n * factor) / factor; } -const getOptionsAplcnYm = (yyyymms: string[]): { text: string; val: string, hidden?: boolean }[] => { - const yyyymms1 : { text: string; val: string; hidden?: boolean }[] = yyyymms.map(dateStr => { - const year = dateStr.slice(0, 4); - const month = dateStr.slice(4, 6); - return { - text: `${year}-${month}`, - val: dateStr - }; - }); - yyyymms1.unshift({ val: '0', text: '선택', hidden: true }); - return yyyymms1; -}; -const selectAplcnYms = async(gubun:string) => { // 상수사용월 - try { - const queryString = ''; - const result = await getData(`v1/${gubun}/aplcnYm${queryString ? `?${queryString}` : ''}`); - if (result && result.length > 0) { - return getOptionsAplcnYm(result); - } - } catch (error) { - console.error(error); - } +const getOptionsAplcnYm = (yyyymms: string[]): { text: string; val: string; hidden?: boolean }[] => { + const mapped: { text: string; val: string; hidden?: boolean }[] = yyyymms.map((dateStr) => ({ + text: `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}`, + val: dateStr, + })); + mapped.unshift({ val: "0", text: "선택", hidden: true }); + return mapped; }; -export const useAplcnYms = (gubun:string) => { - const [data, setData] = useState<{ text: string; val: string }[]>([]); +const selectAplcnYms = async (gubun: string) => { + if (!gubun) return []; + + try { + const result = await getData(`v1/${gubun}/aplcnYm`); + if (Array.isArray(result) && result.length > 0) { + return getOptionsAplcnYm(result); + } + } catch { + return []; + } + + return []; +}; + +export const useAplcnYms = (gubun: string) => { + const [data, setData] = useState<{ text: string; val: string; hidden?: boolean }[]>([]); useEffect(() => { - const fetch = async (gubun:string) => { + if (!gubun) { + setData([]); + return; + } + + const fetch = async () => { const result = await selectAplcnYms(gubun); - if (result) setData(result); + setData(result ?? []); }; - fetch(gubun); + + void fetch(); }, [gubun]); return data; }; -export const getOptionsInftwtAnlsInfo = (anlsArr: TInftwtAnls[]): { text: string; val: number, hidden?: boolean }[] => { - const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({ + +export const getOptionsInftwtAnlsInfo = (anlsArr: TInftwtAnls[]): { text: string; val: number; hidden?: boolean }[] => { + const options: { text: string; val: number; hidden?: boolean }[] = anlsArr.map((anls) => ({ text: anls.anlsInfo, val: anls.inftwtAnlsSn, })); - options.unshift({ val: 0, text: '선택', hidden: true }); + options.unshift({ val: 0, text: "선택", hidden: true }); return options; }; -export const selectInftwtAnls = async(aplcnYm:string) => { // 침입수분석 +export const selectInftwtAnls = async (aplcnYm: string) => { try { - const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ''; - const result = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ''}`); + const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ""; + const result = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ""}`); if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); + return result.content; + } + } catch { + return []; } + + return []; }; -export const getChartDataInftwtAnlsRslt = (arr: TInftwtAnlsRslt[]): { label: string; val: number }[] => { - const options = arr.map(anlsRslt => ({ + +export const getChartDataInftwtAnlsRslt = (arr: TInftwtAnlsRslt[]) => { + return arr.map((anlsRslt) => ({ label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("HH시"), val: roundTo(anlsRslt.predcOilq, 2), })); - return options; }; -export const selectInftwtAnlsRslt = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수분석 결과 + +export const selectInftwtAnlsRslt = async (inftwtAnlsSn: number, pplMngNo: string) => { try { - if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { - console.warn("inftwtAnlsSn or pplMngNo is not provided"); - return []; - } + if (!inftwtAnlsSn || !pplMngNo) return []; const result = await getData(`v1/inftwtAnlsRslt/ppl/${inftwtAnlsSn}/${pplMngNo}`); if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); + return result.content; + } + } catch { + return []; } + + return []; }; -export const getChartDataOilq = (arr: TOilqData[]): { label: string; val: number }[] => { - const options = arr.map(anlsRslt => ({ + +export const getChartDataOilq = (arr: TOilqData[]) => { + return arr.map((anlsRslt) => ({ label: anlsRslt.mtrngDt, val: roundTo(anlsRslt.oilq, 2), })); - return options; -}; -export const selectOilq = async(inftwtAnlsSn:number, pplMngNo:string) => { // 유량 - try { - if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { - console.warn("inftwtAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/inftwtAnlsRslt/oilqPpl/${inftwtAnlsSn}/${pplMngNo}`); - if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); - } -}; -export const selectPplInftwtQty = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수량 - try { - if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) { - console.warn("inftwtAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/inftwtAnlsRslt/pplInftwtQty/${inftwtAnlsSn}/${pplMngNo}`); - if (result.length > 0) { - return result[0].avg_day_flow; - } else { - return 0; // 데이터가 없을 경우 0 반환 - } - } catch (error) { - console.error(error); - } -}; -//---------- -export const selectPplRdiiQty = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // rdii값 - try { - if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) { - console.warn("rainInfwtAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/rdiiAnlsRslt/pplRdiiQty/${rainInfwtAnlsSn}/${pplMngNo}`); - if (result?.sustaintime > 0) { - return result; - } - } catch (error) { - console.error(error); - } }; -export const getOptionsRdiiAnlsInfo = (anlsArr: TRdiiAnls[]): { text: string; val: number, hidden?: boolean }[] => { - const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({ +export const selectOilq = async (inftwtAnlsSn: number, pplMngNo: string) => { + try { + if (!inftwtAnlsSn || !pplMngNo) return []; + const result = await getData(`v1/inftwtAnlsRslt/oilqPpl/${inftwtAnlsSn}/${pplMngNo}`); + if (result && result.numberOfElements > 0) { + return result.content; + } + } catch { + return []; + } + + return []; +}; + +export const selectPplInftwtQty = async (inftwtAnlsSn: number, pplMngNo: string) => { + try { + if (!inftwtAnlsSn || !pplMngNo) return 0; + const result = await getData(`v1/inftwtAnlsRslt/pplInftwtQty/${inftwtAnlsSn}/${pplMngNo}`); + if (Array.isArray(result) && result.length > 0) { + return result[0].avg_day_flow; + } + } catch { + return 0; + } + + return 0; +}; + +const buildPplMngNoCandidates = (pplMngNo: string) => { + const normalized = String(pplMngNo ?? "").trim(); + if (!normalized) return []; + const withoutPrefix = normalized.replace(/^L/, ""); + const withPrefix = withoutPrefix ? `L${withoutPrefix}` : ""; + return Array.from(new Set([normalized, withPrefix, withoutPrefix].filter(Boolean))); +}; + +export const selectPplRdiiQty = async (rainInfwtAnlsSn: number, pplMngNo: string) => { + if (!rainInfwtAnlsSn || !pplMngNo) return null; + + for (const candidate of buildPplMngNoCandidates(pplMngNo)) { + try { + const result = await getData(`v1/rdiiAnlsRslt/pplRdiiQty/${rainInfwtAnlsSn}/${candidate}`); + if (result) { + return result; + } + } catch { + // resultOnly mode fallback candidates should not raise console noise + } + } + + return null; +}; + +export const getOptionsRdiiAnlsInfo = (anlsArr: TRdiiAnls[]): { text: string; val: number; hidden?: boolean }[] => { + const options: { text: string; val: number; hidden?: boolean }[] = anlsArr.map((anls) => ({ text: anls.anlsInfo, val: anls.rainInfwtAnlsSn, })); - options.unshift({ val: 0, text: '선택', hidden: true }); + options.unshift({ val: 0, text: "선택", hidden: true }); return options; }; -export const selectRdiiAnls = async(aplcnYm:string) => { // 침입수분석 +export const selectRdiiAnls = async (aplcnYm: string) => { try { - const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ''; - const result = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ''}`); + const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : ""; + const result = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ""}`); if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); + return result.content; + } + } catch { + return []; } + + return []; }; -export const getChartDataRdiiAnlsRslt = (arr: TRdiiAnlsRslt[]): { label: string; lineVal0: number, barVal0:number }[] => { - const options = arr.map(anlsRslt => ({ + +export const getChartDataRdiiAnlsRslt = (arr: TRdiiAnlsRslt[]) => { + return arr.map((anlsRslt) => ({ label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"), lineVal0: roundTo(anlsRslt.predcOilq, 2), - barVal0: 0 + barVal0: 0, })); - return options; }; -export const getChartDataRdiiAnlsRsltMinq = (arr: TRdiiAnlsRslt[]): { label: string; val: number }[] => { - const options = arr.map(anlsRslt => ({ + +export const getChartDataRdiiAnlsRsltMinq = (arr: TRdiiAnlsRslt[]) => { + return arr.map((anlsRslt) => ({ label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"), val: roundTo(anlsRslt.minq, 2), })); - return options; }; -export const selectRdiiAnlsRslt = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // RDII분석 결과 - try { - if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) { - console.warn("RdiiAnlsSn or pplMngNo is not provided"); - return []; - } - const result = await getData(`v1/rdiiAnlsRslt/ppl/${rainInfwtAnlsSn}/${pplMngNo}`); - if (result && result.numberOfElements > 0) { + +export const selectRdiiAnlsRslt = async (rainInfwtAnlsSn: number, pplMngNo: string) => { + if (!rainInfwtAnlsSn || !pplMngNo) return []; + + for (const candidate of buildPplMngNoCandidates(pplMngNo)) { + try { + const result = await getData(`v1/rdiiAnlsRslt/ppl/${rainInfwtAnlsSn}/${candidate}`); + if (result && result.numberOfElements > 0) { return result.content; - } - } catch (error) { - console.error(error); - } -}; -export const selectRainData = async(rainInfwtAnlsSn:number) => { // 강우데이터 - try { - if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0)) { - console.warn("RdiiAnlsSn or pplMngNo is not provided"); - return []; + } + } catch { + // resultOnly mode fallback candidates should not raise console noise } + } + + return []; +}; + +export const selectRainData = async (rainInfwtAnlsSn: number) => { + try { + if (!rainInfwtAnlsSn) return []; const result = await getData(`v1/rdiiAnlsRslt/rainData/${rainInfwtAnlsSn}`); if (result && result.numberOfElements > 0) { - return result.content; - } - } catch (error) { - console.error(error); + return result.content; + } + } catch { + return []; } + + return []; }; -export const getChartDataRainData = (arr: TRainData[]): { label: string; lineVal0: number, barVal0: number }[] => { - const options = arr.map(data => ({ + +export const getChartDataRainData = (arr: TRainData[]) => { + return arr.map((data) => ({ label: moment(data.mtrngDt, "YYYY-MM-DD HH:mm:ss").format("MM-DD HH시"), lineVal0: 0, - barVal0: roundTo(data.amrf,2), + barVal0: roundTo(data.amrf, 2), })); - return options; }; \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx index ec1cec8b1..3b9d412d1 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx @@ -1,21 +1,25 @@ -'use client' +"use client"; -import React, { useEffect, useMemo, useState } from 'react'; -import moment from 'moment'; +import React, { useEffect, useMemo, useState } from "react"; +import moment from "moment"; import { CHART_FORM, type ChartConfig, MainChart, -} from '@common/business_components'; -import { SelectBox } from '@common/business_components/ui'; -import { Button, Input, Label } from '@common/components'; +} from "@common/business_components"; +import { SelectBox } from "@common/business_components/ui"; +import { Button, Input, Label } from "@common/components"; -import type { TRdiiAnls } from './aplcnYms'; -import * as fn from './aplcnYms'; +import type { TRdiiAnls } from "./aplcnYms"; +import * as fn from "./aplcnYms"; type ModalOpenProp = { - pplMngNo?: string; // 관로관리번호 + pplMngNo?: string; + rainInfwtAnlsSn?: number; + aplcnYm?: string; + analysisInfo?: string; + resultOnly?: boolean; }; type LineBarChartData = { @@ -24,112 +28,77 @@ type LineBarChartData = { barVal0: number; }; -const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }]; +const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: "선택" }]; -const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { +const normalizePplMngNo = (value: string) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return ""; + return trimmed.startsWith("L") ? trimmed : `L${trimmed}`; +}; - const [aplcnYm, setAplcnYm] = useState(''); +const RdiiChart = ({ + pplMngNo = "", + rainInfwtAnlsSn = 0, + aplcnYm = "", + analysisInfo = "", + resultOnly = false, +}: ModalOpenProp) => { + const [selectedAplcnYm, setSelectedAplcnYm] = useState(""); const [rdiiAnlss, setRdiiAnlss] = useState([]); const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>( INITIAL_ANALYSIS_OPTIONS, ); - const [rdiiAnls, setRdiiAnls] = useState(null); // 강우유입수분석정보 - const [rainInfwtAnlsSn, setRainInfwtAnlsSn] = useState(0);// 강우유입수분석 정보 선택시 사용 + const [rdiiAnls, setRdiiAnls] = useState(null); + const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] = useState(0); const [chartData, setChartData] = useState([]); - const [rdiiQty, setRdiiQty] = useState('0'); // 강우유입수량 - const [rainTotal, setRainTotal] = useState('0'); // 적용강우량 - const [totalVolume, setTotalVolume] = useState('0');// 총체적 m³/mm + const [rdiiQty, setRdiiQty] = useState("0"); + const [rainTotal, setRainTotal] = useState("0"); + const [totalVolume, setTotalVolume] = useState("0"); - const aplcnYms = fn.useAplcnYms('rdiiAnlsRslt'); // 상수사용월 데이터 가져오기 - const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : ''; + const normalizedPplMngNo = normalizePplMngNo(pplMngNo); + const isResultOnlyMode = resultOnly && rainInfwtAnlsSn > 0 && Boolean(normalizedPplMngNo); + const aplcnYms = fn.useAplcnYms(isResultOnlyMode ? "" : "rdiiAnlsRslt"); const chartForm = CHART_FORM.LINEBAR2; const chartConfig = { barVal0: { - label: '강수량', - color: '#33AAFF', + label: "강수량", + color: "#33AAFF", }, lineVal0: { - label: 'RDII', - color: '#FF0000', + label: "RDII", + color: "#FF0000", }, } as ChartConfig; const hasChartData = useMemo(() => chartData.length > 0, [chartData]); const resetState = () => { - setAplcnYm(''); // 상수사용월 초기화 - setRdiiAnlss([]); // 강우유입수분석 초기화 - setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); // 강우유입수분석정보 초기화 - setRdiiAnls(null); // 강우유입수분석정보 초기화 - setRainInfwtAnlsSn(0); // 강우유입수분석 일련번호 초기화 - setChartData([]); - setRdiiQty('0'); // 강우유입수량 초기화 - setRainTotal('0');// 적용강우량 초기화 - setTotalVolume('0');// 총체적 m³/mm 초기화 - }; - - const handleAplcnYmChange = async (val: string | number) => { - const selectedValue = String(val); - - setAplcnYm(selectedValue); + setSelectedAplcnYm(""); setRdiiAnlss([]); + setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); setRdiiAnls(null); - setRainInfwtAnlsSn(0); + setSelectedRainInfwtAnlsSn(0); setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); - - if (!selectedValue || selectedValue === '0') { - setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); - return; - } - - try { - const data = await fn.selectRdiiAnls(selectedValue); // 상수사용월 변경시 분석 정보 리스트 가져오기 - const list = data ?? []; - setRdiiAnlss(list); - setRdiiAnlsInfos(fn.getOptionsRdiiAnlsInfo(list)); - } catch (error) { - console.error('selectRdiiAnls 에러:', error); - setRdiiAnlss([]); - setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); - } + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); }; - const handleRdiiAnlsSnChange = (val: string | number) => { - const selectedValue = Number(val); - - setRainInfwtAnlsSn(selectedValue); - setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); - - if (selectedValue === 0) { - setRdiiAnls(null); - return; - } - - const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null; - setRdiiAnls(selectedAnls); - }; - - const fetchFilteredData = async () => { - if (!rainInfwtAnlsSn || !normalizedPplMngNo) { + const loadResultChart = async (targetRainInfwtAnlsSn: number) => { + if (!targetRainInfwtAnlsSn || !normalizedPplMngNo) { setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); return; } try { const [rsltData, rainData, qtyData] = await Promise.all([ - fn.selectRdiiAnlsRslt(rainInfwtAnlsSn, normalizedPplMngNo), - fn.selectRainData(rainInfwtAnlsSn), - fn.selectPplRdiiQty(rainInfwtAnlsSn, normalizedPplMngNo), + fn.selectRdiiAnlsRslt(targetRainInfwtAnlsSn, normalizedPplMngNo), + fn.selectRainData(targetRainInfwtAnlsSn), + fn.selectPplRdiiQty(targetRainInfwtAnlsSn, normalizedPplMngNo), ]); const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []); @@ -143,7 +112,7 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { } }); - setChartData(rsltDataFormatted);// 차트 데이터 설정 + setChartData(rsltDataFormatted); const avgRdii = Number(qtyData?.rdiih ?? 0); const appliedRainfall = Number(qtyData?.raintotal ?? 0); @@ -152,18 +121,80 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => { ? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal) : 0; - setRdiiQty(Math.round(avgRdii * 24).toLocaleString('ko-KR')); - setRainTotal(appliedRainfall.toLocaleString('ko-KR')); - setTotalVolume(rdiiPerRainfall.toLocaleString('ko-KR')); + setRdiiQty(Math.round(avgRdii * 24).toLocaleString("ko-KR")); + setRainTotal(appliedRainfall.toLocaleString("ko-KR")); + setTotalVolume(rdiiPerRainfall.toLocaleString("ko-KR")); } catch (error) { - console.error('rdiiRsltChart 에러:', error); + console.error("RDII 분석결과 차트 조회 중 오류:", error); setChartData([]); - setRdiiQty('0'); - setRainTotal('0'); - setTotalVolume('0'); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); } }; + useEffect(() => { + if (!isResultOnlyMode) return; + + setSelectedAplcnYm(aplcnYm); + setSelectedRainInfwtAnlsSn(rainInfwtAnlsSn); + setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); + void loadResultChart(rainInfwtAnlsSn); + }, [aplcnYm, isResultOnlyMode, normalizedPplMngNo, rainInfwtAnlsSn]); + + const handleAplcnYmChange = async (val: string | number) => { + const selectedValue = String(val); + + setSelectedAplcnYm(selectedValue); + setRdiiAnlss([]); + setRdiiAnls(null); + setSelectedRainInfwtAnlsSn(0); + setChartData([]); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); + + if (!selectedValue || selectedValue === "0") { + setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); + return; + } + + const data = await fn.selectRdiiAnls(selectedValue); + const list = data ?? []; + setRdiiAnlss(list); + setRdiiAnlsInfos(fn.getOptionsRdiiAnlsInfo(list)); + }; + + const handleRdiiAnlsSnChange = (val: string | number) => { + const selectedValue = Number(val); + + setSelectedRainInfwtAnlsSn(selectedValue); + setChartData([]); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); + + if (selectedValue === 0) { + setRdiiAnls(null); + return; + } + + const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null; + setRdiiAnls(selectedAnls); + }; + + const fetchFilteredData = async () => { + if (!selectedRainInfwtAnlsSn || !normalizedPplMngNo) { + setChartData([]); + setRdiiQty("0"); + setRainTotal("0"); + setTotalVolume("0"); + return; + } + + await loadResultChart(selectedRainInfwtAnlsSn); + }; + const renderInfoInput = (label: string, value?: string | number | null) => (
); - const renderBody = () => ( -
-
-
+ const renderResultPanel = () => ( +
+

RDII 분석 결과

+
+
+
+ 상수사용 적용연월 +
+
+ {selectedAplcnYm ? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM") : "-"} +
+
+ +
+
+ 강우유입수량 분석결과 +
+
+
+
평균RDII
+
{rdiiQty}
+
m³/d
+
+
+
적용 강우량
+
{rainTotal}
+
mm
+
+
+
강우량 대비 RDII
+
{totalVolume}
+
m³/mm
+
+
+
+
+ {hasChartData ? ( + + ) : ( +
데이터가 없습니다.
+ )} +
+
+ ); + + const renderDefaultBody = () => ( +
-

- 강우유입수분석 선택 조건 -

+

강우유입수량 선택 조건

- +
- +
- {renderInfoInput('오수전환율:', rdiiAnls?.drtwSwtcRt ?? '')} - {renderInfoInput('수용가-맨홀 매칭:', rdiiAnls?.atflMpngNm ?? '')} - {renderInfoInput('관망도 파일명:', rdiiAnls?.atflInpNm ?? '')} - {renderInfoInput('강우 파일명:', rdiiAnls?.atflRainNm ?? '')} - {renderInfoInput('RTK계수 출처:', rdiiAnls?.srcNm ?? '')} + {renderInfoInput("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")} + {renderInfoInput("수용가-맨홀 매칭:", rdiiAnls?.atflMpngNm ?? "")} + {renderInfoInput("관망도 파일명", rdiiAnls?.atflInpNm ?? "")} + {renderInfoInput("강우 파일명", rdiiAnls?.atflRainNm ?? "")} + {renderInfoInput("RTK계수 출처:", rdiiAnls?.srcNm ?? "")}
- +    - +
-
-

- 강우유입수분석 결과 -

- -
- {/* 좌측 카드 */} -
-
- 상수사용량 적용연월 -
-
- {aplcnYm ? moment(aplcnYm, 'YYYYMM').format('YYYY-MM') : '-'} -
-
- - {/* 우측 카드 */} -
- {/* 좌측: 제목 (세로 가운데 정렬) */} -
-
- 강우유입수량 분석결과 -
-
- - {/* 우측: 지표 영역 */} -
-
-
- 평균RDII -
-
- {rdiiQty} -
-
- m³/d -
-
- -
-
- 적용 강우량 -
-
- {rainTotal} -
-
- mm -
-
- -
-
- 강우량 대비 RDII -
-
- {totalVolume} -
-
- m³/mm -
-
-
-
-
- -
- {hasChartData ? ( - - ) : ( -
- 데이터가 없습니다. -
- )} -
-
+ {renderResultPanel()}
); + if (isResultOnlyMode) { + return
{renderResultPanel()}
; + } + return (

RDII 분석

- {renderBody()} + {renderDefaultBody()}
); }; -export default React.memo(RdiiChart); +export default React.memo(RdiiChart); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx new file mode 100644 index 000000000..bcda216de --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import { Button } from "@common/components"; + +import { getData } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; +import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; +import useStore from "@/store/useStore"; + +import { + RDII_RESULT_ROW_SELECT_MESSAGE, + type RdiiResultRow, +} from "./rdiiResultTypes"; + +const RDII_RESULT_PAGE_SIZE = 10; + +const RDII_RESULT_HEADLIST = [ + { id: "rowNumber", title: "일련번호" }, + { id: "ctpvNm", title: "시도" }, + { id: "sggNm", title: "시군구" }, + { id: "swtrmtFcltNm", title: "하수처리시설" }, + { id: "aplcnYm", title: "상수사용연월" }, + { id: "drtwSwtcRt", title: "오수 전환율" }, + { id: "srcNm", title: "RTK 계수 출처" }, + { id: "cntbArea", title: "RDII 기여면적" }, + { id: "anlsInfo", title: "RDII 분석정보" }, + { id: "regDt", title: "분석일자" }, + { id: "rgtrNm", title: "분석자" }, + { id: "scsYn", title: "분석성공여부" }, +] as const; + +type RdiiResultFilters = { + ctpvCd: string; + sggCd: string; + sggName: string; + swtrmtFcltNm: string; + aplcnYmFrom: string; + aplcnYmTo: string; +}; + +const createDefaultAplcnYm = () => { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + return `${year}${month}`; +}; + +const DEFAULT_FILTERS: RdiiResultFilters = { + ctpvCd: "0", + sggCd: "0", + sggName: "", + swtrmtFcltNm: "", + aplcnYmFrom: `${new Date().getFullYear()}01`, + aplcnYmTo: createDefaultAplcnYm(), +}; + +const postRowSelectMessage = (row: RdiiResultRow) => { + if (typeof window === "undefined" || !window.opener) return; + window.opener.postMessage( + { + type: RDII_RESULT_ROW_SELECT_MESSAGE, + row, + }, + window.location.origin, + ); +}; + +export default function RdiiResultPopupPage() { + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0"); + const [selectedRow, setSelectedRow] = useState(null); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (ctpv.length === 0) void fetchCtpv(); + if (sgg.length === 0) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + + const fetchRows = useCallback( + async (page: number, nextFilters: RdiiResultFilters) => { + const queryParams = new URLSearchParams({ + page: String(page - 1), + size: String(RDII_RESULT_PAGE_SIZE), + sort: "rainInfwtAnlsSn,desc", + }); + + if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom); + if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo); + if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd); + if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd); + if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim()); + + const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`); + const content = Array.isArray(response?.content) ? response.content : []; + + const nextRows = content + .map((item: Record, index: number) => { + const formattedItem = formatDisplayData(item); + const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim()); + const sggOption = sgg.find((code) => code.val === item?.sggCd); + + return { + ...(formattedItem as Record), + rowNumber: (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1, + ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"), + sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"), + scsYn: String(item?.scsYn ?? "N"), + } as RdiiResultRow; + }) + .filter((item: RdiiResultRow) => item.scsYn === "Y"); + + setRows(nextRows); + setTotal(Number(response?.totalElements ?? nextRows.length)); + setCurrentPage(page); + }, + [ctpv, sgg], + ); + + const loadRows = useCallback( + async (page = 1, nextFilters = filters) => { + setIsLoading(true); + try { + await fetchRows(page, nextFilters); + } catch (error) { + console.error("RDII 분석결과 목록 조회 중 오류:", error); + alert("RDII 분석결과 목록을 불러오지 못했습니다."); + setRows([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }, + [fetchRows, filters], + ); + + useEffect(() => { + void loadRows(1, DEFAULT_FILTERS); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSearch = async (page = 1) => { + setSelectedIndex(-1); + setSelectedRow(null); + await loadRows(page, filters); + }; + + const handleRowClick = (index?: number) => { + if (typeof index !== "number") return; + const row = rows[index]; + if (!row) return; + + setSelectedIndex(index); + setSelectedRow(row); + postRowSelectMessage(row); + }; + + const handleDownload = async () => { + if (!selectedRow?.rainInfwtAnlsSn) { + alert("다운로드할 분석결과를 먼저 선택해주세요."); + return; + } + + try { + setIsLoading(true); + const response = await getData(`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`); + const excelRows = Array.isArray(response?.content) ? response.content : []; + + await downloadExcelWithKoreanHeaders({ + data: excelRows as JSON[], + fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`, + sheetNameArray: ["RDII 분석결과"], + headerRange: "A1:D1", + headerMapping: { + rainInfwtAnlsSn: "RDII분석\n일련번호", + pplMngNo: "관로\n관리번호", + anlsTm: "분석시각", + predcOilq: "예측유량\n(m³/h)", + }, + }); + } catch (error) { + console.error("RDII 분석결과 다운로드 중 오류:", error); + alert("RDII 분석결과 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+

RDII 분석결과

+
+ +
+
+
+
+ 상수사용기간 + { + setFilters((current) => ({ + ...current, + aplcnYmFrom: value, + })); + }, + }} + selectClass="w-[145px]" + startYear={1900} + /> + ~ + { + setFilters((current) => ({ + ...current, + aplcnYmTo: value, + })); + }, + }} + selectClass="w-[145px]" + startYear={1900} + /> +
+ +
+ { + setSelectedSwagePrcsFclt("0"); + setFilters((current) => ({ + ...current, + ctpvCd: value, + sggCd: "0", + sggName: "", + swtrmtFcltNm: "", + })); + }} + onSigunguChange={(value) => { + setSelectedSwagePrcsFclt("0"); + setFilters((current) => ({ + ...current, + sggCd: value, + swtrmtFcltNm: "", + })); + }} + setSggName={(value) => { + setSelectedSwagePrcsFclt("0"); + setFilters((current) => ({ + ...current, + sggName: value ?? "", + swtrmtFcltNm: "", + })); + }} + selectedSwagePrcsFclt={selectedSwagePrcsFclt} + onSwagePrcsFcltChange={setSelectedSwagePrcsFclt} + setSwtrmtFcltNm={(value) => + setFilters((current) => ({ + ...current, + swtrmtFcltNm: value ?? "", + })) + } + setIsLoading={setIsLoading} + /> +
+ +
+ + +
+
+
+ +
+ + headName={<>{`분석이력 총 ${total.toLocaleString()}건`}} + tableData={rows} + headlist={RDII_RESULT_HEADLIST as unknown as { id: string; title: string }[]} + pageSize={RDII_RESULT_PAGE_SIZE} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={(page) => void handleSearch(page)} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + /> +
+ + {selectedRow ? ( +
+ {"선택 분석: "} + {selectedRow.anlsInfo} +
+ ) : null} +
+
+ ); +} \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/rdiiResultTypes.ts b/apps/sn/app/(map-layout)/map/components/rdiiResult/rdiiResultTypes.ts new file mode 100644 index 000000000..30604e653 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/rdiiResultTypes.ts @@ -0,0 +1,46 @@ +"use client"; + +export const RDII_RESULT_ROW_SELECT_MESSAGE = "SN_RDII_RESULT_ROW_SELECT"; +export const RDII_RESULT_OVERLAY_TYPE = "RDII_RESULT"; + +export type RdiiResultRow = { + rowNumber: number; + rainInfwtAnlsSn: number; + ctpvCd?: string; + ctpvNm: string; + sggCd?: string; + sggNm: string; + swtrmtFcltNm: string; + aplcnYm: string; + drtwSwtcRt?: number | string; + srcNm?: string; + cntbArea?: number | string; + anlsInfo: string; + regDt?: string; + rgtrNm?: string; + scsYn?: string; +}; + +export type RdiiResultMapViewPipe = { + pplMngNo: string; + fcltMngNo: string; +}; + +export type RdiiResultMapViewResponse = { + analysis: { + rainInfwtAnlsSn: number; + aplcnYm: string; + ctpvCd?: string; + sggCd?: string; + swtrmtFcltNm?: string; + anlsInfo?: string; + }; + focusTargetType: "PIPE_EXTENT"; + focusTargetName: string; + focusLayerType?: "tb_ms_swage_ppl"; + focusFid?: string; + focusFallbackApplied?: boolean; + pipeGeometry: Record; + pipes: RdiiResultMapViewPipe[]; + pipeCount: number; +}; \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/useRdiiResultMap.ts b/apps/sn/app/(map-layout)/map/components/rdiiResult/useRdiiResultMap.ts new file mode 100644 index 000000000..bca19cc0b --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/useRdiiResultMap.ts @@ -0,0 +1,349 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; +import GeoJSON from "ol/format/GeoJSON.js"; +import VectorLayer from "ol/layer/Vector.js"; +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 GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils"; + +import { + RDII_RESULT_OVERLAY_TYPE, + RDII_RESULT_ROW_SELECT_MESSAGE, + type RdiiResultMapViewResponse, + type RdiiResultRow, +} from "./rdiiResultTypes"; + +type UseRdiiResultMapParams = { + gisMaps: MutableRefObject; + setFeatureData: (value: any) => void; + setIsOpen: (value: boolean) => void; + setIsSearchModalOpen: (value: boolean) => void; +}; + +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({ + stroke: new Stroke({ + color: "rgba(15, 23, 42, 0.56)", + width: 9, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 20020, + }), + new Style({ + stroke: new Stroke({ + color: "rgba(255, 255, 255, 0.98)", + width: 5, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 20021, + }), + new Style({ + stroke: new Stroke({ + color: "rgba(59, 130, 246, 0.98)", + width: 2.5, + lineCap: "round", + lineJoin: "round", + }), + zIndex: 20022, + }), +]; + +const getFeatureCollection = (payload: any) => { + if (payload?.type === "FeatureCollection") return payload; + if (payload?.data?.type === "FeatureCollection") return payload.data; + return { type: "FeatureCollection", features: [] }; +}; + +const normalizeProjectionName = (value: string) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return "EPSG:4326"; + if (trimmed.startsWith("EPSG:")) return trimmed; + const match = trimmed.match(/EPSG[:/]{1,2}(\d{4,5})/i) || trimmed.match(/(\d{4,5})$/); + return match?.[1] ? `EPSG:${match[1]}` : trimmed; +}; + +const resolveFeatureCollectionProjection = (payload: any) => { + const featureCollection = getFeatureCollection(payload); + const crsName = String(featureCollection?.crs?.properties?.name ?? "").trim(); + return normalizeProjectionName(crsName || GisApp.TO_PROJ); +}; + +const getOverlayFlag = (data: any) => + String( + data?.get?.("analysisOverlayType") ?? + data?.values_?.analysisOverlayType ?? + data?.analysisOverlayType ?? + "", + ).trim(); + +const getOverlayBasePplMngNo = (data: any) => + String( + data?.get?.("fcltMngNo") ?? + data?.values_?.fcltMngNo ?? + data?.values_?.fclt_mng_no ?? + data?.fcltMngNo ?? + data?.fclt_mng_no ?? + "", + ).trim(); + +const getOverlayAnalysisPplMngNo = (data: any) => + String( + data?.get?.("pplMngNo") ?? + data?.values_?.pplMngNo ?? + data?.pplMngNo ?? + "", + ).trim(); + +const getOverlayAnalysisSn = (data: any) => + Number( + data?.get?.("rainInfwtAnlsSn") ?? + data?.values_?.rainInfwtAnlsSn ?? + data?.rainInfwtAnlsSn ?? + 0, + ); + +const getOverlayAplcnYm = (data: any) => + String( + data?.get?.("aplcnYm") ?? + data?.values_?.aplcnYm ?? + data?.aplcnYm ?? + "", + ).trim(); + +const getOverlayAnalysisInfo = (data: any) => + String( + data?.get?.("analysisInfo") ?? + data?.values_?.analysisInfo ?? + data?.analysisInfo ?? + "", + ).trim(); + +export const useRdiiResultMap = ({ + gisMaps, + setFeatureData, + setIsOpen, + setIsSearchModalOpen, +}: UseRdiiResultMapParams) => { + const activeResultRowRef = useRef(null); + const pipeLayerRef = useRef | null>(null); + const pipeSourceRef = useRef(null); + const mapViewCacheRef = useRef>(new Map()); + const [activeMapView, setActiveMapView] = useState(null); + + const clearLayers = useCallback(() => { + pipeSourceRef.current?.clear?.(); + pipeLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + }, [gisMaps]); + + const ensurePipeLayer = useCallback(() => { + const map = gisMaps.current; + if (!map) return null; + + if (pipeLayerRef.current && pipeSourceRef.current) { + return pipeSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + zIndex: 20019, + }); + + layer.set("id", RDII_PIPE_LAYER_ID); + layer.set("lyrNm", "RDII 결과관로"); + layer.set("lyrTyNm", "tb_ms_swage_ppl"); + layer.set("clickPsblYn", "Y"); + layer.setVisible?.(true); + map.addLayer(layer); + + pipeLayerRef.current = layer; + pipeSourceRef.current = source; + return source; + }, [gisMaps]); + + const fitToRenderedFeatures = useCallback(() => { + const map = gisMaps.current; + const pipeExtent = pipeSourceRef.current?.getExtent?.(); + if (!map || !pipeExtent || pipeExtent.some((value: number) => !Number.isFinite(value))) { + return; + } + + map.getView?.().fit?.(pipeExtent, { + duration: 350, + padding: DEFAULT_RESULT_FIT_PADDING, + maxZoom: 16, + constrainResolution: true, + nearest: true, + }); + }, [gisMaps]); + + const moveByRegionCodes = useCallback( + (ctpvCd?: string, sggCd?: string) => { + const normalizedSggCd = String(sggCd ?? "").trim(); + if (normalizedSggCd && normalizedSggCd !== "0") { + const sigunguCenter = getSigunguCenter(normalizedSggCd); + if (sigunguCenter) { + moveMapToCenter(gisMaps, sigunguCenter, 12); + return true; + } + } + + const normalizedCtpvCd = String(ctpvCd ?? "").trim(); + if (normalizedCtpvCd && normalizedCtpvCd !== "0") { + const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)]; + if (sidoCenterCoordinate) { + moveMapToCenter(gisMaps, sidoCenterCoordinate, 11); + return true; + } + } + + return false; + }, + [gisMaps], + ); + + const moveToStableFocus = useCallback( + async (mapView: RdiiResultMapViewResponse) => { + const activeRow = activeResultRowRef.current; + const movedByRegion = moveByRegionCodes( + activeRow?.ctpvCd ?? mapView.analysis?.ctpvCd, + activeRow?.sggCd ?? mapView.analysis?.sggCd, + ); + if (movedByRegion) return; + + fitToRenderedFeatures(); + }, + [fitToRenderedFeatures, moveByRegionCodes], + ); + + const renderMapView = useCallback( + async (mapView: RdiiResultMapViewResponse) => { + const pipeSource = ensurePipeLayer(); + if (!pipeSource) return; + + clearLayers(); + + const pipeDataProjection = resolveFeatureCollectionProjection(mapView.pipeGeometry); + const pipeFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.pipeGeometry), { + dataProjection: pipeDataProjection, + featureProjection: GisApp.TO_PROJ, + }); + pipeFeatures.forEach((feature: any) => { + feature.setStyle(pipeStyle); + }); + pipeSource.addFeatures(pipeFeatures); + pipeSource.changed?.(); + pipeLayerRef.current?.setVisible?.(true); + pipeLayerRef.current?.changed?.(); + gisMaps.current?.renderSync?.(); + + await moveToStableFocus(mapView); + }, + [clearLayers, ensurePipeLayer, gisMaps, moveToStableFocus], + ); + + const handleRowSelect = useCallback( + async (row: RdiiResultRow) => { + 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) { + setActiveMapView(cachedMapView); + void 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); + setActiveMapView(mapView); + void renderMapView(mapView); + } catch (error) { + console.error("RDII 분석결과 지도 연계 중 오류:", error); + alert("RDII 분석결과를 지도에 연계하지 못했습니다."); + } + }, + [renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen], + ); + + useEffect(() => { + 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); + }; + + window.addEventListener("message", handlePopupMessage); + return () => window.removeEventListener("message", handlePopupMessage); + }, [handleRowSelect]); + + useEffect(() => { + return () => { + clearLayers(); + }; + }, [clearLayers]); + + const handleOverlayFeatureSelect = useCallback((data: any) => { + if (getOverlayFlag(data) !== RDII_RESULT_OVERLAY_TYPE) { + return false; + } + + const overlayAnalysisSn = getOverlayAnalysisSn(data); + const basePplMngNo = getOverlayBasePplMngNo(data); + const analysisPplMngNo = getOverlayAnalysisPplMngNo(data); + const currentRow = activeResultRowRef.current; + const popupWindow = openWindowPopup("rdiiChart", { + pplMngNo: basePplMngNo || analysisPplMngNo.replace(/^L/, ""), + rainInfwtAnlsSn: overlayAnalysisSn || currentRow?.rainInfwtAnlsSn || 0, + aplcnYm: getOverlayAplcnYm(data) || currentRow?.aplcnYm || activeMapView?.analysis?.aplcnYm || "", + analysisInfo: + getOverlayAnalysisInfo(data) || + currentRow?.anlsInfo || + activeMapView?.analysis?.anlsInfo || + "", + resultOnly: "Y", + }); + + if (!popupWindow) { + alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요."); + } + + return true; + }, [activeMapView]); + + return { + activeMapView, + clearLayers, + handleOverlayFeatureSelect, + }; +}; \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index ed4044ca1..082460f20 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -79,6 +79,7 @@ import { } from "./components/agedPipe/agedPipeUtils"; import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport"; import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap"; +import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap"; import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel"; import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel"; import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel"; @@ -2459,6 +2460,12 @@ const Map = () => { setIsOpen, setIsSearchModalOpen, }); + const rdiiResultMap = useRdiiResultMap({ + gisMaps, + setFeatureData, + setIsOpen, + setIsSearchModalOpen, + }); const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); const sinkholePopupWindowRef = useRef(null); @@ -3584,6 +3591,12 @@ const Map = () => { const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data); // 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다. clearSinkholeFocusedPipeLayer(); + if (rdiiResultMap.handleOverlayFeatureSelect(data)) { + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } if (inftwtResultMap.handleOverlayFeatureSelect(data)) { setFeatureData(null); setIsOpen(false); diff --git a/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md b/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md new file mode 100644 index 000000000..6d7b8c591 --- /dev/null +++ b/apps/sn/docs/작업내용/SN-RDII분석결과-지도연계유지보수/001-fe-SN-RDII분석결과-지도연계유지보수-통합구현기록.md @@ -0,0 +1,46 @@ +# SN RDII분석결과 지도연계 유지보수 FE 통합구현기록 + +## 1. 작업 목적 +- RDII 분석결과를 좌측 LNB 대신 하수관망도 `분석도구` popup으로 옮기고, 침입수 분석결과와 같은 지도 연계 흐름으로 정리한다. + +## 2. 실제 수정 파일 +- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/rdiiResult/**` +- `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- `FE_APP_ROOT/src/features/windowPopup/**` +- `FE_APP_ROOT/src/app/styles/components.css` + +## 3. 구현 내용 +- `rdiiResultPopup` popup 정의와 registry를 추가했다. +- `RdiiResultPopupPage`를 만들어 기존 `ii/rdiiResult`의 조회/다운로드 흐름을 popup에서 재사용하도록 옮겼다. +- row click 시 `SN_RDII_RESULT_ROW_SELECT` 메시지를 부모 창으로 전달하고, `useRdiiResultMap`에서 `/sewerage-service/api/v1/rdiiAnlsRslt/mapView/{rainInfwtAnlsSn}`를 호출해 결과 관로 overlay를 렌더하도록 연결했다. +- row click 직후 지도 이동은 `SN_INFTWT_PROC_SEARCH` 검색바 동기화 이벤트를 재사용해 시도/시군구 중심 이동 규칙을 그대로 따르도록 맞췄다. +- overlay 관로 click 시 `rdiiChart` popup을 `resultOnly` 모드로 열고, `rainInfwtAnlsSn/aplcnYm/analysisInfo`를 함께 넘겨 좌측 선택조건 없이 결과만 보이도록 확장했다. +- `분석도구` 메뉴에는 popup key를 `rdiiResultPopup`으로 연결하되, 실제 노출 라벨은 `RDII 분석`으로 맞췄고 legacy `ii/rdiiResult`는 CSS로만 숨겨 route는 유지했다. + +## 4. 검증 +- FE 변경분 기준 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`을 실행했다. +- 이번 변경으로 새로 생긴 오류는 없었고, 아래 선행 타입 오류 때문에 전체 통과는 하지 못했다. + - `app/(map-layout)/map/components/common/index.ts` + - `app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts` + - `app/(map-layout)/map/page.tsx` + - `app/html/(map-layout)/map/components/common/index.ts` + - `app/html/(with-base-layout)/layout.tsx` + - `packages/common/business_components/src/ui/chart/*` + +## 5. 2026-07-02 후속 UI 보정 +- 사용자의 피드백에 따라 침입수 분석 메뉴 아이콘은 기존 물방울 표시로 되돌렸다. +- RDII 분석결과 메뉴 문구는 실제 노출 기준 RDII 분석으로 조정했다. + +## 6. 남은 이슈 +- 브라우저 실동작 검증은 이번 세션에서 수행하지 못했다. +- LNB 숨김 규칙은 현재 메뉴 순서 기준 `nth-child(4)`를 사용하므로, 추후 I/I 메뉴 순서가 바뀌면 selector를 함께 조정해야 한다. +## 7. 2026-07-02 resultOnly popup 후속 보정 +- `rdiiChart` resultOnly 우측 요약 카드의 제목 라벨을 `shrink-0 + whitespace-nowrap` 기준으로 보정해 `강우유입수량 분석결과`가 한 줄로 유지되도록 맞췄다. +- 차트/요약 조회 Promise 묶음에 예외 방어를 추가해 조회 실패 시 콘솔 오류를 남기고 상태를 빈 값으로 명확히 초기화하도록 정리했다. +- FE가 계속 호출하던 `/api/v1/rdiiAnlsRslt/ppl/{rainInfwtAnlsSn}/{pplMngNo}`, `/api/v1/rdiiAnlsRslt/ppl/{rainInfwtAnlsSn}` 경로가 BE에 실제로 필요함을 기준으로 연동 계약을 다시 점검했다. +- `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json` 재실행 결과, 이번 변경과 무관한 기존 선행 오류만 남고 RDII 변경 파일 기준 신규 오류는 확인되지 않았다. +## 8. 최종 확인 메모 +- 사용자 실동작 기준으로 `분석도구 > RDII 분석` popup, row click 지도 이동, 관로 highlight, resultOnly popup 흐름이 정상 동작하는 것으로 마감한다. +- FE 전체 타입 오류는 이번 RDII 변경과 무관한 기존 선행 이슈만 남아 별도 정리 대상으로 유지한다. \ No newline at end of file diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css index 679feb8f2..e7a0d904d 100644 --- a/apps/sn/src/app/styles/components.css +++ b/apps/sn/src/app/styles/components.css @@ -19,6 +19,10 @@ } } /* `I/I 분석 > 침입수 분석결과`는 나중에 다시 사용할 수 있도록 라우트는 유지하고 LNB에서만 임시 숨김 처리한다. */ +/* `I/I 분석 > 침입수 분석결과`, `RDII 분석결과`는 지도 popup 흐름으로 대체하고 레거시 라우트는 유지한 채 LNB에서만 숨긴다. */ [data-sidebar="menu"] > *:nth-child(2) [data-sidebar="menu-sub"] > li:nth-child(2) { display: none; +} +[data-sidebar="menu"] > *:nth-child(2) [data-sidebar="menu-sub"] > li:nth-child(4) { + display: none; } \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 69fc8d4d1..744ccfe8a 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -52,6 +52,12 @@ export const windowPopupDefinitions = { width: 1280, height: 900, }, + rdiiResultPopup: { + key: "rdiiResultPopup", + title: "RDII 분석결과", + width: 1280, + height: 900, + }, infilChart: { key: "infilChart", title: "침입수 분석", @@ -66,8 +72,12 @@ export const windowPopupDefinitions = { rdiiChart: { key: "rdiiChart", title: "RDII 분석", - width: 1360, - height: 980, + width: 940, + height: 860, + placement: "browser-left", + alignTop: true, + offsetLeft: 0, + offsetTop: 70, requiredParams: ["pplMngNo"], }, sscDetail: { diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index 29d767747..fe6758bf2 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -8,6 +8,7 @@ import InftwtResultPopupContent from "./ui/InftwtResultPopupContent"; import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent"; import OldPpTablePopupContent from "./ui/OldPpTablePopupContent"; import RdiiChartPopupContent from "./ui/RdiiChartPopupContent"; +import RdiiResultPopupContent from "./ui/RdiiResultPopupContent"; import ReportFilesPopupContent from "./ui/ReportFilesPopupContent"; import ReportTabPopupContent from "./ui/ReportTabPopupContent"; import SearchModalPopupContent from "./ui/SearchModalPopupContent"; @@ -65,6 +66,10 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.inftwtResultPopup, component: InftwtResultPopupContent, }, + rdiiResultPopup: { + ...windowPopupDefinitions.rdiiResultPopup, + component: RdiiResultPopupContent, + }, infilChart: { ...windowPopupDefinitions.infilChart, component: InfilChartPopupContent, diff --git a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx index 8a591e004..148813108 100644 --- a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx @@ -8,6 +8,10 @@ export default function RdiiChartPopupContent({ searchParams, }: WindowPopupContentProps) { const pplMngNo = getPopupParamValue(searchParams.pplMngNo); + const resultOnly = getPopupParamValue(searchParams.resultOnly) === "Y"; + const aplcnYm = getPopupParamValue(searchParams.aplcnYm); + const analysisInfo = getPopupParamValue(searchParams.analysisInfo); + const rainInfwtAnlsSn = Number(getPopupParamValue(searchParams.rainInfwtAnlsSn) || "0"); if (!pplMngNo) { return ( @@ -18,5 +22,13 @@ export default function RdiiChartPopupContent({ ); } - return ; -} + return ( + + ); +} \ No newline at end of file diff --git a/apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx new file mode 100644 index 000000000..c3e6f3039 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import RdiiResultPopupPage from "@/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage"; + +export default function RdiiResultPopupContent() { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return
; + } + + return ; +} \ No newline at end of file