diff --git a/apps/cm/src/pages/dashboard/api/sn-service.ts b/apps/cm/src/pages/dashboard/api/sn-service.ts index 0beaaf59e..bb8a3fd9b 100644 --- a/apps/cm/src/pages/dashboard/api/sn-service.ts +++ b/apps/cm/src/pages/dashboard/api/sn-service.ts @@ -2,7 +2,7 @@ import { client } from "@/shared/api"; import { create } from "zustand"; import { ENDPOINT } from "@/shared/config"; -import type { PplLengthContent, SewerageService } from "../model/sn-interface"; +import type { BasinStofcbrCountContent, PplLengthContent, SewerageService } from "../model/sn-interface"; import type { OldPplChartData, mapPplOldType, mapPplOldItem } from "../model/sn-interface"; export type SnState = { @@ -35,26 +35,7 @@ const initialUsePplChartData: OldPplChartData = { }; const initialOldPplChartData: OldPplChartData = { labels: ["금강", "한강", "낙동강", "영산강"], - datasets: [ - { - label: "20-25년", - data: [1140, 3181, 3100, 1662], - backgroundColor: "#3B82F6", - borderRadius: 4 - }, - { - label: "25-30년", - data: [1391, 3615, 1814, 1411], - backgroundColor: "#EF4444", - borderRadius: 4 - }, - { - label: "30년이상", - data: [2014, 10303, 9831, 2000], - backgroundColor: "#F87171", - borderRadius: 4 - } - ] + datasets: [] }; type OldPplStoreState = { chartData: OldPplChartData; @@ -70,23 +51,122 @@ export const useUsePplStore = create((set) => ({ })); export const initailMapOldPplData: mapPplOldType = { - colTitle: { totalLen: "총길이", pplLen2025: "20~25년", pplLen2530: "25~30년", pplLen30: "30년 이상" }, //헤더 영역 + colTitle: { stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" }, data: [ - { name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30: 2014 }, - { name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30: 10303 }, - { name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30: 9831 }, - { name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30: 2000 }, + { name: "금강", totalLen: 0, stofcbrCount: 0 }, + { name: "한강", totalLen: 0, stofcbrCount: 0 }, + { name: "낙동강", totalLen: 0, stofcbrCount: 0 }, + { name: "영산강", totalLen: 0, stofcbrCount: 0 }, ] }; type mapPplOldState = { mapData: mapPplOldItem[]; setMapData: (mapData: mapPplOldItem[]) => void; + setStofcbrCounts: (counts: BasinStofcbrCountContent[]) => void; }; export const useMapOldPplStore = create((set) => ({ mapData: initailMapOldPplData.data, - setMapData: (newMapData) => set({ mapData: newMapData }), + setMapData: (newMapData) => set((state) => ({ + mapData: newMapData.map((item) => ({ + ...item, + stofcbrCount: state.mapData.find((current) => current.name === item.name)?.stofcbrCount ?? item.stofcbrCount, + })), + })), + setStofcbrCounts: (counts) => set((state) => ({ + mapData: state.mapData.map((item) => ({ + ...item, + stofcbrCount: counts.find((count) => count.zoneNm === item.name)?.stofcbrCount ?? item.stofcbrCount, + })), + })), })); +const DEFAULT_BASIN_ORDER = ["금강", "한강", "낙동강", "영산강"]; +const YEAR_GROUP_START_COLOR = "#FACC15"; +const YEAR_GROUP_END_COLOR = "#F87171"; + +/** + * 시작색과 종료색 사이를 RGB 기준으로 보간해 구간 수만큼 색상을 생성합니다. + * 구간이 하나면 시작색을, 둘 이상이면 첫 구간은 파란색·마지막 구간은 빨간색으로 고정합니다. + */ +function buildYearGroupColors(count: number): string[] { + if (count <= 0) return []; + if (count === 1) return [YEAR_GROUP_START_COLOR]; + + const parseHex = (color: string) => [ + Number.parseInt(color.slice(1, 3), 16), + Number.parseInt(color.slice(3, 5), 16), + Number.parseInt(color.slice(5, 7), 16), + ]; + const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0"); + const start = parseHex(YEAR_GROUP_START_COLOR); + const end = parseHex(YEAR_GROUP_END_COLOR); + + return Array.from({ length: count }, (_, index) => { + const ratio = index / (count - 1); + return `#${start.map((channel, channelIndex) => { + const endChannel = end[channelIndex] ?? channel; + return toHex(channel + (endChannel - channel) * ratio); + }).join("")}`; + }); +} + +/** + * 별도 정렬순서가 없는 MV 계약에서 구간명의 첫 숫자를 기준으로 표시순서를 결정합니다. + * 숫자가 없는 비정형 구간은 숫자 구간 뒤에 한글 오름차순으로 배치합니다. + */ +function compareYearGroups(left: string, right: string): number { + const leftNumber = left.match(/\d+/)?.[0]; + const rightNumber = right.match(/\d+/)?.[0]; + + if (leftNumber !== undefined && rightNumber !== undefined) { + const numberDifference = Number(leftNumber) - Number(rightNumber); + if (numberDifference !== 0) return numberDifference; + } else if (leftNumber !== undefined) { + return -1; + } else if (rightNumber !== undefined) { + return 1; + } + + return left.localeCompare(right, "ko"); +} + +/** + * MV 응답의 yearGroup 목록으로 노후관로 차트 데이터를 동적으로 생성합니다. + * 구간명이나 구간 개수가 바뀌어도 고정 매핑 없이 동일한 로직을 사용합니다. + */ +export function buildOldPplChartData(items: PplLengthContent[]): OldPplChartData { + const responseZones = [...new Set(items.map((item) => item.zoneNm).filter(Boolean))]; + const labels = [ + ...DEFAULT_BASIN_ORDER, + ...responseZones.filter((zone) => !DEFAULT_BASIN_ORDER.includes(zone)).sort((left, right) => left.localeCompare(right, "ko")), + ]; + const yearGroups = [ + ...new Set( + items + .map((item) => item.yearGroup?.trim()) + .filter((yearGroup): yearGroup is string => Boolean(yearGroup)), + ), + ].sort(compareYearGroups); + const colors = buildYearGroupColors(yearGroups.length); + + const lengthByZoneAndGroup = new Map(); + items.forEach((item) => { + const yearGroup = item.yearGroup?.trim(); + if (!item.zoneNm || !yearGroup) return; + lengthByZoneAndGroup.set(`${item.zoneNm}\u0000${yearGroup}`, Number(item.sumPrlgVl) || 0); + }); + + return { + labels, + datasets: yearGroups.map((yearGroup, index) => ({ + label: yearGroup, + data: labels.map((zone) => lengthByZoneAndGroup.get(`${zone}\u0000${yearGroup}`) ?? 0), + backgroundColor: colors[index] ?? YEAR_GROUP_START_COLOR, + borderRadius: 4, + })), + }; +} + /** * OldPplChartData를 지도 팝업 데이터 형태인 mapPplOldItem[]으로 변환합니다. * @param chartData 차트 데이터 @@ -96,22 +176,16 @@ export function transformChartDataToMapData(chartData: OldPplChartData): mapPplO const mapData: mapPplOldItem[] = []; const { labels, datasets } = chartData; - const pplLen2025Data = datasets.find(d => d.label === '20-25년')?.data || []; - const pplLen2530Data = datasets.find(d => d.label === '25-30년')?.data || []; - const pplLen30Data = datasets.find(d => d.label === '30년이상')?.data || []; - labels.forEach((label, index) => { - const pplLen2025 = pplLen2025Data[index] || 0; - const pplLen2530 = pplLen2530Data[index] || 0; - const pplLen30 = pplLen30Data[index] || 0; - const totalLen = pplLen2025 + pplLen2530 + pplLen30; + const totalLen = datasets.reduce((sum, dataset) => { + const value = dataset.data[index]; + return sum + (typeof value === "number" ? value : 0); + }, 0); mapData.push({ name: label, totalLen, - pplLen2025, - pplLen2530, - pplLen30, + stofcbrCount: 0, }); }); @@ -171,7 +245,7 @@ export class SewerageApiService implements SewerageService { })); return list; } - // 유역별 노후도별 관로 길이(km) MV조회 : yearGroup - 20년~25년미만,25년~30년미만,30년이상 + // 유역별 노후도별 관로 길이(km) MV조회: yearGroup은 MV가 제공하는 동적 구간명 public async getMvOldPplLength(): Promise { const response = await client.get<{ PplLengthList: PplLengthContent[]; @@ -184,6 +258,18 @@ export class SewerageApiService implements SewerageService { return list; } + // CM 대시보드 유역별 우수토실 개소수 조회 + public async getBasinStofcbrCount(): Promise { + const response = await client.get<{ + basinStofcbrCountList: BasinStofcbrCountContent[]; + }>(ENDPOINT.SEWERAGE_SERVICE.BASIN_STOFCBR_COUNT); + + return (response.data.basinStofcbrCountList ?? []).map((item) => ({ + ...item, + stofcbrCount: Number(item.stofcbrCount), + })); + } + } export const SewerageApi = new SewerageApiService(); diff --git a/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx b/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx index c4eedbd10..c6ec33559 100644 --- a/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx +++ b/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx @@ -12,7 +12,7 @@ import { svcInfo } from "../model/map-data"; import type { mapOperFcltType, mapReuseMapType} from "../model/fo-interface"; import type { mapPplOldType } from "../model/sn-interface"; import { useMapOperFcltStore, OperationApi, useMapReuseMapStore } from "../api/fo-service"; -import { useMapOldPplStore } from "../api/sn-service"; +import { SewerageApi, useMapOldPplStore } from "../api/sn-service"; import {Button} from "@common/components"; const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요 @@ -31,12 +31,12 @@ const mapOperFcltData: mapOperFcltType = { ] }; const mapPplOldData: mapPplOldType = { - colTitle: {totalLen: "총길이",pplLen2025: "20~25년",pplLen2530: "25~30년",pplLen30 : "30년 이상" }, //헤더 영역 + colTitle: {stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" }, data: [ - {name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30 : 2014}, - {name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30 : 10303}, - {name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30 : 9831}, - {name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30 : 2000}, + {name: "금강", totalLen: 0, stofcbrCount: 0}, + {name: "한강", totalLen: 0, stofcbrCount: 0}, + {name: "낙동강", totalLen: 0, stofcbrCount: 0}, + {name: "영산강", totalLen: 0, stofcbrCount: 0}, ] }; const mapReuseMapData: mapReuseMapType = { @@ -62,17 +62,18 @@ export function KoreaBasinMap() { const { selectedBasin, setSelectedBasin } = useStore(basinStore); const selectedBasinKey = basinKeyMap[selectedBasin]; const {mapOperFclt} = useMapOperFcltStore(); - const {mapData: mapPplOld} = useMapOldPplStore(); + const {mapData: mapPplOld, setStofcbrCounts} = useMapOldPplStore(); const {setMapData: setMapReuseMapData} = useMapReuseMapStore(); mapOperFcltData.data = mapOperFclt; mapPplOldData.data = mapPplOld; // mapReuseMapData.data = mapReuseMap; - let standardYear = '2024'; + const [standardYear, setStandardYear] = useState('2024'); useEffect(() => { const fetchData = async () => { try { - standardYear = await OperationApi.getStandardYear(); - const mapReuseMapRslt = await OperationApi.getReuseMap(standardYear); + const fetchedStandardYear = await OperationApi.getStandardYear(); + setStandardYear(fetchedStandardYear); + const mapReuseMapRslt = await OperationApi.getReuseMap(fetchedStandardYear); setMapReuseMapData(mapReuseMapRslt); mapReuseMapData.data = mapReuseMapRslt; } catch (e) { @@ -80,7 +81,13 @@ export function KoreaBasinMap() { } }; fetchData(); - }, []); + }, [setMapReuseMapData]); + + useEffect(() => { + SewerageApi.getBasinStofcbrCount() + .then(setStofcbrCounts) + .catch((e) => console.error("유역별 우수토실 개소수 조회 실패", e)); + }, [setStofcbrCounts]); // 유역정보 const showFor = useCallback((g: SVGGElement) => { const activeService = services.find(s => s.active); @@ -104,24 +111,16 @@ export function KoreaBasinMap() { }); } else if (activeService.svcId === 'sn') { // 하수관로 const item = mapPplOldData.data.find(d => d.name === basinName); - newPopupContent.title = `${standardYear}년도 ${basinName}유역 노후하수관로 현황`; + newPopupContent.title = `${standardYear}년도 ${basinName}유역 하수관로 현황`; const colTitles = mapPplOldData.colTitle; + newPopupContent.items.push({ + header: colTitles.stofcbrCount, + data: item ? `${fmt(item.stofcbrCount)}개소` : '-', + }); newPopupContent.items.push({ header: colTitles.totalLen, data: item ? `${fmt(item.totalLen)}km` : '-', }); - newPopupContent.items.push({ - header: colTitles.pplLen2025, - data: item ? `${fmt(item.pplLen2025)}km` : '-', - }); - newPopupContent.items.push({ - header: colTitles.pplLen2530, - data: item ? `${fmt(item.pplLen2530)}km` : '-', - }); - newPopupContent.items.push({ - header: colTitles.pplLen30, - data: item ? `${fmt(item.pplLen30)}km` : '-', - }); } else if (activeService.svcId === 'wr') { // 물재이용 const item = mapReuseMapData.data.find(d => d.name === basinName); newPopupContent.title = `${standardYear}년도 ${basinName}유역 물재이용 현황`; @@ -160,7 +159,7 @@ export function KoreaBasinMap() { ); popupRef.current.style.left = x + "px"; } - }, [services, mapOperFclt]); + }, [services, mapOperFclt, mapPplOld, standardYear]); // selectedBasinKey prop이 변경될 때 지도를 업데이트하는 useEffect useEffect(() => { @@ -410,8 +409,14 @@ export function KoreaBasinMap() { }; // 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요. return ( -
-
+
+

유역별 정보

diff --git a/apps/cm/src/pages/dashboard/model/sn-interface.ts b/apps/cm/src/pages/dashboard/model/sn-interface.ts index 4b86cd897..bf9a912ce 100644 --- a/apps/cm/src/pages/dashboard/model/sn-interface.ts +++ b/apps/cm/src/pages/dashboard/model/sn-interface.ts @@ -6,11 +6,17 @@ export interface PplLengthContent { yearGroup?: string; } +export interface BasinStofcbrCountContent { + zoneNm: string; + stofcbrCount: number; +} + export interface SewerageService { getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise; getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise; getMvUsgPplLength(): Promise; getMvOldPplLength(): Promise; + getBasinStofcbrCount(): Promise; } export interface ChartDataSet { @@ -37,17 +43,13 @@ type SnOldPplLength = { export type mapPplOldItem = { name: string; totalLen: number; - pplLen2025: number; - pplLen2530: number; - pplLen30: number; + stofcbrCount: number; }; export type mapPplOldType = { colTitle: { - totalLen: string; // 총길이 - pplLen2025: string; // 20~25년 - pplLen2530: string; // 25~30년 - pplLen30: string; // 30년 이상 + stofcbrCount: string; // 우수토실개소수 + totalLen: string; // 총연장 }; data: mapPplOldItem[]; }; @@ -82,4 +84,4 @@ export function buildSnOldPplLength( }); return result; -} \ No newline at end of file +} diff --git a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx index fe8a7f6c8..ed4095ea5 100644 --- a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx @@ -135,7 +135,7 @@ function DashboardPage() {
{/* 첫번째 라인 - 좌측 6개 패널(2x3) + 우측 지도/사용자 정보 */} -
+
{/* 좌측: 드래그 가능한 6개 패널 (2x3 그리드, 화면의 50%) */}
{isMounted ? ( @@ -173,9 +173,9 @@ function DashboardPage() {
{/* 우측: 고정된 지도와 사용자 정보 (화면의 50%) */} -
+
{/* 지도영역 - 고정 (드래그 불가) */} -
+
diff --git a/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx index fa502e7c5..07d333abb 100644 --- a/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx +++ b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx @@ -15,8 +15,8 @@ import { } from "chart.js"; import { Bar } from "react-chartjs-2"; import { Button } from "@common/components"; -import type { PplLengthContent, ChartDataSet, OldPplChartData } from "../model/sn-interface"; -import { SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service"; +import type { PplLengthContent } from "../model/sn-interface"; +import { buildOldPplChartData, SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service"; import { useDragHandle } from "./sortable-panel"; @@ -41,45 +41,7 @@ export default function SnOldPpl() { useEffect(() => { if (oldPplLenData.length === 0) return; - // 유역 목록 추출 (중복 제거) - const allZones = [...new Set(oldPplLenData.map(d => d.zoneNm))]; - - // yearGroup 매핑: API 응답값 -> 차트 label - const yearGroupMap: { [key: string]: { label: string; color: string } } = { - "20년~25년미만": { label: "20-25년", color: "#3B82F6" }, - "25년~30년미만": { label: "25-30년", color: "#EF4444" }, - "30년이상": { label: "30년이상", color: "#F87171" }, - }; - - // 유역별, 노후년도별 데이터 구조화 - const dataByZone: { [zone: string]: { [yearGroup: string]: number } } = {}; - allZones.forEach(zone => { - dataByZone[zone] = {}; - }); - - oldPplLenData.forEach(item => { - let zoneData = dataByZone[item.zoneNm]; - if (!zoneData) { - zoneData = {}; - dataByZone[item.zoneNm] = zoneData; - } - if (item.yearGroup) { - zoneData[item.yearGroup] = item.sumPrlgVl; - } - }); - - // 데이터셋 생성 - const newDatasets: ChartDataSet[] = Object.entries(yearGroupMap).map(([yearGroup, { label, color }]) => ({ - label, - data: allZones.map(zone => dataByZone[zone]?.[yearGroup] ?? 0), - backgroundColor: color, - borderRadius: 4, - })); - - const newChartData: OldPplChartData = { - labels: allZones, - datasets: newDatasets, - }; + const newChartData = buildOldPplChartData(oldPplLenData); setChartData(newChartData); @@ -110,9 +72,7 @@ export default function SnOldPpl() { y: { beginAtZero: true, min: 0, - max: 10000, ticks: { - stepSize: 2000, callback: function (value) { return Number(value).toLocaleString() + 'km'; } @@ -192,4 +152,4 @@ export default function SnOldPpl() {
); -} \ No newline at end of file +} diff --git a/apps/cm/src/shared/config/api.ts b/apps/cm/src/shared/config/api.ts index 26d5b8e95..f5ebb324f 100644 --- a/apps/cm/src/shared/config/api.ts +++ b/apps/cm/src/shared/config/api.ts @@ -80,6 +80,7 @@ export const ENDPOINT = { OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts", MV_USG_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvUsgPplLength", MV_OLD_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvOldPplLength", + BASIN_STOFCBR_COUNT: "/sewerage-service/api/v1/ms/basinStofcbrCount", }, OPERATION_SERVICE: { MAIN_YEAR: "/operation-service/api/v1/dashBoard/main/year", diff --git a/apps/sn/app/(map-layout)/map/components/aged-pipe/aged-pipe-layer.ts b/apps/sn/app/(map-layout)/map/components/aged-pipe/aged-pipe-layer.ts new file mode 100644 index 000000000..afdbadb9e --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/aged-pipe/aged-pipe-layer.ts @@ -0,0 +1,48 @@ +/** 노후관로의 지도와 목록이 사용하는 DB 레이어 설정 계약이다. */ +import { getRawJson } from "@/shared/api/sewerage-api"; +import { buildAgedPipeCqlFilters, isAgedPipeLayerNode, type AgedPipeFilters } from "@/entities/facility/lib/aged-pipe-utils"; + +/** DB 응답을 그대로 보존하며 최초 표출 전에 필터만 해석한다. */ +export const prepareAgedPipeLayerRows = (rows: any[], filters: AgedPipeFilters): any[] => + rows.map((row) => { + const next = { ...row }; + if (isAgedPipeLayerNode(row)) { + next.agedPipeFilterTemplate = row.flterSql; + try { + validateAgedPipeLayer(row); + next.flterSql = buildAgedPipeCqlFilters(filters, row.flterSql).join(" AND "); + } catch (error) { + // 노후관로 DB 미전환이 다른 시설물 레이어까지 중단시키지 않도록 이 행만 차단한다. + next.agedPipeConfigError = error instanceof Error ? error.message : "노후관로 레이어 설정 오류"; + next.actlLyrYn = "N"; + next.flterSql = "EXCLUDE"; + } + } + if (Array.isArray(row.children)) next.children = prepareAgedPipeLayerRows(row.children, filters); + return next; + }); + +/** 환경마다 달라질 수 있는 일련번호 대신 DB 업무명을 통해 설정 행을 찾는다. */ +export const findAgedPipeLayer = (rows: any[]): any | undefined => { + for (const row of rows) { + if (isAgedPipeLayerNode(row)) return row; + const child = Array.isArray(row.children) ? findAgedPipeLayer(row.children) : undefined; + if (child) return child; + } +}; + +/** 예전 제어용 행을 실제 WFS 레이어처럼 요청하지 않도록 전환 상태를 확인한다. */ +export const validateAgedPipeLayer = (row: any) => { + if (row?.agedPipeConfigError) throw new Error(row.agedPipeConfigError); + if (!row || row.actlLyrYn !== "Y" || row.srvcNm !== "wfs" || !row.lyrTyNm || row.lyrTyNm === "tb_old_pipe") { + throw new Error("노후관로 DB 레이어 설정을 실제 WFS 레이어와 sql_filt 템플릿으로 갱신해 주세요."); + } +}; + +/** 독립 목록 팝업도 부모창의 하드코딩 없이 동일 설정 API를 조회한다. */ +export const fetchAgedPipeLayer = async () => { + const rows = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK"); + const row = findAgedPipeLayer(Array.isArray(rows) ? rows : []); + validateAgedPipeLayer(row); + return row; +}; diff --git a/apps/sn/app/(map-layout)/map/components/aged-pipe/aged-pipe-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/aged-pipe/aged-pipe-popup-page.tsx index 5d45a6004..b9a31efe5 100644 --- a/apps/sn/app/(map-layout)/map/components/aged-pipe/aged-pipe-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/aged-pipe/aged-pipe-popup-page.tsx @@ -15,7 +15,6 @@ import useStore from "@/entities/reference-data/model/reference-data-store"; import LoadingOverlay from "@/components/ui/loading-bar"; import { AGED_PIPE_DEFAULT_FILTERS, - AGED_PIPE_LAYER_TYPE, AGED_PIPE_LIST_HEADLIST, AGED_PIPE_PAGE_SIZE, buildAgedPipeCqlFilters, @@ -27,6 +26,7 @@ import { type AgedPipeRow, type AgedPipeManagementAgency, } from "@/entities/facility/lib/aged-pipe-utils"; +import { fetchAgedPipeLayer } from "./aged-pipe-layer"; import { downloadAgedPipeExcel } from "./aged-pipe-export"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; @@ -109,12 +109,14 @@ export default function AgedPipePopupPage() { const fetchRows = useCallback( async (page: number, nextFilters: AgedPipeFilters, size: number) => { const normalized = normalizeAgedPipeFilters(nextFilters); + // 독립 팝업에서도 현재 DB의 원천 레이어와 필터 템플릿을 사용한다. + const layer = await fetchAgedPipeLayer(); const queryParams = new URLSearchParams({ - lyrTypeNm: AGED_PIPE_LAYER_TYPE, + lyrTypeNm: layer.lyrTyNm, page: String(page - 1), size: String(size), }); - const cqlFilters = buildAgedPipeCqlFilters(normalized); + const cqlFilters = buildAgedPipeCqlFilters(normalized, layer.flterSql); if (cqlFilters.length > 0) { queryParams.append("cqlFiter", cqlFilters.join(" and ")); } @@ -148,13 +150,14 @@ export default function AgedPipePopupPage() { setAppliedFilters(normalizeAgedPipeFilters(nextFilters)); } catch (error) { console.error("노후관로 목록 조회 중 오류:", error); + alert(error instanceof Error ? error.message : "노후관로 목록 조회 중 오류가 발생했습니다."); setRows([]); setTotal(0); } finally { setIsLoading(false); } }, - [fetchRows, filters], + [alert, fetchRows, filters], ); useEffect(() => { diff --git a/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx index aecff3ba5..c6b50038d 100644 --- a/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx @@ -397,8 +397,8 @@ export default function PipeAnalysisPopupPage() { })) } > - - {ctpv.map((item) => ( + {/* 시도는 전체 조회 없이 개별 지역만 선택한다. */} + {ctpv.filter((item) => String(item.val).trim() !== "0").map((item) => ( diff --git a/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-utils.ts b/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-utils.ts index 5bca34aca..7088ff9bd 100644 --- a/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-utils.ts +++ b/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-utils.ts @@ -140,7 +140,8 @@ export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{ export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters = { analysisType: "YEAR", - ctpvCd: "0", + // 관로분석 최초 조회·초기화의 기본 시도는 부산광역시다. 계정 지역 제한은 별도 적용한다. + ctpvCd: "26", sggCd: "0", swtrmtZoneNm: "0", scopeRegionCode: "", diff --git a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx index eff9a15a9..ed46e94ce 100644 --- a/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx +++ b/apps/sn/app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx @@ -150,7 +150,7 @@ const formatAnalysisBaseAt = (value?: string | null) => { return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim(); }; -/** 개발 DB의 과거 시험자료와 운영 최신자료를 함께 찾도록 최근 1년을 기본 조회기간으로 사용한다. */ +/** 현재 위험 조회와 검색 초기화에 사용하는 최근 1년의 기본 조회조건이다. */ const buildDefaultResultFilters = (): ResultFilters => { const endDate = new Date(); const startDate = new Date(endDate); @@ -191,7 +191,12 @@ const buildResultRowKey = (row: WaterRiskResultRow) => /** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { - const initialFilters = useMemo(() => buildDefaultResultFilters(), []); + // 팝업 최초 진입에만 지정 기간을 사용하고 초기화는 기본 조회조건을 새로 만든다. + const initialFilters = useMemo(() => ({ + ...buildDefaultResultFilters(), + startDate: "2026-08-15", + endDate: "2026-08-17", + }), []); const [resultMode, setResultMode] = useState("current"); const [draftResultFilters, setDraftResultFilters] = useState(initialFilters); @@ -234,9 +239,13 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { let active = true; setIsLoading(true); setErrorMessage(""); + // 과거 이력의 고정 기본기간이 현재 진행 중인 강우 조회를 제한하지 않게 분리한다. + const dateFilters = resultMode === "current" + ? buildDefaultResultFilters() + : appliedResultFilters; fetchWaterRiskResults({ - startDate: appliedResultFilters.startDate, - endDate: appliedResultFilters.endDate, + startDate: dateFilters.startDate, + endDate: dateFilters.endDate, analysisBaseAt: resultMode === "current" ? currentAnalysisBaseAt : undefined, ctpvCd: @@ -659,8 +668,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { type="button" color="white" onClick={() => { - setDraftResultFilters(initialFilters); - setAppliedResultFilters(initialFilters); + // 초기화 시점의 기본기간·전체 조건으로 첫 페이지를 다시 조회한다. + const defaultFilters = buildDefaultResultFilters(); + setDraftResultFilters(defaultFilters); + setAppliedResultFilters(defaultFilters); setResultCurrentPage(1); setSelectedResultKey(""); }} diff --git a/apps/sn/app/(map-layout)/map/lib/layer-tree.ts b/apps/sn/app/(map-layout)/map/lib/layer-tree.ts index 319441054..d734b6c08 100644 --- a/apps/sn/app/(map-layout)/map/lib/layer-tree.ts +++ b/apps/sn/app/(map-layout)/map/lib/layer-tree.ts @@ -1,64 +1,5 @@ -/** 레이어 트리 구성과 관종별 유하방향 표시 계산. 기존 값과 처리 순서를 유지한다. */ -import { AGED_PIPE_CONTROL_NODE_ID, AGED_PIPE_HIGHLIGHT_COLOR, SINKHOLE_TREE_ICON_NAME } from "../model/map-constants"; - - - -export const appendAgedPipeControlNode = (data: any[]) => { - if (!Array.isArray(data)) return data; - const isAgedPipeControlNode = (item: any) => - String(item.lyrSn ?? item.id) === AGED_PIPE_CONTROL_NODE_ID || - item.lyrNm === "노후관로"; - - if (data.some(isAgedPipeControlNode)) { - return data.map((item) => - isAgedPipeControlNode(item) - ? { - ...item, - gobjTy: "line", - iconNm: null, - lnColor: AGED_PIPE_HIGHLIGHT_COLOR, - } - : item, - ); - } - - return [ - ...data, - { - lyrSn: AGED_PIPE_CONTROL_NODE_ID, - id: AGED_PIPE_CONTROL_NODE_ID, - lyrNm: "노후관로", - upperLyrSn: null, - upId: null, - sortOrdr: "4", - drawOrdr: null, - flterSql: null, - gobjTy: "line", - lyrTyNm: "tb_old_pipe", - useYn: "Y", - minViewSize: null, - iconNm: null, - iconSize: null, - lblAtrbNm: null, - fontNm: null, - fontIntnColor: null, - fontExtnColor: null, - fontExtnWdth: null, - lnWdth: null, - lnColor: AGED_PIPE_HIGHLIGHT_COLOR, - polgnColor: null, - initEprssYn: "Y", - clickPsblYn: "N", - exprsYn: "Y", - cmncNm: "all", - srvcNm: "wfs", - actlLyrYn: "N", - clckZoom: null, - children: null, - }, - ]; -}; +import { SINKHOLE_TREE_ICON_NAME } from "../model/map-constants"; export const normalizeTreeRowSpacing = (data: any[]) => { if (!Array.isArray(data)) return data; diff --git a/apps/sn/app/(map-layout)/map/lib/use-flood-prediction-grid-overlay.ts b/apps/sn/app/(map-layout)/map/lib/use-flood-prediction-grid-overlay.ts deleted file mode 100644 index 6a13142ab..000000000 --- a/apps/sn/app/(map-layout)/map/lib/use-flood-prediction-grid-overlay.ts +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; -import type * as React from "react"; -import { useCallback, useRef, useEffect } from "react"; -import Overlay from "ol/Overlay.js"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import { getRawJson } from "@/shared/api/sewerage-api"; -import { getFloodRiskGradeClassName } from "@/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content"; -import { FLOOD_PREDICTION_GRID_LAYER_ID } from "../model/map-constants"; -import { formatFloodPredictionDateTime } from "./map-values"; - -/** 침수예측 선택행의 격자 중앙 정보 오버레이를 소유한다. */ -type Options = { - gisMaps: React.RefObject; -}; - -/** 침수예측 선택행의 격자 중앙 정보 오버레이를 소유한다. */ -export function useFloodPredictionGridOverlay({ gisMaps }: Options) { - // 침수예측현황 선택행에 따라 초단기예보 격자 중앙 라벨을 교체하는 전용 overlay를 보관한다. - const floodPredictionGridLayerRef = useRef(null); - const requestSequence = useRef(0); - - /** 기존 침수예측 중앙 오버레이를 제거한다. */ - const removeFloodPredictionGridLayer = useCallback(() => { - requestSequence.current += 1; - const core = GisApp.Module.core as any; - core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID); - const map = gisMaps.current; - if (map && floodPredictionGridLayerRef.current) { - map.removeOverlay(floodPredictionGridLayerRef.current); - } - floodPredictionGridLayerRef.current = null; - }, [gisMaps]); - - /** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */ - const loadFloodPredictionGridLayer = useCallback( - async (sggCd: string, analysisDateTime: string, extent: number[]) => { - const map = gisMaps.current; - if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime)) - return; - removeFloodPredictionGridLayer(); - const sequence = requestSequence.current; - try { - const response = await getRawJson( - `/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`, - ); - if (sequence !== requestSequence.current) return; - const payload = response?.data ?? response; - const row = payload && !Array.isArray(payload) ? payload : null; - if ( - !row || - extent.length !== 4 || - extent.some((value) => !Number.isFinite(value)) - ) - return; - const center = [ - (extent[0]! + extent[2]!) / 2, - (extent[1]! + extent[3]!) / 2, - ]; - const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed( - 1, - ); - const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed( - 1, - ); - const container = document.createElement("div"); - container.style.cssText = - "position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; min-width:230px;"; - const close = document.createElement("button"); - close.type = "button"; - close.textContent = "×"; - close.title = "침수예측 오버레이 닫기"; - close.style.cssText = - "position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;"; - close.addEventListener("click", () => removeFloodPredictionGridLayer()); - const table = document.createElement("table"); - table.style.cssText = "border-collapse:collapse; width:100%;"; - const title = table.createTHead().insertRow(); - const titleCell = title.insertCell(); - titleCell.colSpan = 2; - titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`; - titleCell.style.cssText = - "font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;"; - const body = table.createTBody(); - [ - [ - "분석시각", - formatFloodPredictionDateTime( - row.analysisDateTime ?? analysisDateTime, - ), - ], - [ - "강우시작시각", - formatFloodPredictionDateTime(row.rainBeginningDateTime), - ], - ["관측 누적강수량(mm)", observed], - ["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)], - ["예측 누적강수량(mm)", predicted], - ["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`], - ["침수위험도", row.floodRiskGrade ?? "-"], - ].forEach(([label, value]) => { - const tr = body.insertRow(); - const key = tr.insertCell(); - key.textContent = label; - key.style.cssText = - "font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; - const val = tr.insertCell(); - val.style.cssText = - "font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; - if (label === "침수위험도") { - // 침수예측현황 목록과 같은 배지 스타일로 등급을 표시한다. - const badge = document.createElement("span"); - badge.textContent = value; - badge.className = `sn-popup-badge min-w-[44px] ${getFloodRiskGradeClassName(String(value ?? ""))}`; - val.append(badge); - } else { - val.textContent = value; - } - }); - container.append(close, table); - const overlay = new Overlay({ - element: container, - positioning: "center-center", - stopEvent: true, - }); - overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID); - overlay.setPosition(center); - map.addOverlay(overlay); - floodPredictionGridLayerRef.current = overlay; - } catch (error) { - if (sequence !== requestSequence.current) return; - console.error("침수예측지도 오버레이 표시 실패:", error); - } - }, - [gisMaps, removeFloodPredictionGridLayer], - ); - useEffect( - () => () => { - requestSequence.current += 1; - if (floodPredictionGridLayerRef.current) - gisMaps.current?.removeOverlay?.(floodPredictionGridLayerRef.current); - floodPredictionGridLayerRef.current = null; - }, - [gisMaps], - ); - return { loadFloodPredictionGridLayer }; -} diff --git a/apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts b/apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts index ed86ea654..980074046 100644 --- a/apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts +++ b/apps/sn/app/(map-layout)/map/lib/use-map-runtime-bridge.ts @@ -1,24 +1,14 @@ "use client"; - -import { - useEffect, - type MutableRefObject, - type Dispatch, - type SetStateAction, -} from "react"; +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"; -import { FLOOD_DNGR_MAP_LAYER_TYPE } from "../components/flood-dngr-map/flood-dngr-map-types"; type MapRuntimeBridgeOptions = { /** 공통 Gis가 생성한 지도 참조. 생성/파괴 소유권은 Gis에 유지한다. */ gisMaps: MutableRefObject; setMapReady: Dispatch>; - loadFloodPredictionGridLayer: ( - sggCd: string, - analysisDateTime: string, - extent: number[], - ) => Promise; + handleFloodPredictionPopupSelection: ReturnType["handleFloodPredictionPopupSelection"]; }; /** 지도 준비 감지와 정보검색/GIS 업로드 메시지 연결을 소유한다. @@ -28,7 +18,7 @@ type MapRuntimeBridgeOptions = { export function useMapRuntimeBridge({ gisMaps, setMapReady, - loadFloodPredictionGridLayer, + handleFloodPredictionPopupSelection, }: MapRuntimeBridgeOptions) { useEffect(() => { const timer = window.setInterval(() => { @@ -91,83 +81,10 @@ export function useMapRuntimeBridge({ return; } - if ( - data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP - ) { - const extent = Array.isArray(data.payload?.extent) - ? data.payload.extent.map(Number) - : []; - const sggCd = String(data.payload?.sggCd ?? "").trim(); - const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim(); - const analysisDateTime = String( - data.payload?.analysisDateTime ?? "", - ).trim(); - const map = gisMaps.current; - const select = GisApp.Module.select as any; - if ( - extent.length !== 4 || - extent.some((value: number) => !Number.isFinite(value)) || - !/^\d{5}$/.test(sggCd) || - !/^(030|050|080|100)$/.test(frequency) || - !map?.getView || - !select?.selectFocusedBoundaryFeature - ) { - return; - } - - await select.resetSelectedFeature?.(); - const selectedFeatureIds = new Set(); - let floodLayerVisible = false; - const selectLoadedGroupFeatures = (source: any) => { - const features = source?.getFeatures?.() ?? []; - features.forEach((feature: any) => { - const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim(); - const featureFrequency = String( - feature.get?.("sbmrg_frq_expln") ?? "", - ).trim(); - if (featureSggCd !== sggCd || featureFrequency !== frequency) - return; - const featureId = String( - feature.getId?.() ?? feature.get?.("fid") ?? "", - ); - if (featureId && selectedFeatureIds.has(featureId)) return; - if (featureId) selectedFeatureIds.add(featureId); - select.selectFocusedBoundaryFeature(feature, true); - }); - }; - - map - .getLayers() - .getArray() - .forEach((layer: any) => { - if ( - String(layer.get?.("lyrTyNm") ?? "").trim() !== - FLOOD_DNGR_MAP_LAYER_TYPE - ) - return; - if (layer.getVisible?.() !== false) floodLayerVisible = true; - const source = layer.getSource?.(); - if (!source?.getFeatures) return; - selectLoadedGroupFeatures(source); - source.once?.("featuresloadend", () => - selectLoadedGroupFeatures(source), - ); - }); - if (!floodLayerVisible) { - (event.source as Window | null)?.postMessage?.( - { type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED }, - event.origin, - ); - return; - } - map.getView().fit(extent, { - duration: 300, - padding: [50, 50, 50, 50], - maxZoom: 16, - }); - void loadFloodPredictionGridLayer(sggCd, analysisDateTime, extent); - 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; @@ -204,5 +121,5 @@ export function useMapRuntimeBridge({ window.clearInterval(timer); window.removeEventListener("message", handlePopupMessage); }; - }, [gisMaps, setMapReady, loadFloodPredictionGridLayer]); + }, [gisMaps, setMapReady, handleFloodPredictionPopupSelection]); } diff --git a/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx b/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx index d38b61007..920574774 100644 --- a/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx +++ b/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx @@ -16,8 +16,10 @@ */ "use client"; +import { useFloodPredictionMap } from "@/features/fldng-predc-prst/lib/use-flood-prediction-map"; +import { FLOOD_DNGR_MAP_LAYER_NAME, FLOOD_DNGR_MAP_LAYER_TYPE } from "../components/flood-dngr-map/flood-dngr-map-types"; +import { findAgedPipeLayer, prepareAgedPipeLayerRows, validateAgedPipeLayer } from "../components/aged-pipe/aged-pipe-layer"; import { useFloodHistoryClick } from "../lib/use-flood-history-click"; -import { useFloodPredictionGridOverlay } from "../lib/use-flood-prediction-grid-overlay"; import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; import { useFloodHistoryVisibility } from "../lib/use-flood-history-visibility"; import { useFloodHistorySelection } from "../lib/use-flood-history-selection"; @@ -33,40 +35,14 @@ import { useWaterRiskLayer } from "../lib/use-water-risk-layer"; import { useFlowDirectionLayer } from "@/features/map-layers/lib/use-flow-direction-layer"; import dynamic from "next/dynamic"; import { type VertexHandleMeta } from "@/features/gis-edit/model/vertex-handles"; -import { - type RightPanelTab, - findRightPanelTabOverflowCandidate, - removeRightPanelTabGroup, - RightPanelBookmarkTabs, -} from "../components/right-panel/right-panel-bookmark-tabs"; -import { - FLOW_DIRECTION_DEFAULT_MIN_ZOOM, - getFlowDirectionModeByPathname, - SWAGE_PRCS_FCLT_LAYER_TYPE, - type SwagePrcsFcltLayerMode, - getSwagePrcsFcltLayerMode, - normalizeFlowDirectionLayerInfos, - normalizeRainObservationLayerInteraction, - getFlowDirectionMinZoom, -} from "@/features/map-layers"; +import { type RightPanelTab, findRightPanelTabOverflowCandidate, removeRightPanelTabGroup, RightPanelBookmarkTabs } from "../components/right-panel/right-panel-bookmark-tabs"; +import { FLOW_DIRECTION_DEFAULT_MIN_ZOOM, getFlowDirectionModeByPathname, SWAGE_PRCS_FCLT_LAYER_TYPE, type SwagePrcsFcltLayerMode, getSwagePrcsFcltLayerMode, normalizeFlowDirectionLayerInfos, normalizeRainObservationLayerInteraction, getFlowDirectionMinZoom } from "@/features/map-layers"; import { ListTree } from "lucide-react"; -import { - Fragment, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, - type UIEvent, -} from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } from "react"; import { usePathname, useSearchParams } from "next/navigation"; import GeoJSON from "ol/format/GeoJSON.js"; import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; -import { - isWindowPopupKey, - type WindowPopupKey, -} from "@/features/window-popup/model/popup-definitions"; +import { isWindowPopupKey, type WindowPopupKey } from "@/features/window-popup/model/popup-definitions"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import Gis from "@common/business_components/ui/map/Gis"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; @@ -82,28 +58,9 @@ import GisEditSessionPanel from "../components/gis-edit/gis-edit-session-panel"; import GisEditToolbar from "../components/gis-edit/gis-edit-toolbar"; import GisEditPipeSessionPopup from "../components/gis-edit/gis-edit-pipe-session-popup"; import { useGisEdit } from "../components/gis-edit/use-gis-edit"; -import type { - GisEditPipeBasicInfo, - GisEditSessionSnapshot, -} from "../components/gis-edit/gis-edit-types"; -import { - PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, - type PipeAnalysisDetailRow, -} from "../components/pipe-analysis/pipe-analysis-utils"; -import { - AGED_PIPE_DEFAULT_FILTERS, - AGED_PIPE_GUIDE_MIN_ZOOM, - AGED_PIPE_MIN_ZOOM, - AGED_PIPE_LAYER_TYPE, - AGED_PIPE_PAGE_SIZE, - type AgedPipeFilters, - type AgedPipeRow, - buildAgedPipeCqlFilters, - isAgedPipeLayerNode, - normalizeAgedPipeFilters, - toDisplayPipeRow, - validateElapsedYears, -} from "@/entities/facility/lib/aged-pipe-utils"; +import type { GisEditPipeBasicInfo, GisEditSessionSnapshot } from "../components/gis-edit/gis-edit-types"; +import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, type PipeAnalysisDetailRow } from "../components/pipe-analysis/pipe-analysis-utils"; +import { AGED_PIPE_DEFAULT_FILTERS, AGED_PIPE_GUIDE_MIN_ZOOM, AGED_PIPE_LAYER_TYPE, AGED_PIPE_PAGE_SIZE, type AgedPipeFilters, type AgedPipeRow, buildAgedPipeCqlFilters, isAgedPipeLayerNode, normalizeAgedPipeFilters, toDisplayPipeRow, validateElapsedYears } from "@/entities/facility/lib/aged-pipe-utils"; import { downloadAgedPipeExcel } from "../components/aged-pipe/aged-pipe-export"; import { useInftwtResultMap } from "../components/inftwt-result/use-inftwt-result-map"; import { useRdiiResultMap } from "../components/rdii-result/use-rdii-result-map"; @@ -113,110 +70,27 @@ import FldngHstryDetailPanel from "../components/fldng-hstry/fldng-hstry-detail- import FldngHstryLayerSettingPanel from "../components/fldng-hstry/fldng-hstry-layer-setting-panel"; import ExmnLayerSettingPanel from "../components/exmn/exmn-layer-setting-panel"; import LayerSettingCard from "../components/layer-setting/layer-setting-card"; -import { - EMPTY_EXMN_MAP_FILTERS, - appendExmnResultControlNodes, - buildExmnMapEntryContext, - isExmnResultMnhlNode, - isExmnResultPplNode, - type ExmnMapFilters, -} from "../components/exmn/exmn-map-types"; -import { - buildSinkholeDefaultYearRange, - buildSinkholeFiltersFromYearRange, - buildSinkholeScope, - isSinkholeLayerNode, - isSinkholeRootNode, - normalizeSinkholeFilters, - normalizeSinkholeYearRange, - SINKHOLE_DEFAULT_RADIUS, - SINKHOLE_FILTER_SYNC_MESSAGE, - SINKHOLE_LAYER_TYPE, - SINKHOLE_POPUP_KEY, - SINKHOLE_ROW_SELECT_MESSAGE, - SINKHOLE_SYNC_COMPLETED_MESSAGE, - type SinkholeDetail, - type SinkholeFilters, - type SinkholeListItem, - type SinkholeNearbyPipe, - type SinkholeSyncSummary, - type SinkholeYearRange, -} from "../components/sinkhole/sinkhole-types"; -import { - FLDNG_HSTRY_LAYER_TYPE, - FLDNG_HSTRY_POPUP_KEY, - FLDNG_HSTRY_ROW_SELECT_MESSAGE, - type FldngHstryYearRange, - type FldngHstryItem, -} from "@/entities/flood-history/model/flood-history-types"; -import { - buildFldngHstryDefaultYearRange, - isFldngHstryDepthNode, - isFldngHstryWtlvNode, - isFldngHstryRootNode, -} from "@/entities/flood-history/lib/flood-history-utils"; -import { - SBMRG_DNGR_MAP_POPUP_KEY, - isSbmrgDngrMapRootNode, -} from "../components/sbmrg-dngr-map/sbmrg-dngr-map-types"; -import { - FLOOD_DNGR_MAP_POPUP_KEY, - isFloodDngrMapRootNode, -} from "../components/flood-dngr-map/flood-dngr-map-types"; +import { EMPTY_EXMN_MAP_FILTERS, appendExmnResultControlNodes, buildExmnMapEntryContext, isExmnResultMnhlNode, isExmnResultPplNode, type ExmnMapFilters } from "../components/exmn/exmn-map-types"; +import { buildSinkholeDefaultYearRange, buildSinkholeFiltersFromYearRange, buildSinkholeScope, isSinkholeLayerNode, isSinkholeRootNode, normalizeSinkholeFilters, normalizeSinkholeYearRange, SINKHOLE_DEFAULT_RADIUS, SINKHOLE_FILTER_SYNC_MESSAGE, SINKHOLE_LAYER_TYPE, SINKHOLE_POPUP_KEY, SINKHOLE_ROW_SELECT_MESSAGE, SINKHOLE_SYNC_COMPLETED_MESSAGE, type SinkholeDetail, type SinkholeFilters, type SinkholeListItem, type SinkholeNearbyPipe, type SinkholeSyncSummary, type SinkholeYearRange } from "../components/sinkhole/sinkhole-types"; +import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_POPUP_KEY, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryYearRange, type FldngHstryItem } from "@/entities/flood-history/model/flood-history-types"; +import { buildFldngHstryDefaultYearRange, isFldngHstryDepthNode, isFldngHstryWtlvNode, isFldngHstryRootNode } from "@/entities/flood-history/lib/flood-history-utils"; +import { SBMRG_DNGR_MAP_POPUP_KEY, isSbmrgDngrMapRootNode } from "../components/sbmrg-dngr-map/sbmrg-dngr-map-types"; +import { FLOOD_DNGR_MAP_POPUP_KEY, isFloodDngrMapRootNode } from "../components/flood-dngr-map/flood-dngr-map-types"; import FeatureClickEventPopup from "@/features/facility-detail/ui/feature-click-event-popup"; import SearchModal from "../search-modal/search-modal"; -import { - WATER_DEPTH_LAYER_TYPES, - extractWaterDepthLegendItems, -} from "../water-depth/water-depth"; +import { WATER_DEPTH_LAYER_TYPES, extractWaterDepthLegendItems } from "../water-depth/water-depth"; import { buildWaterRiskStatusLabel } from "../components/water-risk/water-risk-types"; const AnalysisPopupMenu = dynamic( () => import("../components/analysis-menu/analysis-popup-menu"), { ssr: false }, ); -import { - AGED_PIPE_HIGHLIGHT_LAYER_ID, - AGED_PIPE_HIGHLIGHT_COLOR, - AGED_PIPE_ROW_SELECT_MESSAGE, - AGED_PIPE_POPUP_MINIMIZE_MESSAGE, - AGED_PIPE_FILTER_SYNC_MESSAGE, - EXMN_PROC_SEARCH_EVENT, - SOUTH_KOREA_VIEW_EXTENT, - FLOW_DIRECTION_SRS, - SHORT_TERM_FORECAST_GRID_LAYER_NAME, - RIGHT_PANEL_TAB_LIMIT, - RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID, - AGED_PIPE_FOCUS_PADDING, - DEFAULT_FEATURE_FIT_PADDING, -} from "../model/map-constants"; +import { AGED_PIPE_ROW_SELECT_MESSAGE, AGED_PIPE_POPUP_MINIMIZE_MESSAGE, AGED_PIPE_FILTER_SYNC_MESSAGE, EXMN_PROC_SEARCH_EVENT, SOUTH_KOREA_VIEW_EXTENT, FLOW_DIRECTION_SRS, SHORT_TERM_FORECAST_GRID_LAYER_NAME, RIGHT_PANEL_TAB_LIMIT, RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID, AGED_PIPE_FOCUS_PADDING, DEFAULT_FEATURE_FIT_PADDING } from "../model/map-constants"; import { getFeatureCollection } from "../lib/map-values"; -import { - getSinkholeAccidentId, - getSinkholeFallbackDetailFromSelection, - getSinkholePointFromSelection, - createSinkholeFocusedPipeStyle, - appendSinkholeControlNode, -} from "../lib/sinkhole-features"; -import { - getGisEditFeatureFid, - isGisEditPreviewFeatureData, - highlightGisEditSelectedFeature, - isVisibleGisEditSessionChange, - getLatestNumericEditSn, - findInvalidPipeRequiredChange, -} from "../lib/gis-edit-features"; -import { - getSelectedFeatureLayerTypeName, - getRightPanelFeatureIdentifier, - getRightPanelFeatureProperty, - getRightPanelTabCategory, - getRightPanelFeatureDisplayLabel, -} from "../lib/right-panel-features"; -import { - appendAgedPipeControlNode, - normalizeTreeRowSpacing, -} from "../lib/layer-tree"; +import { getSinkholeAccidentId, getSinkholeFallbackDetailFromSelection, getSinkholePointFromSelection, createSinkholeFocusedPipeStyle, appendSinkholeControlNode } from "../lib/sinkhole-features"; +import { getGisEditFeatureFid, isGisEditPreviewFeatureData, highlightGisEditSelectedFeature, isVisibleGisEditSessionChange, getLatestNumericEditSn, findInvalidPipeRequiredChange } from "../lib/gis-edit-features"; +import { getSelectedFeatureLayerTypeName, getRightPanelFeatureIdentifier, getRightPanelFeatureProperty, getRightPanelTabCategory, getRightPanelFeatureDisplayLabel } from "../lib/right-panel-features"; +import { normalizeTreeRowSpacing } from "../lib/layer-tree"; import { useMapRuntimeBridge } from "../lib/use-map-runtime-bridge"; import MapControlStyles from "./map-control-styles"; @@ -252,6 +126,17 @@ const MapPageClient = () => { } | null>(null); const [layerLoadError, setLayerLoadError] = useState(null); const gisMaps = useRef(null); +const requestFloodPredictionLayer = useCallback(() => { + setCheckedLayerRequest((current) => ({ + requestKey: (current?.requestKey ?? 0) + 1, + layerNames: [FLOOD_DNGR_MAP_LAYER_NAME], + })); + }, []); +const { handleFloodPredictionPopupSelection } = useFloodPredictionMap({ + mapRef: gisMaps, + floodLayerType: FLOOD_DNGR_MAP_LAYER_TYPE, + onRequestFloodLayer: requestFloodPredictionLayer, + }); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 // 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다. @@ -486,9 +371,7 @@ const MapPageClient = () => { useGisEditPipeSnap({ gisEdit, gisMaps, mapReady }); useGisEditOriginalMask({ gisMaps, gisEdit, mapReady }); - const { loadFloodPredictionGridLayer } = useFloodPredictionGridOverlay({ - gisMaps, - }); + const gisEditVisibleSessionChanges = useMemo( () => gisEdit.changes.filter(isVisibleGisEditSessionChange), @@ -748,7 +631,7 @@ const MapPageClient = () => { sinkholeYearRange, ]); - useMapRuntimeBridge({ gisMaps, setMapReady, loadFloodPredictionGridLayer }); + useMapRuntimeBridge({ gisMaps, setMapReady, handleFloodPredictionPopupSelection }); useEffect(() => { if (shouldShowGisEditSession) return; @@ -870,14 +753,16 @@ const MapPageClient = () => { const data = await getRawJson( "/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", ); + // DB 필터 치환 실패는 노후관로에만 한정하고 다른 시설물 레이어는 유지한다. + const preparedData = prepareAgedPipeLayerRows(data, appliedAgedPipeFilters); + const agedPipeConfigError = findAgedPipeLayer(preparedData)?.agedPipeConfigError; + if (agedPipeConfigError) alert(agedPipeConfigError); setTreeData( appendExmnResultControlNodes( - appendAgedPipeControlNode( - appendSinkholeControlNode( - normalizeTreeRowSpacing( - normalizeRainObservationLayerInteraction( - normalizeFlowDirectionLayerInfos(data), - ), + appendSinkholeControlNode( + normalizeTreeRowSpacing( + normalizeRainObservationLayerInteraction( + normalizeFlowDirectionLayerInfos(preparedData), ), ), ), @@ -1145,14 +1030,16 @@ const MapPageClient = () => { const fetchAgedPipeRows = useCallback( async (page: number, filters: AgedPipeFilters, size: number) => { const normalized = normalizeAgedPipeFilters(filters); + const layer = findAgedPipeLayer(treeData); + validateAgedPipeLayer(layer); const queryParams = new URLSearchParams({ - lyrTypeNm: AGED_PIPE_LAYER_TYPE, + lyrTypeNm: layer.lyrTyNm, page: String(page - 1), size: String(size), sort: "string", }); - const cqlFilters = buildAgedPipeCqlFilters(normalized); + const cqlFilters = buildAgedPipeCqlFilters(normalized, layer.agedPipeFilterTemplate ?? layer.flterSql); if (cqlFilters.length > 0) { queryParams.append("cqlFiter", cqlFilters.join(" and ")); } @@ -1167,7 +1054,7 @@ const MapPageClient = () => { total: Number(response?.count ?? 0), }; }, - [ctpv, sgg, facilityCodes], + [ctpv, sgg, treeData, facilityCodes], ); const loadAgedPipeList = useCallback( @@ -2658,52 +2545,36 @@ const MapPageClient = () => { useEffect(() => { if (!mapReady || !gisMaps.current) return; - + const row = findAgedPipeLayer(treeData); + if (!row) return; const core = GisApp.Module.core as any; - core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID); - - if (!isAgedPipeChecked || !isAgedPipeSettingEnabled) return; - - const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find( - (item: any) => - item?.lyrTyNm === AGED_PIPE_LAYER_TYPE && item?.actlLyrYn === "Y", - ) as any; - - if (!baseLayerCode) return; - - const layerCode = { - ...baseLayerCode, - id: AGED_PIPE_HIGHLIGHT_LAYER_ID, - lyrSn: AGED_PIPE_HIGHLIGHT_LAYER_ID, - lyrNm: "노후관로", - lyrTyNm: AGED_PIPE_LAYER_TYPE, - gobjTy: "line", - actlLyrYn: "Y", - exprsYn: "Y", - clickPsblYn: "Y", - cmncNm: "bbox", - srvcNm: "wfs", - flterSql: buildAgedPipeCqlFilters(appliedAgedPipeFilters).join(" and "), - lnWdth: "4", - lnColor: AGED_PIPE_HIGHLIGHT_COLOR, - drawOrdr: "9999", - minViewSize: String(AGED_PIPE_MIN_ZOOM), - }; - - const layer = core?.addWfsLayer?.(layerCode); - layer?.set?.("id", AGED_PIPE_HIGHLIGHT_LAYER_ID); - layer?.set?.("lyrNm", "노후관로"); - layer?.set?.("lyrTyNm", AGED_PIPE_LAYER_TYPE); - - return () => { - core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID); - }; - }, [ - appliedAgedPipeFilters, - isAgedPipeChecked, - isAgedPipeSettingEnabled, - mapReady, - ]); + const id = String(row.lyrSn); + const layerCode = (GisApp.layerCode as any)?.[id]; + if (!layerCode) return; + try { + validateAgedPipeLayer(row); + const filter = buildAgedPipeCqlFilters( + appliedAgedPipeFilters, row.agedPipeFilterTemplate ?? row.flterSql, + ).join(" AND "); + layerCode.flterSql = filter; + const visible = isAgedPipeChecked && isAgedPipeSettingEnabled; + let layer = core?.getLayerById?.(id); + if (!visible) { + layer?.setVisible?.(false); + return; + } + // 숨긴 동안 바뀐 조건도 다시 켤 때 반영하도록 실제 레이어의 생성 당시 필터와 비교한다. + if (!layer || layer.get?.("flterSql") !== filter) { + layer = core?.addWfsLayer?.(layerCode); + core?.setSelectInteraction?.(); + } + layer?.setVisible?.(true); + } catch (error) { + core?.getLayerById?.(id)?.setVisible?.(false); + console.error("노후관로 DB 레이어 설정 오류:", error); + } + }, [appliedAgedPipeFilters, isAgedPipeChecked, isAgedPipeSettingEnabled, + mapReady, treeData, checkedLayerLegendVersion]); useFloodHistoryVisibility({ mapReady, gisMaps, diff --git a/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md index d70d513a3..11575355d 100644 --- a/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md @@ -83,3 +83,13 @@ - SN 전체 TypeScript는 기존 `map/page.tsx:3659`의 `postMessage` 타입 오류 1건만 남았고 이번 공통 보강 코드의 추가 오류는 없다. - ESLint는 설치 상태에서 `eslint-plugin-react-hooks`를 찾지 못해 실행 환경 오류로 완료하지 못했다. - 판정: PASS. + +## 2026-09-30 관로분석 시도 기본값 부산 적용 + +- quick_vibe: 사용자 지시로 시도 전체 옵션을 제거하고 기본 시도를 부산광역시(26)로 설정했다. +- `PipeAnalysisPopupPage.tsx`: 시도 전체 option 및 공통 목록의 값 0을 제외한다. 시군구·처리구역 전체 선택은 유지한다. +- `pipeAnalysisUtils.ts`: 집계 기본 필터 ctpvCd=26. 최초 자동 조회와 초기화에서 같은 기본값을 사용한다. 계정 지역 제한은 기존 applyScopeToSummaryFilters를 우선한다. +- API/백엔드/DB 및 상세 필터 기본값은 변경하지 않는다. +- 원본: 요구사항정의서 기능 추적표 88행 SFR-011-002, 화면설계서 10번 SN_MN010103. 부산 기본값은 이번 사용자 지시를 적용한다. +- 검증: git diff --check 통과. ESLint는 eslint-plugin-react-hooks 설치 누락으로 실행 실패. 실제 브라우저 확인과 전체 빌드는 미실행이며 사용자 개발 서버를 유지했다. +- 롤백: 집계 기본 시도를 0으로 되돌리고 시도 전체 option을 복원한다. diff --git a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md index 1d99320f7..67fd8bfba 100644 --- a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md @@ -221,3 +221,12 @@ - 데이터 조회, 행 선택, 위험 판정, API 요청·응답 코드는 변경하지 않았다. - 대상 `WaterRiskPopupPage.tsx` ESLint와 프론트·공통 문서 저장소의 `git diff --check`가 통과했다. - 실행 중인 `sn` 개발 서버가 없어 로그인 화면의 실제 줄바꿈·배치는 확인하지 않았다. + +## 2026-09-30 과거분석이력 기본 강우발생기간 + +- 사용자 지시로 과거분석이력 최초 진입 기간만 2026-08-15 ~ 2026-08-17로 지정했다. 후속 사용자 지시에 따라 초기화는 실행 시점의 최근 1년·전체 검색조건을 새로 생성해 첫 페이지를 조회한다. +- 수정 파일: `frontend/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx`. +- 현재 탭의 조회기간은 기존 최근 1년 기준으로 별도 생성해 과거 고정기간에 의해 현재 데이터가 제외되지 않도록 했다. +- 과거 날짜 직접 변경·조회와 최신순 정렬을 유지한다. 백엔드·API 형식·DB 변경 없음. +- 검증: git diff --check 통과 및 최초 initialFilters와 초기화 buildDefaultResultFilters 분리 및 초기화 후 재조회 흐름 확인. 브라우저 확인 미실행, ESLint는 앞선 작업에서 확인한 eslint-plugin-react-hooks 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다. +- 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다. diff --git a/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md index 07a93843e..0e7b939d9 100644 --- a/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md @@ -75,3 +75,11 @@ | 서버 노드 호환 | 정적 통과 | 기존 노드와 프론트 가상 노드를 동일 기준으로 정규화 | | 변경 공백 검사 | 통과 | 프론트 저장소 `git diff --check` | | 브라우저 육안 확인 | 대기 | 지도 새로고침 후 체크박스 옆 빨간 선형 범례 확인 필요 | + +## 2026-10-01 DB 레이어 설정 이관 검증 +- TypeScript 검사 통과: node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false. 의존성 읽기 권한으로 상승 실행했다. +- 메모리 내 자동 검증 9건 통과: DB ID/스타일/원본 템플릿 보존, 가상 행 미생성, 경과연수 변경, 시군구 우선, 시도 조건, 빈/미지원 템플릿 거부, 기존 제어 행 오류 격리, 중첩 행, SQL 템플릿 일치. +- git diff --check 통과. 테스트 파일이나 원시 결과 파일은 생성하지 않았다. +- 실행 중 3002 브라우저에서 DB 미전환 안내와 기존 시설물 트리/노후관로 항목 표시를 확인했다. DB 응답이 실제 WFS 설정 요건을 충족하지 않아 정상 관로 표출·목록·Excel 실데이터 회귀는 미완료이다. +- 사용자 서버 3002/8883은 재시작하거나 종료하지 않았다. 전체 빌드는 실행하지 않았다. +- 후속 검증: 기존 139번에 UPDATE 적용 후 새로고침, 체크/해제, 30년→40년, 설정 숨김 중 변경 후 재표시, 지도·목록·Excel 기준 일치 확인. diff --git a/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md index 504b89518..229c41871 100644 --- a/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md @@ -85,3 +85,13 @@ - 동일 관로의 상세 효과가 개발 모드에서 반복 실행돼도 시설관리번호·관리기관코드별 요청 Promise를 재사용해 중복 API 호출을 막는다. 실패한 요청은 캐시에서 제거해 재시도를 허용한다. - 대상 관로 `2015303665`의 화면 표출값은 `시종점 관저고`이며, 계산코드는 `DEPTH`이다. - 수정 파일은 `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`이며, `map/page.tsx`는 관로 선택 행을 그대로 상세 컴포넌트에 전달한다. + +## 2026-09-30 관로분석 시도 기본값 부산 적용 + +- quick_vibe: 사용자 지시로 시도 전체 옵션을 제거하고 기본 시도를 부산광역시(26)로 설정했다. +- `PipeAnalysisPopupPage.tsx`: 시도 전체 option 및 공통 목록의 값 0을 제외한다. 시군구·처리구역 전체 선택은 유지한다. +- `pipeAnalysisUtils.ts`: 집계 기본 필터 ctpvCd=26. 최초 자동 조회와 초기화에서 같은 기본값을 사용한다. 계정 지역 제한은 기존 applyScopeToSummaryFilters를 우선한다. +- API/백엔드/DB 및 상세 필터 기본값은 변경하지 않는다. +- 원본: 요구사항정의서 기능 추적표 88행 SFR-011-002, 화면설계서 10번 SN_MN010103. 부산 기본값은 이번 사용자 지시를 적용한다. +- 검증: git diff --check 통과. ESLint는 eslint-plugin-react-hooks 설치 누락으로 실행 실패. 실제 브라우저 확인과 전체 빌드는 미실행이며 사용자 개발 서버를 유지했다. +- 롤백: 집계 기본 시도를 0으로 되돌리고 시도 전체 option을 복원한다. diff --git a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md index ce9365f91..839b425e3 100644 --- a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md @@ -266,3 +266,12 @@ - 현재·과거분석이력이 공유하는 결과표 우측 안내문 뒤에 `(GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 위험, 없으면 정상)`을 추가했다. - 지도 요약카드와 동일하게 `위험`은 빨간색(`#ff1c15`), `정상`은 파란색(`#1677ff`)의 굵은 글씨로 표시한다. - 지도 요약카드에만 있던 위험 판정기준을 결과 목록에서도 확인할 수 있게 했으며, 판정 로직·API·DB 계약은 변경하지 않았다. + +## 2026-09-30 과거분석이력 기본 강우발생기간 + +- 사용자 지시로 과거분석이력 최초 진입 기간만 2026-08-15 ~ 2026-08-17로 지정했다. 후속 사용자 지시에 따라 초기화는 실행 시점의 최근 1년·전체 검색조건을 새로 생성해 첫 페이지를 조회한다. +- 수정 파일: `frontend/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx`. +- 현재 탭의 조회기간은 기존 최근 1년 기준으로 별도 생성해 과거 고정기간에 의해 현재 데이터가 제외되지 않도록 했다. +- 과거 날짜 직접 변경·조회와 최신순 정렬을 유지한다. 백엔드·API 형식·DB 변경 없음. +- 검증: git diff --check 통과 및 최초 initialFilters와 초기화 buildDefaultResultFilters 분리 및 초기화 후 재조회 흐름 확인. 브라우저 확인 미실행, ESLint는 앞선 작업에서 확인한 eslint-plugin-react-hooks 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다. +- 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md new file mode 100644 index 000000000..80e0a5d6b --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md @@ -0,0 +1,39 @@ +# 침수예측현황 분석시각 초기화 구현기록 + +## 작업 목적 + +- 침수예측현황 팝업을 처음 열거나 검색조건을 초기화할 때 분석시각 범위를 자동 입력하지 않는다. +- 분석시각 제한 없이 조회한 결과는 최근 분석 데이터부터 표시한다. + +## 원본 및 변경 근거 + +- 요구사항정의서 `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 사용자 승인사항: 분석시각 기본 검색조건 제거 및 최근 데이터 우선 표시 + +## 변경 내용 + +- 최초·초기화 검색조건의 `analysisFrom`, `analysisTo`를 빈 값으로 변경했다. +- 목록과 Excel 전체 조회에 `analysisDateTime,desc` 정렬을 명시했다. +- 사용자가 필요할 때 직접 분석시각 범위를 입력할 수 있도록 검색 입력란은 유지했다. + +## 영향 범위 + +- `apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx` +- 백엔드 API와 응답 구조는 변경하지 않는다. +- 시도, 시군구, 강우시각, 초과 빈도 검색조건은 기존 동작을 유지한다. + +## 검증 + +- `pnpm --filter sn run typecheck` 통과 +- `git diff --check` 통과 +- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다. + +## 롤백 + +- `buildInitialFilters`에서 분석시각의 금일 기본 범위를 다시 생성하고 명시적 최신순 정렬 파라미터를 제거한다. + +## 남은 확인 + +- 팝업 최초 진입 시 분석시각 입력란이 비어 있는지 확인한다. +- 초기화 버튼을 누르면 분석시각이 비워지고 첫 페이지에 최신 분석 데이터가 표시되는지 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/003-fe-SFR-011-008-침수예측지도-오버레이시각표시-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/003-fe-SFR-011-008-침수예측지도-오버레이시각표시-구현기록.md new file mode 100644 index 000000000..31f23e41b --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/003-fe-SFR-011-008-침수예측지도-오버레이시각표시-구현기록.md @@ -0,0 +1,46 @@ +# 침수예측지도 오버레이 시각 표시 구현기록 + +## 작업 목적 + +- 침수예측현황 팝업에서 행을 선택했을 때 지도 오버레이의 분석시각과 예측 관련 시각을 생략 없이 표시한다. + +## 원본 및 변경 근거 + +- 요구사항정의서 `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 사용자 요청사항: 지도 오버레이에서 월 뒤로 생략되는 분석시각 값을 전체 표시 + +## 원인 + +- SN 목록 테이블의 데이터 셀에 적용되는 전역 말줄임 규칙이 지도 오버레이 내부 테이블에도 적용되었다. +- 오버레이의 기존 날짜 변환 함수는 `YYYY-MM-DD HH:mm` 전체 값을 만들고 있었으나, 좁은 셀에서 화면 표시만 생략되었다. + +## 변경 내용 + +- 지도 오버레이 폭을 기존과 동일한 `230px`로 유지했다. +- 고정된 폭 안에서 라벨 열 55%, 값 열 45%로 배분하도록 `colgroup`과 `table-layout: fixed`를 적용했다. +- 오버레이의 라벨·값 셀을 목록용 전역 말줄임 대상에서 제외했다. +- 전체 날짜와 시각이 한 줄에 표시되도록 값 셀의 줄바꿈과 말줄임을 해제했다. +- 기존 날짜 변환 형식과 침수위험도 배지 동작은 유지했다. + +## 영향 범위 + +- `apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts` +- 침수예측 격자 선택 후 표시되는 지도 오버레이에만 적용한다. +- 일반 목록 그리드의 말줄임 정책은 변경하지 않는다. + +## 검증 + +- `pnpm --filter sn run typecheck` 통과 +- `git diff --check` 통과 +- 실행 중인 개발 서버의 `/map` 경로 HTTP 200 응답 확인 +- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다. + +## 롤백 + +- 오버레이 셀의 `data-sn-cell-wrap` 속성과 표·셀 너비 스타일을 제거하고 기존 최소 너비를 복원한다. + +## 남은 확인 + +- 침수예측현황 행 선택 후 분석시각, 강우시작시각, 예측시각이 `YYYY-MM-DD HH:mm` 형식으로 모두 보이는지 확인한다. +- 지도의 오른쪽 경계 부근에서도 오버레이가 화면 안에 정상적으로 표시되는지 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/004-fe-SFR-011-008-침수예측지도-기능분리-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/004-fe-SFR-011-008-침수예측지도-기능분리-구현기록.md new file mode 100644 index 000000000..2fab0e07a --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/004-fe-SFR-011-008-침수예측지도-기능분리-구현기록.md @@ -0,0 +1,52 @@ +# 침수예측지도 기능 분리 구현기록 + +## 작업 목적 + +- 하수관망도 `map/page.tsx`에 포함된 침수예측현황 전용 로직을 기능 디렉터리로 이동한다. +- 지도 페이지에는 공통 지도 구성과 침수예측 기능 연결 코드만 남겨 기능별 책임을 분명히 한다. + +## 원본 및 변경 근거 + +- 요구사항정의서 `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 화면설계서 슬라이드 28, `SN_MN010206 침수예측지도` +- 사용자 요청사항: `map/page.tsx`의 침수예측현황 관련 함수와 상태를 `features` 하위 해당 기능으로 이동 + +## 변경 내용 + +- 침수예측 팝업 메시지 payload 검증을 기능 훅으로 이동했다. +- 도시침수지도 자동 활성화 요청 이후의 재시도 상태와 팝업 응답 대상을 기능 훅에서 관리한다. +- 도시침수지도 피처 선택, 지도 범위 이동, 분석결과 API 조회와 오버레이 생성·삭제를 기능 훅으로 이동했다. +- 침수위험도 배지 색상 계산을 별도 기능 유틸리티로 분리하여 팝업 목록과 지도 오버레이가 함께 사용하도록 했다. +- `map/page.tsx`에는 도시침수지도 레이어 활성화 요청과 팝업 메시지 위임만 남겼다. + +## 수정 대상 파일 + +- `apps/sn/app/(map-layout)/map/page.tsx` +- `apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts` +- `apps/sn/src/features/fldngPredcPrst/lib/flood-risk-grade.ts` +- `apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx` + +## 영향 범위 + +- 침수예측현황 행 선택 후 도시침수지도 자동 선택·이동·분석 오버레이 표시 흐름 +- 침수예측현황 목록과 지도 오버레이의 위험도 배지 색상 +- API 경로, 팝업 메시지 타입, 레이어명·레이어 타입과 사용자 동작은 변경하지 않는다. + +## 검증 + +- 신규 기능 파일 ESLint 검사 통과 +- `pnpm --filter sn run typecheck` 통과 +- `git diff --check` 확인 +- 실행 중인 개발 서버의 `/map` 경로 HTTP 응답 확인 +- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다. + +## 롤백 + +- `useFloodPredictionMap`의 상태·콜백·재시도 effect를 `map/page.tsx`로 되돌린다. +- 오버레이 DOM 생성 함수와 날짜 포맷터를 `map/page.tsx`로 되돌린다. +- 위험도 배지 유틸리티를 팝업 컴포넌트 내부 함수로 복원한다. + +## 남은 리스크 + +- 실제 로그인 세션에서 팝업 행을 선택해 레이어 자동 체크, 지도 이동, 오버레이 표시가 연속으로 유지되는지 최종 화면 확인이 필요하다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md new file mode 100644 index 000000000..7c5e1187d --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md @@ -0,0 +1,42 @@ +# 침수예측현황 도시침수지도 자동 선택 구현기록 + +## 작업 목적 + +- 침수예측현황 팝업에서 행을 선택하면 사용자가 레이어 트리에서 직접 조작하지 않아도 도시침수지도 레이어를 자동으로 체크한다. +- 레이어 활성화 후 선택한 시군구와 빈도에 맞는 위치 이동, 피처 선택, 침수예측 오버레이 표시를 이어서 실행한다. + +## 원본 근거 + +- 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 94행, `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 화면설계서 슬라이드 28, `SN_MN010206 침수예측지도` +- 자동 레이어 선택 비교 화면: 슬라이드 47, `SN_MN040106 조사결과 지도보기` + +## 변경 내용 + +- 하수관망도에 이미 연결된 `checkedLayerRequest`를 사용하여 `도시침수지도(정보제공포털)` 레이어를 기존 체크 상태에 추가한다. +- 도시침수지도 레이어가 활성화되기 전에 도착한 행 선택 정보는 임시 상태에 보관한다. +- 레이어가 준비되면 기존 시군구·빈도 피처 선택, 지도 범위 이동, 침수예측 오버레이 표시 로직을 실행한다. +- 제한 시간 안에 레이어가 준비되지 않으면 기존 수동 선택 안내 메시지를 팝업에 전달한다. + +## 영향 범위 + +- `apps/sn/app/(map-layout)/map/page.tsx` +- 백엔드 API와 메시지 payload 계약은 변경하지 않는다. +- 기존에 사용자가 체크한 다른 지도 레이어는 유지한다. +- 도시침수지도 미제공 빈도에 대한 기존 안내는 유지한다. + +## 검증 + +- `pnpm --filter sn exec tsc --noEmit` 통과 +- `git diff --check` 통과 +- 개발 서버가 3002 포트에서 실행 중이므로 별도 빌드는 수행하지 않는다. + +## 롤백 + +- `map/page.tsx`의 도시침수지도 자동 체크 요청, 보류 상태, 재시도 effect를 제거하고 기존 레이어 가시성 검사와 수동 선택 안내 흐름으로 복원한다. + +## 남은 확인 + +- 로그인된 브라우저에서 도시침수지도가 꺼진 상태와 이미 켜진 상태를 각각 확인한다. +- 행을 연속 선택했을 때 마지막 선택 행 기준으로 지도와 오버레이가 갱신되는지 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md index bde98f381..762fb9422 100644 --- a/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md @@ -115,3 +115,12 @@ - 실제 로그인 지도에서 노후관로 범례와 지도 강조선의 색상 일치 여부를 육안 확인해야 한다. - 사용자 스크린샷 기준의 정확한 위치 체감은 실제 브라우저 수동 확인이 필요하다. - 최소화 후 다시 열 때 사용하는 메인 화면의 미니 카드 위치는 이번 라운드 범위에 포함하지 않았다. + +## 2026-10-01 DB 레이어 설정 이관 +- 변경 파일: map/page.tsx, agedPipe/agedPipeUtils.ts, agedPipe/AgedPipePopupPage.tsx, 신규 agedPipe/agedPipeLayer.ts. +- appendAgedPipeControlNode, SN_AGED_PIPE 및 SN_AGED_PIPE_HIGHLIGHT 생성, 스타일 상수 덮어쓰기를 제거했다. +- 기존 설정 API 응답의 레이어 ID·원천·스타일을 사용하고 최초 요청 전에 sql_filt의 ${cutoffYmd}를 치환한다. 조건 변경 시 같은 ID의 레이어만 갱신한다. +- 숨긴 상태의 조건 변경도 재표시할 때 적용한다. 노후관로를 유하방향 관종 스타일 수집에서 제외한다. +- DB 미전환/필터 오류는 해당 행만 요청 차단하고 안내하며 다른 시설물 트리는 유지한다. +- 독립 목록 팝업도 레이어 설정 API를 사용한다. Excel API와 위치 이동 동작은 유지한다. 백엔드/공통 GIS 코드는 변경하지 않았다. +- DB작업.sql에 INSERT 및 기존 139번용 UPDATE를 작성했다. 실제 DB 변경은 미실행이다. diff --git a/apps/sn/src/entities/facility/lib/aged-pipe-utils.ts b/apps/sn/src/entities/facility/lib/aged-pipe-utils.ts index 9082f6f0f..9c6e7a54d 100644 --- a/apps/sn/src/entities/facility/lib/aged-pipe-utils.ts +++ b/apps/sn/src/entities/facility/lib/aged-pipe-utils.ts @@ -245,15 +245,18 @@ export const getElapsedYearsDisplayText = (value: unknown) => { return elapsedYears == null ? "확인불가" : `${elapsedYears}년`; }; -export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { +/** DB의 SQL 필터 템플릿을 해석한다. 누락/미지원 토큰은 전체 관로 조회로 대체하지 않는다. */ +export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters, filterTemplate: string) => { const normalized = normalizeAgedPipeFilters(filters); - const cqlFilters = [ - "instl_ymd IS NOT NULL", - ...AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.map( - (prefix) => `instl_ymd NOT LIKE '${prefix}%'`, - ), - `instl_ymd <= '${getCutoffYmd(normalized.elapsedYears)}'`, - ]; + const template = String(filterTemplate ?? "").trim(); + if (!template.includes("${cutoffYmd}")) { + throw new Error("노후관로 DB sql_filt에 ${cutoffYmd} 조건을 등록해 주세요."); + } + const filter = template.split("${cutoffYmd}").join(getCutoffYmd(normalized.elapsedYears)); + if (/\$\{[^}]*\}/.test(filter)) { + throw new Error("노후관로 DB sql_filt에 지원하지 않는 치환값이 있습니다."); + } + const cqlFilters = [`(${filter})`]; if (normalized.sggCd !== "0") { cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.sggCd}%'`); diff --git a/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx index ab56014e6..5a8b03b10 100644 --- a/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx +++ b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx @@ -393,6 +393,8 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined; const span: 1 | 2 | 3 = key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1; + // 수정 화면에서는 중복 판정 업무키인 사업순번과 단계를 변경할 수 없다. + const isBusinessKeyLocked = Boolean(id) && (key === "seqNo" || key === "stp"); return ( setForm((current) => ({ diff --git a/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-list-page.tsx b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-list-page.tsx index fd7406afc..a2f247017 100644 --- a/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-list-page.tsx +++ b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-list-page.tsx @@ -164,13 +164,11 @@ const formatLocalDateInputValue = (date: Date) => { return `${year}-${month}-${day}`; }; -/** 지정일 기본 검색기간을 오늘까지의 최근 1년으로 생성한다. */ +/** 지정일 기본 검색기간을 최초 사업자료 기준일인 2013-01-01부터 오늘까지로 생성한다. */ const createDefaultDesignationDateRange = () => { const to = new Date(); - const from = new Date(to); - from.setFullYear(from.getFullYear() - 1); return { - dsgnYmdFrom: formatLocalDateInputValue(from), + dsgnYmdFrom: "2013-01-01", dsgnYmdTo: formatLocalDateInputValue(to), }; }; diff --git a/apps/sn/src/features/fldng-predc-prst/lib/flood-risk-grade.ts b/apps/sn/src/features/fldng-predc-prst/lib/flood-risk-grade.ts new file mode 100644 index 000000000..a0f7e2419 --- /dev/null +++ b/apps/sn/src/features/fldng-predc-prst/lib/flood-risk-grade.ts @@ -0,0 +1,16 @@ +/** 침수위험도 등급을 목록과 지도 오버레이에서 사용하는 고정 의미색으로 변환한다. */ +export const getFloodRiskGradeClassName = (value: string) => { + const grade = String(value ?? "") + .trim() + .toUpperCase(); + if (grade.includes("위험") || grade === "DANGER") { + return "bg-[#dc2626] text-white"; + } + if (grade.includes("경계") || grade === "WARNING") { + return "bg-[#f97316] text-white"; + } + if (grade.includes("주의") || grade === "CAUTION") { + return "bg-[#facc15] text-[#713f12]"; + } + return "sn-popup-badge-normal"; +}; diff --git a/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts b/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts new file mode 100644 index 000000000..9204b1bb8 --- /dev/null +++ b/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts @@ -0,0 +1,406 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import Overlay from "ol/Overlay.js"; + +import GisApp from "@common/business_components/ui/map/lib/gis.core"; + +import { getRawJson } from "@/shared/api/sewerage-api"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; + +import { getFloodRiskGradeClassName } from "./flood-risk-grade"; + +const FLOOD_PREDICTION_GRID_LAYER_ID = "SN_FLDNG_PREDICTION_GRID_LABEL"; +const FLOOD_PREDICTION_LAYER_RETRY_LIMIT = 20; +const FLOOD_PREDICTION_LAYER_RETRY_DELAY = 200; + +/** 침수예측현황 행 선택 후 지도에 적용할 위치·행 식별 정보다. */ +type FloodPredictionSelection = { + extent: [number, number, number, number]; + sggCd: string; + frequency: string; + analysisDateTime: string; +}; + +/** 자동 레이어 선택 실패 시 안내를 돌려보낼 침수예측 팝업 정보다. */ +type FloodPredictionPopupContext = { + source: Window | null; + origin: string; +}; + +type FloodPredictionFeature = { + get?: (key: string) => unknown; + getId?: () => string | number | undefined; +}; + +type FloodPredictionSource = { + getFeatures?: () => FloodPredictionFeature[]; + once?: (eventName: string, listener: () => void) => void; +}; + +type FloodPredictionLayer = { + get?: (key: string) => unknown; + getVisible?: () => boolean; + getSource?: () => FloodPredictionSource | null; +}; + +type FloodPredictionMap = { + addOverlay: (overlay: Overlay) => void; + removeOverlay: (overlay: Overlay) => void; + getLayers: () => { getArray: () => FloodPredictionLayer[] }; + getView: () => { + fit: ( + extent: [number, number, number, number], + options: { + duration: number; + padding: [number, number, number, number]; + maxZoom: number; + }, + ) => void; + }; +}; + +type FloodPredictionOverlayRow = Record & { + analysisDateTime?: unknown; + ctpvCd?: unknown; + ctpvNm?: unknown; + exceededFrequencyYear?: unknown; + floodRiskGrade?: unknown; + observedAccumulatedRainfall?: unknown; + predictedAccumulatedRainfall?: unknown; + predictionDateTime?: unknown; + rainBeginningDateTime?: unknown; + sggCd?: unknown; + sggNm?: unknown; +}; + +type FloodSelectionModule = { + resetSelectedFeature?: () => void | Promise; + selectFocusedBoundaryFeature?: ( + feature: FloodPredictionFeature, + append: boolean, + ) => void; +}; + +type UseFloodPredictionMapOptions = { + mapRef: { current: FloodPredictionMap | null }; + floodLayerType: string; + onRequestFloodLayer: () => void; +}; + +/** 침수예측 오버레이용 12자리 분석시각을 화면 표시 형식으로 변환한다. */ +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 || "-"; +}; + +/** 팝업 메시지 payload를 검증하여 지도 선택 정보로 변환한다. */ +const parseFloodPredictionSelection = ( + payload: unknown, +): FloodPredictionSelection | null => { + const message = + payload && typeof payload === "object" + ? (payload as Record) + : {}; + const extent = Array.isArray(message.extent) + ? message.extent.map(Number) + : []; + const sggCd = String(message.sggCd ?? "").trim(); + const frequency = String(message.sbmrgFrqExpln ?? "").trim(); + const analysisDateTime = String(message.analysisDateTime ?? "").trim(); + + if ( + extent.length !== 4 || + extent.some((value: number) => !Number.isFinite(value)) || + !/^\d{5}$/.test(sggCd) || + !/^(030|050|080|100)$/.test(frequency) + ) { + return null; + } + + return { + extent: extent as [number, number, number, number], + sggCd, + frequency, + analysisDateTime, + }; +}; + +/** 선택 행의 분석값을 표시할 지도 오버레이 DOM을 생성한다. */ +const createFloodPredictionOverlayElement = ( + row: FloodPredictionOverlayRow, + analysisDateTime: string, + onClose: () => void, +) => { + const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(1); + const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(1); + const container = document.createElement("div"); + container.style.cssText = + "position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; width:230px;"; + + const close = document.createElement("button"); + close.type = "button"; + close.textContent = "×"; + close.title = "침수예측 오버레이 닫기"; + close.style.cssText = + "position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;"; + close.addEventListener("click", onClose); + + const table = document.createElement("table"); + table.style.cssText = + "border-collapse:collapse; table-layout:fixed; width:100%;"; + const colgroup = document.createElement("colgroup"); + const keyColumn = document.createElement("col"); + const valueColumn = document.createElement("col"); + keyColumn.style.width = "55%"; + valueColumn.style.width = "45%"; + colgroup.append(keyColumn, valueColumn); + table.append(colgroup); + + const title = table.createTHead().insertRow(); + const titleCell = title.insertCell(); + titleCell.colSpan = 2; + titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`; + titleCell.style.cssText = + "font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;"; + + const body = table.createTBody(); + const overlayRows: Array<[string, string]> = [ + [ + "분석시각", + formatFloodPredictionDateTime(row.analysisDateTime ?? analysisDateTime), + ], + [ + "강우시작시각", + formatFloodPredictionDateTime(row.rainBeginningDateTime), + ], + ["관측 누적강수량(mm)", observed], + ["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)], + ["예측 누적강수량(mm)", predicted], + ["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`], + ["침수위험도", String(row.floodRiskGrade ?? "-")], + ]; + overlayRows.forEach(([label, value]) => { + const tr = body.insertRow(); + const key = tr.insertCell(); + key.textContent = label; + // 목록용 전역 말줄임 규칙이 지도 오버레이 셀에는 적용되지 않게 한다. + key.setAttribute("data-sn-cell-wrap", "true"); + key.style.cssText = + "font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; + + const val = tr.insertCell(); + val.setAttribute("data-sn-cell-wrap", "true"); + val.style.cssText = + "font-weight:400; overflow:visible; text-overflow:clip; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; + if (label === "침수위험도") { + // 침수예측현황 목록과 같은 배지 스타일로 등급을 표시한다. + const badge = document.createElement("span"); + badge.textContent = value; + badge.className = `sn-popup-badge min-w-[44px] ${getFloodRiskGradeClassName(String(value ?? ""))}`; + val.append(badge); + } else { + val.textContent = value; + } + }); + + container.append(close, table); + return container; +}; + +/** 침수예측현황 팝업과 도시침수지도의 선택·이동·오버레이 흐름을 관리한다. */ +export const useFloodPredictionMap = ({ + mapRef, + floodLayerType, + onRequestFloodLayer, +}: UseFloodPredictionMapOptions) => { + // 자동 레이어 선택이 완료된 뒤 다시 적용할 침수예측 행을 보관한다. + const [pendingSelection, setPendingSelection] = + useState(null); + // 팝업 Window 객체는 렌더링 대상이 아니므로 자동 선택 실패 안내용 ref로 보관한다. + const pendingPopupRef = useRef(null); + // 행을 다시 선택할 때 기존 오버레이를 제거할 수 있도록 OpenLayers 객체를 ref로 보관한다. + const overlayRef = useRef(null); + + /** 기존 침수예측 중앙 오버레이를 제거한다. */ + const removeFloodPredictionOverlay = useCallback(() => { + const core = GisApp.Module.core as unknown as + | { deleteLayer?: (layerId: string) => void } + | undefined; + core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID); + const map = mapRef.current; + if (map && overlayRef.current) { + map.removeOverlay(overlayRef.current); + } + overlayRef.current = null; + }, [mapRef]); + + /** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */ + const loadFloodPredictionOverlay = useCallback( + async (selection: FloodPredictionSelection) => { + const { sggCd, analysisDateTime, extent } = selection; + const map = mapRef.current; + if (!map || !/^\d{12}$/.test(analysisDateTime)) return; + removeFloodPredictionOverlay(); + + try { + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`, + ); + const payload = response?.data ?? response; + const row = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as FloodPredictionOverlayRow) + : null; + if (!row) return; + + const center = [ + (extent[0] + extent[2]) / 2, + (extent[1] + extent[3]) / 2, + ]; + const overlay = new Overlay({ + element: createFloodPredictionOverlayElement( + row, + analysisDateTime, + removeFloodPredictionOverlay, + ), + positioning: "center-center", + stopEvent: true, + }); + overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID); + overlay.setPosition(center); + map.addOverlay(overlay); + overlayRef.current = overlay; + } catch (error) { + console.error("침수예측지도 오버레이 표시 실패:", error); + } + }, + [mapRef, removeFloodPredictionOverlay], + ); + + /** 활성화된 도시침수지도에서 선택 행의 시군구·빈도 피처를 선택하고 지도와 오버레이를 이동한다. */ + const applyFloodPredictionSelection = useCallback( + async (selection: FloodPredictionSelection) => { + const map = mapRef.current; + const select = GisApp.Module.select as unknown as + | FloodSelectionModule + | undefined; + const selectFocusedBoundaryFeature = + select?.selectFocusedBoundaryFeature; + if (!map || !selectFocusedBoundaryFeature) return false; + + const floodLayers = map + .getLayers() + .getArray() + .filter( + (layer) => + String(layer.get?.("lyrTyNm") ?? "").trim() === floodLayerType && + layer.getVisible?.() !== false, + ); + if (floodLayers.length === 0) return false; + + await select.resetSelectedFeature?.(); + const selectedFeatureIds = new Set(); + /** 현재 로드된 동일 시군구·빈도 피처를 중복 없이 선택한다. */ + const selectLoadedGroupFeatures = (source: FloodPredictionSource) => { + const features = source?.getFeatures?.() ?? []; + features.forEach((feature) => { + const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim(); + const featureFrequency = String( + feature.get?.("sbmrg_frq_expln") ?? "", + ).trim(); + if ( + featureSggCd !== selection.sggCd || + featureFrequency !== selection.frequency + ) { + return; + } + + const featureId = String( + feature.getId?.() ?? feature.get?.("fid") ?? "", + ); + if (featureId && selectedFeatureIds.has(featureId)) return; + if (featureId) selectedFeatureIds.add(featureId); + selectFocusedBoundaryFeature(feature, true); + }); + }; + + floodLayers.forEach((layer) => { + const source = layer.getSource?.(); + if (!source?.getFeatures) return; + selectLoadedGroupFeatures(source); + source.once?.("featuresloadend", () => + selectLoadedGroupFeatures(source), + ); + }); + map.getView().fit(selection.extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + }); + void loadFloodPredictionOverlay(selection); + return true; + }, + [floodLayerType, loadFloodPredictionOverlay, mapRef], + ); + + /** 침수예측현황 행 선택 메시지를 검증하고, 필요하면 도시침수지도 레이어 활성화를 요청한다. */ + const handleFloodPredictionPopupSelection = useCallback( + async (payload: unknown, popupContext: FloodPredictionPopupContext) => { + const selection = parseFloodPredictionSelection(payload); + if (!selection) return; + if (await applyFloodPredictionSelection(selection)) return; + + pendingPopupRef.current = popupContext; + setPendingSelection(selection); + onRequestFloodLayer(); + }, + [applyFloodPredictionSelection, onRequestFloodLayer], + ); + + useEffect(() => { + if (!pendingSelection) return; + + let retryTimer: number | null = null; + let retryCount = 0; + + /** 자동 체크 요청으로 도시침수지도 레이어가 생성될 때까지 선택 행 적용을 제한적으로 재시도한다. */ + const applyPendingSelection = async () => { + if (await applyFloodPredictionSelection(pendingSelection)) { + setPendingSelection((current) => + current === pendingSelection ? null : current, + ); + pendingPopupRef.current = null; + return; + } + + retryCount += 1; + if (retryCount >= FLOOD_PREDICTION_LAYER_RETRY_LIMIT) { + const popup = pendingPopupRef.current; + popup?.source?.postMessage( + { type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED }, + popup.origin, + ); + pendingPopupRef.current = null; + setPendingSelection((current) => + current === pendingSelection ? null : current, + ); + return; + } + + retryTimer = window.setTimeout(() => { + void applyPendingSelection(); + }, FLOOD_PREDICTION_LAYER_RETRY_DELAY); + }; + + void applyPendingSelection(); + return () => { + if (retryTimer !== null) window.clearTimeout(retryTimer); + }; + }, [applyFloodPredictionSelection, pendingSelection]); + + return { handleFloodPredictionPopupSelection }; +}; diff --git a/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx b/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx index 9a731c001..c03d3d902 100644 --- a/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx +++ b/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx @@ -20,6 +20,7 @@ import useStore from "@/entities/reference-data/model/reference-data-store"; import { createPredictionAnalysisTimeRange } from "../model/analysis-time-range"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel"; +import { getFloodRiskGradeClassName } from "../lib/flood-risk-grade"; type ApiRow = { sggCd: string; @@ -111,21 +112,16 @@ const HEADS: UiHeadProps[] = [ { id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 }, ]; -/** 최초·초기화 조회조건은 금일 분석결과를 기준으로 생성한다. */ -const buildInitialFilters = (): Filters => { - const range = createPredictionAnalysisTimeRange(); - const toDateTimeLocal = (value: string) => - `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(8, 10)}:${value.slice(10, 12)}`; - return { - ctpvCd: "0", - sggCd: "0", - rainBgngFrom: "", - rainBgngTo: "", - analysisFrom: toDateTimeLocal(range.analysisFrom), - analysisTo: toDateTimeLocal(range.analysisTo), - frequencyYear: "0", - }; -}; +/** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */ +const buildInitialFilters = (): Filters => ({ + ctpvCd: "0", + sggCd: "0", + rainBgngFrom: "", + rainBgngTo: "", + analysisFrom: "", + analysisTo: "", + frequencyYear: "0", +}); const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12); @@ -151,6 +147,7 @@ const buildQuery = (filters: Filters, page?: number, size = PAGE_SIZE) => { if (page !== undefined) { params.set("page", String(page - 1)); params.set("size", String(size)); + params.set("sort", "analysisDateTime,desc"); } appendApiSearchParams(params, { ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2), @@ -194,23 +191,6 @@ const validateFilters = (filters: Filters) => { const buildRowId = (row: ApiRow) => `${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.predictionHour}`; -/** 침수위험도 등급을 목록 배지의 고정 의미색으로 변환한다. */ -export const getFloodRiskGradeClassName = (value: string) => { - const grade = String(value ?? "") - .trim() - .toUpperCase(); - if (grade.includes("위험") || grade === "DANGER") { - return "bg-[#dc2626] text-white"; - } - if (grade.includes("경계") || grade === "WARNING") { - return "bg-[#f97316] text-white"; - } - if (grade.includes("주의") || grade === "CAUTION") { - return "bg-[#facc15] text-[#713f12]"; - } - return "sn-popup-badge-normal"; -}; - export default function FldngPredcPrstPopupContent() { const alert = useAlertStore((state) => state.setMessage); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); diff --git a/apps/sn/src/features/map-layers/lib/flow-direction-selection.ts b/apps/sn/src/features/map-layers/lib/flow-direction-selection.ts index ce7b43320..1127aa674 100644 --- a/apps/sn/src/features/map-layers/lib/flow-direction-selection.ts +++ b/apps/sn/src/features/map-layers/lib/flow-direction-selection.ts @@ -1,3 +1,4 @@ +import { isAgedPipeLayerNode } from "@/entities/facility/lib/aged-pipe-utils"; import { FLOW_DIRECTION_DEFAULT_COLOR, type FlowDirectionUsageStyle, @@ -54,7 +55,7 @@ export const extractCheckedPipeUsageStyles = ( checkedNodesData.forEach((node) => { const data = node?.data ?? node; - if (!data || isFlowDirectionLayerInfo(data)) return; + if (!data || isFlowDirectionLayerInfo(data) || isAgedPipeLayerNode(data)) return; if (getLayerTypeName(data) !== FLOW_DIRECTION_PIPE_LAYER_TYPE) return; if (!isLayerVisibleAtZoom(data, zoom)) return;