merge(sn): retain remote layer and flood updates after refactoring
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { create } from "zustand";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PplLengthContent, SewerageService } from "../model/sn-interface";
|
||||
import type { BasinStofcbrCountContent, PplLengthContent, SewerageService } from "../model/sn-interface";
|
||||
import type { OldPplChartData, mapPplOldType, mapPplOldItem } from "../model/sn-interface";
|
||||
|
||||
export type SnState = {
|
||||
@@ -35,26 +35,7 @@ const initialUsePplChartData: OldPplChartData = {
|
||||
};
|
||||
const initialOldPplChartData: OldPplChartData = {
|
||||
labels: ["금강", "한강", "낙동강", "영산강"],
|
||||
datasets: [
|
||||
{
|
||||
label: "20-25년",
|
||||
data: [1140, 3181, 3100, 1662],
|
||||
backgroundColor: "#3B82F6",
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: "25-30년",
|
||||
data: [1391, 3615, 1814, 1411],
|
||||
backgroundColor: "#EF4444",
|
||||
borderRadius: 4
|
||||
},
|
||||
{
|
||||
label: "30년이상",
|
||||
data: [2014, 10303, 9831, 2000],
|
||||
backgroundColor: "#F87171",
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
datasets: []
|
||||
};
|
||||
type OldPplStoreState = {
|
||||
chartData: OldPplChartData;
|
||||
@@ -70,23 +51,122 @@ export const useUsePplStore = create<OldPplStoreState>((set) => ({
|
||||
}));
|
||||
|
||||
export const initailMapOldPplData: mapPplOldType = {
|
||||
colTitle: { totalLen: "총길이", pplLen2025: "20~25년", pplLen2530: "25~30년", pplLen30: "30년 이상" }, //헤더 영역
|
||||
colTitle: { stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" },
|
||||
data: [
|
||||
{ name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30: 2014 },
|
||||
{ name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30: 10303 },
|
||||
{ name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30: 9831 },
|
||||
{ name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30: 2000 },
|
||||
{ name: "금강", totalLen: 0, stofcbrCount: 0 },
|
||||
{ name: "한강", totalLen: 0, stofcbrCount: 0 },
|
||||
{ name: "낙동강", totalLen: 0, stofcbrCount: 0 },
|
||||
{ name: "영산강", totalLen: 0, stofcbrCount: 0 },
|
||||
]
|
||||
};
|
||||
type mapPplOldState = {
|
||||
mapData: mapPplOldItem[];
|
||||
setMapData: (mapData: mapPplOldItem[]) => void;
|
||||
setStofcbrCounts: (counts: BasinStofcbrCountContent[]) => void;
|
||||
};
|
||||
export const useMapOldPplStore = create<mapPplOldState>((set) => ({
|
||||
mapData: initailMapOldPplData.data,
|
||||
setMapData: (newMapData) => set({ mapData: newMapData }),
|
||||
setMapData: (newMapData) => set((state) => ({
|
||||
mapData: newMapData.map((item) => ({
|
||||
...item,
|
||||
stofcbrCount: state.mapData.find((current) => current.name === item.name)?.stofcbrCount ?? item.stofcbrCount,
|
||||
})),
|
||||
})),
|
||||
setStofcbrCounts: (counts) => set((state) => ({
|
||||
mapData: state.mapData.map((item) => ({
|
||||
...item,
|
||||
stofcbrCount: counts.find((count) => count.zoneNm === item.name)?.stofcbrCount ?? item.stofcbrCount,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
const DEFAULT_BASIN_ORDER = ["금강", "한강", "낙동강", "영산강"];
|
||||
const YEAR_GROUP_START_COLOR = "#FACC15";
|
||||
const YEAR_GROUP_END_COLOR = "#F87171";
|
||||
|
||||
/**
|
||||
* 시작색과 종료색 사이를 RGB 기준으로 보간해 구간 수만큼 색상을 생성합니다.
|
||||
* 구간이 하나면 시작색을, 둘 이상이면 첫 구간은 파란색·마지막 구간은 빨간색으로 고정합니다.
|
||||
*/
|
||||
function buildYearGroupColors(count: number): string[] {
|
||||
if (count <= 0) return [];
|
||||
if (count === 1) return [YEAR_GROUP_START_COLOR];
|
||||
|
||||
const parseHex = (color: string) => [
|
||||
Number.parseInt(color.slice(1, 3), 16),
|
||||
Number.parseInt(color.slice(3, 5), 16),
|
||||
Number.parseInt(color.slice(5, 7), 16),
|
||||
];
|
||||
const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0");
|
||||
const start = parseHex(YEAR_GROUP_START_COLOR);
|
||||
const end = parseHex(YEAR_GROUP_END_COLOR);
|
||||
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const ratio = index / (count - 1);
|
||||
return `#${start.map((channel, channelIndex) => {
|
||||
const endChannel = end[channelIndex] ?? channel;
|
||||
return toHex(channel + (endChannel - channel) * ratio);
|
||||
}).join("")}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 별도 정렬순서가 없는 MV 계약에서 구간명의 첫 숫자를 기준으로 표시순서를 결정합니다.
|
||||
* 숫자가 없는 비정형 구간은 숫자 구간 뒤에 한글 오름차순으로 배치합니다.
|
||||
*/
|
||||
function compareYearGroups(left: string, right: string): number {
|
||||
const leftNumber = left.match(/\d+/)?.[0];
|
||||
const rightNumber = right.match(/\d+/)?.[0];
|
||||
|
||||
if (leftNumber !== undefined && rightNumber !== undefined) {
|
||||
const numberDifference = Number(leftNumber) - Number(rightNumber);
|
||||
if (numberDifference !== 0) return numberDifference;
|
||||
} else if (leftNumber !== undefined) {
|
||||
return -1;
|
||||
} else if (rightNumber !== undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left.localeCompare(right, "ko");
|
||||
}
|
||||
|
||||
/**
|
||||
* MV 응답의 yearGroup 목록으로 노후관로 차트 데이터를 동적으로 생성합니다.
|
||||
* 구간명이나 구간 개수가 바뀌어도 고정 매핑 없이 동일한 로직을 사용합니다.
|
||||
*/
|
||||
export function buildOldPplChartData(items: PplLengthContent[]): OldPplChartData {
|
||||
const responseZones = [...new Set(items.map((item) => item.zoneNm).filter(Boolean))];
|
||||
const labels = [
|
||||
...DEFAULT_BASIN_ORDER,
|
||||
...responseZones.filter((zone) => !DEFAULT_BASIN_ORDER.includes(zone)).sort((left, right) => left.localeCompare(right, "ko")),
|
||||
];
|
||||
const yearGroups = [
|
||||
...new Set(
|
||||
items
|
||||
.map((item) => item.yearGroup?.trim())
|
||||
.filter((yearGroup): yearGroup is string => Boolean(yearGroup)),
|
||||
),
|
||||
].sort(compareYearGroups);
|
||||
const colors = buildYearGroupColors(yearGroups.length);
|
||||
|
||||
const lengthByZoneAndGroup = new Map<string, number>();
|
||||
items.forEach((item) => {
|
||||
const yearGroup = item.yearGroup?.trim();
|
||||
if (!item.zoneNm || !yearGroup) return;
|
||||
lengthByZoneAndGroup.set(`${item.zoneNm}\u0000${yearGroup}`, Number(item.sumPrlgVl) || 0);
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: yearGroups.map((yearGroup, index) => ({
|
||||
label: yearGroup,
|
||||
data: labels.map((zone) => lengthByZoneAndGroup.get(`${zone}\u0000${yearGroup}`) ?? 0),
|
||||
backgroundColor: colors[index] ?? YEAR_GROUP_START_COLOR,
|
||||
borderRadius: 4,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OldPplChartData를 지도 팝업 데이터 형태인 mapPplOldItem[]으로 변환합니다.
|
||||
* @param chartData 차트 데이터
|
||||
@@ -96,22 +176,16 @@ export function transformChartDataToMapData(chartData: OldPplChartData): mapPplO
|
||||
const mapData: mapPplOldItem[] = [];
|
||||
const { labels, datasets } = chartData;
|
||||
|
||||
const pplLen2025Data = datasets.find(d => d.label === '20-25년')?.data || [];
|
||||
const pplLen2530Data = datasets.find(d => d.label === '25-30년')?.data || [];
|
||||
const pplLen30Data = datasets.find(d => d.label === '30년이상')?.data || [];
|
||||
|
||||
labels.forEach((label, index) => {
|
||||
const pplLen2025 = pplLen2025Data[index] || 0;
|
||||
const pplLen2530 = pplLen2530Data[index] || 0;
|
||||
const pplLen30 = pplLen30Data[index] || 0;
|
||||
const totalLen = pplLen2025 + pplLen2530 + pplLen30;
|
||||
const totalLen = datasets.reduce((sum, dataset) => {
|
||||
const value = dataset.data[index];
|
||||
return sum + (typeof value === "number" ? value : 0);
|
||||
}, 0);
|
||||
|
||||
mapData.push({
|
||||
name: label,
|
||||
totalLen,
|
||||
pplLen2025,
|
||||
pplLen2530,
|
||||
pplLen30,
|
||||
stofcbrCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +245,7 @@ export class SewerageApiService implements SewerageService {
|
||||
}));
|
||||
return list;
|
||||
}
|
||||
// 유역별 노후도별 관로 길이(km) MV조회 : yearGroup - 20년~25년미만,25년~30년미만,30년이상
|
||||
// 유역별 노후도별 관로 길이(km) MV조회: yearGroup은 MV가 제공하는 동적 구간명
|
||||
public async getMvOldPplLength(): Promise<PplLengthContent[]> {
|
||||
const response = await client.get<{
|
||||
PplLengthList: PplLengthContent[];
|
||||
@@ -184,6 +258,18 @@ export class SewerageApiService implements SewerageService {
|
||||
return list;
|
||||
}
|
||||
|
||||
// CM 대시보드 유역별 우수토실 개소수 조회
|
||||
public async getBasinStofcbrCount(): Promise<BasinStofcbrCountContent[]> {
|
||||
const response = await client.get<{
|
||||
basinStofcbrCountList: BasinStofcbrCountContent[];
|
||||
}>(ENDPOINT.SEWERAGE_SERVICE.BASIN_STOFCBR_COUNT);
|
||||
|
||||
return (response.data.basinStofcbrCountList ?? []).map((item) => ({
|
||||
...item,
|
||||
stofcbrCount: Number(item.stofcbrCount),
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const SewerageApi = new SewerageApiService();
|
||||
|
||||
@@ -12,7 +12,7 @@ import { svcInfo } from "../model/map-data";
|
||||
import type { mapOperFcltType, mapReuseMapType} from "../model/fo-interface";
|
||||
import type { mapPplOldType } from "../model/sn-interface";
|
||||
import { useMapOperFcltStore, OperationApi, useMapReuseMapStore } from "../api/fo-service";
|
||||
import { useMapOldPplStore } from "../api/sn-service";
|
||||
import { SewerageApi, useMapOldPplStore } from "../api/sn-service";
|
||||
import {Button} from "@common/components";
|
||||
|
||||
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
|
||||
@@ -31,12 +31,12 @@ const mapOperFcltData: mapOperFcltType = {
|
||||
]
|
||||
};
|
||||
const mapPplOldData: mapPplOldType = {
|
||||
colTitle: {totalLen: "총길이",pplLen2025: "20~25년",pplLen2530: "25~30년",pplLen30 : "30년 이상" }, //헤더 영역
|
||||
colTitle: {stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" },
|
||||
data: [
|
||||
{name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30 : 2014},
|
||||
{name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30 : 10303},
|
||||
{name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30 : 9831},
|
||||
{name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30 : 2000},
|
||||
{name: "금강", totalLen: 0, stofcbrCount: 0},
|
||||
{name: "한강", totalLen: 0, stofcbrCount: 0},
|
||||
{name: "낙동강", totalLen: 0, stofcbrCount: 0},
|
||||
{name: "영산강", totalLen: 0, stofcbrCount: 0},
|
||||
]
|
||||
};
|
||||
const mapReuseMapData: mapReuseMapType = {
|
||||
@@ -62,17 +62,18 @@ export function KoreaBasinMap() {
|
||||
const { selectedBasin, setSelectedBasin } = useStore(basinStore);
|
||||
const selectedBasinKey = basinKeyMap[selectedBasin];
|
||||
const {mapOperFclt} = useMapOperFcltStore();
|
||||
const {mapData: mapPplOld} = useMapOldPplStore();
|
||||
const {mapData: mapPplOld, setStofcbrCounts} = useMapOldPplStore();
|
||||
const {setMapData: setMapReuseMapData} = useMapReuseMapStore();
|
||||
mapOperFcltData.data = mapOperFclt;
|
||||
mapPplOldData.data = mapPplOld;
|
||||
// mapReuseMapData.data = mapReuseMap;
|
||||
let standardYear = '2024';
|
||||
const [standardYear, setStandardYear] = useState('2024');
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
standardYear = await OperationApi.getStandardYear();
|
||||
const mapReuseMapRslt = await OperationApi.getReuseMap(standardYear);
|
||||
const fetchedStandardYear = await OperationApi.getStandardYear();
|
||||
setStandardYear(fetchedStandardYear);
|
||||
const mapReuseMapRslt = await OperationApi.getReuseMap(fetchedStandardYear);
|
||||
setMapReuseMapData(mapReuseMapRslt);
|
||||
mapReuseMapData.data = mapReuseMapRslt;
|
||||
} catch (e) {
|
||||
@@ -80,7 +81,13 @@ export function KoreaBasinMap() {
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
}, [setMapReuseMapData]);
|
||||
|
||||
useEffect(() => {
|
||||
SewerageApi.getBasinStofcbrCount()
|
||||
.then(setStofcbrCounts)
|
||||
.catch((e) => console.error("유역별 우수토실 개소수 조회 실패", e));
|
||||
}, [setStofcbrCounts]);
|
||||
// 유역정보
|
||||
const showFor = useCallback((g: SVGGElement) => {
|
||||
const activeService = services.find(s => s.active);
|
||||
@@ -104,24 +111,16 @@ export function KoreaBasinMap() {
|
||||
});
|
||||
} else if (activeService.svcId === 'sn') { // 하수관로
|
||||
const item = mapPplOldData.data.find(d => d.name === basinName);
|
||||
newPopupContent.title = `${standardYear}년도 ${basinName}유역 노후하수관로 현황`;
|
||||
newPopupContent.title = `${standardYear}년도 ${basinName}유역 하수관로 현황`;
|
||||
const colTitles = mapPplOldData.colTitle;
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.stofcbrCount,
|
||||
data: item ? `${fmt(item.stofcbrCount)}개소` : '-',
|
||||
});
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.totalLen,
|
||||
data: item ? `${fmt(item.totalLen)}km` : '-',
|
||||
});
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.pplLen2025,
|
||||
data: item ? `${fmt(item.pplLen2025)}km` : '-',
|
||||
});
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.pplLen2530,
|
||||
data: item ? `${fmt(item.pplLen2530)}km` : '-',
|
||||
});
|
||||
newPopupContent.items.push({
|
||||
header: colTitles.pplLen30,
|
||||
data: item ? `${fmt(item.pplLen30)}km` : '-',
|
||||
});
|
||||
} else if (activeService.svcId === 'wr') { // 물재이용
|
||||
const item = mapReuseMapData.data.find(d => d.name === basinName);
|
||||
newPopupContent.title = `${standardYear}년도 ${basinName}유역 물재이용 현황`;
|
||||
@@ -160,7 +159,7 @@ export function KoreaBasinMap() {
|
||||
);
|
||||
popupRef.current.style.left = x + "px";
|
||||
}
|
||||
}, [services, mapOperFclt]);
|
||||
}, [services, mapOperFclt, mapPplOld, standardYear]);
|
||||
|
||||
// selectedBasinKey prop이 변경될 때 지도를 업데이트하는 useEffect
|
||||
useEffect(() => {
|
||||
@@ -410,8 +409,14 @@ export function KoreaBasinMap() {
|
||||
};
|
||||
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
|
||||
return (
|
||||
<div className="col-span-3 !p-0 relative z-10" style={{ height: "765px", background: "rgba(255, 255, 255, 0.6)" }}>
|
||||
<div className="card card-border" style={{ height: "100%" }}>
|
||||
<div
|
||||
className="absolute inset-0 z-10 col-span-3 h-full !p-0"
|
||||
style={{ background: "rgba(255, 255, 255, 0.6)" }}
|
||||
>
|
||||
<div
|
||||
className="card card-border"
|
||||
style={{ height: "calc(100% - 1.2rem)" }}
|
||||
>
|
||||
<div className="card-header text-foreground">
|
||||
<h3 className="text-2xl font-medium">유역별 정보</h3>
|
||||
|
||||
|
||||
@@ -6,11 +6,17 @@ export interface PplLengthContent {
|
||||
yearGroup?: string;
|
||||
}
|
||||
|
||||
export interface BasinStofcbrCountContent {
|
||||
zoneNm: string;
|
||||
stofcbrCount: number;
|
||||
}
|
||||
|
||||
export interface SewerageService {
|
||||
getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
|
||||
getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise<PplLengthContent[]>;
|
||||
getMvUsgPplLength(): Promise<PplLengthContent[]>;
|
||||
getMvOldPplLength(): Promise<PplLengthContent[]>;
|
||||
getBasinStofcbrCount(): Promise<BasinStofcbrCountContent[]>;
|
||||
}
|
||||
|
||||
export interface ChartDataSet {
|
||||
@@ -37,17 +43,13 @@ type SnOldPplLength = {
|
||||
export type mapPplOldItem = {
|
||||
name: string;
|
||||
totalLen: number;
|
||||
pplLen2025: number;
|
||||
pplLen2530: number;
|
||||
pplLen30: number;
|
||||
stofcbrCount: number;
|
||||
};
|
||||
|
||||
export type mapPplOldType = {
|
||||
colTitle: {
|
||||
totalLen: string; // 총길이
|
||||
pplLen2025: string; // 20~25년
|
||||
pplLen2530: string; // 25~30년
|
||||
pplLen30: string; // 30년 이상
|
||||
stofcbrCount: string; // 우수토실개소수
|
||||
totalLen: string; // 총연장
|
||||
};
|
||||
data: mapPplOldItem[];
|
||||
};
|
||||
@@ -82,4 +84,4 @@ export function buildSnOldPplLength(
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ function DashboardPage() {
|
||||
<div className="p-2 sm:p-4 md:p-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* 첫번째 라인 - 좌측 6개 패널(2x3) + 우측 지도/사용자 정보 */}
|
||||
<div className="flex flex-col gap-1 lg:flex-row">
|
||||
<div className="flex flex-col gap-1 lg:flex-row lg:items-stretch">
|
||||
{/* 좌측: 드래그 가능한 6개 패널 (2x3 그리드, 화면의 50%) */}
|
||||
<div className="w-full lg:w-1/2">
|
||||
{isMounted ? (
|
||||
@@ -173,9 +173,9 @@ function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* 우측: 고정된 지도와 사용자 정보 (화면의 50%) */}
|
||||
<div className="flex w-full gap-1 lg:w-1/2">
|
||||
<div className="flex w-full flex-col items-stretch gap-1 lg:w-1/2 lg:flex-row">
|
||||
{/* 지도영역 - 고정 (드래그 불가) */}
|
||||
<div className="flex-1">
|
||||
<div className="relative min-h-[765px] w-full self-stretch lg:min-h-0 lg:flex-1">
|
||||
<KoreaBasinMap />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from "chart.js";
|
||||
import { Bar } from "react-chartjs-2";
|
||||
import { Button } from "@common/components";
|
||||
import type { PplLengthContent, ChartDataSet, OldPplChartData } from "../model/sn-interface";
|
||||
import { SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
|
||||
import type { PplLengthContent } from "../model/sn-interface";
|
||||
import { buildOldPplChartData, SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
|
||||
import { useDragHandle } from "./sortable-panel";
|
||||
|
||||
|
||||
@@ -41,45 +41,7 @@ export default function SnOldPpl() {
|
||||
useEffect(() => {
|
||||
if (oldPplLenData.length === 0) return;
|
||||
|
||||
// 유역 목록 추출 (중복 제거)
|
||||
const allZones = [...new Set(oldPplLenData.map(d => d.zoneNm))];
|
||||
|
||||
// yearGroup 매핑: API 응답값 -> 차트 label
|
||||
const yearGroupMap: { [key: string]: { label: string; color: string } } = {
|
||||
"20년~25년미만": { label: "20-25년", color: "#3B82F6" },
|
||||
"25년~30년미만": { label: "25-30년", color: "#EF4444" },
|
||||
"30년이상": { label: "30년이상", color: "#F87171" },
|
||||
};
|
||||
|
||||
// 유역별, 노후년도별 데이터 구조화
|
||||
const dataByZone: { [zone: string]: { [yearGroup: string]: number } } = {};
|
||||
allZones.forEach(zone => {
|
||||
dataByZone[zone] = {};
|
||||
});
|
||||
|
||||
oldPplLenData.forEach(item => {
|
||||
let zoneData = dataByZone[item.zoneNm];
|
||||
if (!zoneData) {
|
||||
zoneData = {};
|
||||
dataByZone[item.zoneNm] = zoneData;
|
||||
}
|
||||
if (item.yearGroup) {
|
||||
zoneData[item.yearGroup] = item.sumPrlgVl;
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터셋 생성
|
||||
const newDatasets: ChartDataSet[] = Object.entries(yearGroupMap).map(([yearGroup, { label, color }]) => ({
|
||||
label,
|
||||
data: allZones.map(zone => dataByZone[zone]?.[yearGroup] ?? 0),
|
||||
backgroundColor: color,
|
||||
borderRadius: 4,
|
||||
}));
|
||||
|
||||
const newChartData: OldPplChartData = {
|
||||
labels: allZones,
|
||||
datasets: newDatasets,
|
||||
};
|
||||
const newChartData = buildOldPplChartData(oldPplLenData);
|
||||
|
||||
setChartData(newChartData);
|
||||
|
||||
@@ -110,9 +72,7 @@ export default function SnOldPpl() {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
max: 10000,
|
||||
ticks: {
|
||||
stepSize: 2000,
|
||||
callback: function (value) {
|
||||
return Number(value).toLocaleString() + 'km';
|
||||
}
|
||||
@@ -192,4 +152,4 @@ export default function SnOldPpl() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -397,8 +397,8 @@ export default function PipeAnalysisPopupPage() {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{ctpv.map((item) => (
|
||||
{/* 시도는 전체 조회 없이 개별 지역만 선택한다. */}
|
||||
{ctpv.filter((item) => String(item.val).trim() !== "0").map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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<WaterRiskResultMode>("current");
|
||||
const [draftResultFilters, setDraftResultFilters] =
|
||||
useState<ResultFilters>(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("");
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<any>;
|
||||
};
|
||||
|
||||
/** 침수예측 선택행의 격자 중앙 정보 오버레이를 소유한다. */
|
||||
export function useFloodPredictionGridOverlay({ gisMaps }: Options) {
|
||||
// 침수예측현황 선택행에 따라 초단기예보 격자 중앙 라벨을 교체하는 전용 overlay를 보관한다.
|
||||
const floodPredictionGridLayerRef = useRef<Overlay | null>(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 };
|
||||
}
|
||||
@@ -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<any>;
|
||||
setMapReady: Dispatch<SetStateAction<boolean>>;
|
||||
loadFloodPredictionGridLayer: (
|
||||
sggCd: string,
|
||||
analysisDateTime: string,
|
||||
extent: number[],
|
||||
) => Promise<void>;
|
||||
handleFloodPredictionPopupSelection: ReturnType<typeof useFloodPredictionMap>["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<string>();
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const gisMaps = useRef<any | null>(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<any>(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,
|
||||
|
||||
@@ -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을 복원한다.
|
||||
|
||||
@@ -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 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다.
|
||||
- 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다.
|
||||
|
||||
@@ -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 기준 일치 확인.
|
||||
|
||||
@@ -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을 복원한다.
|
||||
|
||||
@@ -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 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다.
|
||||
- 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다.
|
||||
|
||||
@@ -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`에서 분석시각의 금일 기본 범위를 다시 생성하고 명시적 최신순 정렬 파라미터를 제거한다.
|
||||
|
||||
## 남은 확인
|
||||
|
||||
- 팝업 최초 진입 시 분석시각 입력란이 비어 있는지 확인한다.
|
||||
- 초기화 버튼을 누르면 분석시각이 비워지고 첫 페이지에 최신 분석 데이터가 표시되는지 확인한다.
|
||||
@@ -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` 형식으로 모두 보이는지 확인한다.
|
||||
- 지도의 오른쪽 경계 부근에서도 오버레이가 화면 안에 정상적으로 표시되는지 확인한다.
|
||||
@@ -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`로 되돌린다.
|
||||
- 위험도 배지 유틸리티를 팝업 컴포넌트 내부 함수로 복원한다.
|
||||
|
||||
## 남은 리스크
|
||||
|
||||
- 실제 로그인 세션에서 팝업 행을 선택해 레이어 자동 체크, 지도 이동, 오버레이 표시가 연속으로 유지되는지 최종 화면 확인이 필요하다.
|
||||
+42
@@ -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를 제거하고 기존 레이어 가시성 검사와 수동 선택 안내 흐름으로 복원한다.
|
||||
|
||||
## 남은 확인
|
||||
|
||||
- 로그인된 브라우저에서 도시침수지도가 꺼진 상태와 이미 켜진 상태를 각각 확인한다.
|
||||
- 행을 연속 선택했을 때 마지막 선택 행 기준으로 지도와 오버레이가 갱신되는지 확인한다.
|
||||
@@ -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 변경은 미실행이다.
|
||||
|
||||
@@ -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}%'`);
|
||||
|
||||
@@ -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 (
|
||||
<StandardFormField
|
||||
key={key}
|
||||
@@ -408,6 +410,7 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
maxLength={standardMaxLengths[key]}
|
||||
type={type === "date" ? "date" : type}
|
||||
list={suggestionListId}
|
||||
disabled={isBusinessKeyLocked}
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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<string, unknown> & {
|
||||
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<void>;
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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<FloodPredictionSelection | null>(null);
|
||||
// 팝업 Window 객체는 렌더링 대상이 아니므로 자동 선택 실패 안내용 ref로 보관한다.
|
||||
const pendingPopupRef = useRef<FloodPredictionPopupContext | null>(null);
|
||||
// 행을 다시 선택할 때 기존 오버레이를 제거할 수 있도록 OpenLayers 객체를 ref로 보관한다.
|
||||
const overlayRef = useRef<Overlay | null>(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<string>();
|
||||
/** 현재 로드된 동일 시군구·빈도 피처를 중복 없이 선택한다. */
|
||||
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 };
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user