침수지도 목록보기 및 WMS 표출 구현

This commit is contained in:
kiminchan
2026-08-05 13:55:22 +09:00
parent ce60e5f5fb
commit f58daf3bac
18 changed files with 1448 additions and 6 deletions
@@ -0,0 +1,333 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { UiTable } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore";
import {
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
FLOOD_DNGR_MAP_DEPTH_OPTIONS,
FLOOD_DNGR_MAP_FREQUENCY_OPTIONS,
FLOOD_DNGR_MAP_HEADLIST,
FLOOD_DNGR_MAP_PAGE_SIZE,
buildFloodDngrMapListQueryString,
toFloodDngrMapTableRows,
type FloodDngrMapApiItem,
type FloodDngrMapExtent,
type FloodDngrMapFilters,
type FloodDngrMapTableRow,
} from "./floodDngrMapTypes";
const FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS = 10_000;
const FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS = 12_000;
/** Abort 처리 지연 여부와 무관하게 제한 시간이 지나면 팝업 요청 Promise를 종료한다. */
const getFloodDngrMapJson = async (url: string) => {
let timeoutId: number | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
reject(new Error("도시침수지도 조회 시간이 초과되었습니다. 검색조건을 좁혀 다시 조회해주세요."));
}, FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS);
});
try {
return await Promise.race([getData(url), timeoutPromise]);
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
}
};
/** 시설물 레이어의 정보제공포털 도시침수지도 피처를 조건별 목록으로 제공한다. */
export default function FloodDngrMapPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<FloodDngrMapFilters>(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<FloodDngrMapFilters>(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
const [items, setItems] = useState<FloodDngrMapApiItem[]>([]);
const [administrativeScopeCodes, setAdministrativeScopeCodes] = useState<string[] | null>(null);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [sort, setSort] = useState<UiTableSort>();
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const availableCtpvCodes = useMemo(
() => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 2)).filter(Boolean)),
[administrativeScopeCodes],
);
const availableSggCodes = useMemo(
() => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 5)).filter(Boolean)),
[administrativeScopeCodes],
);
const ctpvOptions = useMemo(
() =>
administrativeScopeCodes === null
? ctpv
: ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))),
[administrativeScopeCodes, availableCtpvCodes, ctpv],
);
const sggOptions = useMemo(() => {
const scopedOptions =
administrativeScopeCodes === null
? sgg
: sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5)));
return filters.ctpvCd === "0"
? scopedOptions
: scopedOptions.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
}, [administrativeScopeCodes, availableSggCodes, filters.ctpvCd, sgg]);
const rows = useMemo(
() =>
toFloodDngrMapTableRows(
items,
currentPage - 1,
FLOOD_DNGR_MAP_PAGE_SIZE,
ctpv,
sgg,
total,
sort,
),
[ctpv, currentPage, items, sgg, sort, total],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 요청 Promise가 예상과 다르게 정리되지 않아도 로딩 오버레이를 최대 12초 안에 해제한다. */
useEffect(() => {
if (!isLoading) return;
const watchdogId = window.setTimeout(() => {
setIsLoading(false);
setErrorMessage((current) =>
current || "도시침수지도 조회가 지연되어 로딩을 종료했습니다. 검색조건을 좁혀 다시 조회해주세요.",
);
}, FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS);
return () => window.clearTimeout(watchdogId);
}, [isLoading]);
/** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */
const loadRows = useCallback(
async (page: number, nextFilters: FloodDngrMapFilters, nextSort?: UiTableSort) => {
setIsLoading(true);
setErrorMessage("");
try {
const queryString = buildFloodDngrMapListQueryString(
nextFilters,
page - 1,
FLOOD_DNGR_MAP_PAGE_SIZE,
nextSort,
);
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map?${queryString}`,
);
const payload = response as {
items?: FloodDngrMapApiItem[];
total?: number;
} | null;
if (!payload || !Array.isArray(payload.items)) {
throw new Error("도시침수지도 목록 API 응답 형식이 올바르지 않습니다.");
}
setItems(payload.items);
setTotal(Number(payload?.total ?? 0));
setCurrentPage(page);
setAppliedFilters(nextFilters);
setSort(nextSort);
setAppliedSort(nextSort);
setSelectedIndex(-1);
} catch (error) {
console.error("도시침수지도 목록 조회 중 오류:", error);
setItems([]);
setTotal(0);
setErrorMessage(
error instanceof Error ? error.message : "도시침수지도 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[],
);
/** 실제 도시침수지도 데이터가 존재하는 행정구역 코드만 검색 선택지로 제한한다. */
const loadAdministrativeScope = useCallback(async () => {
try {
const response = await getFloodDngrMapJson(
"/sewerage-service/api/v1/fldng/flood-dngr-map/scope",
);
if (!Array.isArray(response)) {
throw new Error("도시침수지도 행정구역 API 응답 형식이 올바르지 않습니다.");
}
setAdministrativeScopeCodes(response);
} catch (error) {
console.error("도시침수지도 행정구역 범위 조회 중 오류:", error);
setAdministrativeScopeCodes(null);
}
}, []);
useEffect(() => {
void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS);
void loadAdministrativeScope();
}, [loadAdministrativeScope, loadRows]);
const handleSearch = () => {
void loadRows(1, filters, sort);
};
/** 시군구·침수빈도 그룹의 범위와 선택 조건을 부모 지도에 전달한다. */
const handleRowClick = async (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row?.sggCd || !row?.sbmrgFrqExpln) return;
setSelectedIndex(index);
setIsLoading(true);
setErrorMessage("");
try {
const params = new URLSearchParams({
sggCd: row.sggCd,
sbmrgFrqExpln: row.sbmrgFrqExpln,
});
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`,
);
const payload = response as FloodDngrMapExtent;
const extent = [payload?.minX, payload?.minY, payload?.maxX, payload?.maxY].map(Number);
if (extent.length !== 4 || extent.some((value) => !Number.isFinite(value))) {
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
}
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP,
payload: {
extent,
sggCd: row.sggCd,
sbmrgFrqExpln: row.sbmrgFrqExpln,
},
},
window.location.origin,
);
} catch (error) {
console.error("도시침수지도 위치 이동 중 오류:", error);
const message =
error instanceof Error && error.message.includes("404")
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
: error instanceof Error
? error.message
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
setErrorMessage(message);
} finally {
setIsLoading(false);
}
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">도시침수지도 목록</h1>
</header>
<section className="flex min-h-0 flex-1 flex-col p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[200px_200px_220px_200px_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) => setFilters({ ...filters, ctpvCd: event.target.value, sggCd: "0" })}
>
<option value="0">전체</option>
{ctpvOptions.map((item) => (
<option key={item.val} value={String(item.val)}>{item.text}</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>{item.text}</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">침수심도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sbmrgDpthlCd}
onChange={(event) => setFilters({ ...filters, sbmrgDpthlCd: event.target.value })}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_DEPTH_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">침수빈도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sbmrgFrqExpln}
onChange={(event) => setFilters({ ...filters, sbmrgFrqExpln: event.target.value })}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
<option key={value} value={value}>{Number(value)}년</option>
))}
</select>
</label>
<div className="flex justify-end md:pr-3">
<Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={handleSearch}>
조회
</Button>
</div>
</div>
{errorMessage ? (
<div role="alert" className="mt-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700">
{errorMessage}
</div>
) : null}
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<FloodDngrMapTableRow>
headName={
<>
도시침수지도 조회 결과 총 {total.toLocaleString()}건
</>
}
tableData={rows}
headlist={FLOOD_DNGR_MAP_HEADLIST}
pageSize={FLOOD_DNGR_MAP_PAGE_SIZE}
total={total}
cellClick={(index) => void handleRowClick(index)}
onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
sort={sort}
onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)}
/>
</div>
</section>
</main>
);
}
@@ -0,0 +1,172 @@
"use client";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup";
export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map";
export const FLOOD_DNGR_MAP_LAYER_NAME = "도시침수지도(정보제공포털)";
export const FLOOD_DNGR_MAP_PAGE_SIZE = 20;
export type FloodDngrMapFilters = {
ctpvCd: string;
sggCd: string;
sbmrgDpthlCd: string;
sbmrgFrqExpln: string;
};
export type FloodDngrMapApiItem = {
ctpvCd: string;
sggCd: string;
minSbmrgDpthlCd: string;
maxSbmrgDpthlCd: string;
sbmrgFrqExpln: string;
};
export type FloodDngrMapExtent = {
minX: number;
minY: number;
maxX: number;
maxY: number;
};
export type FloodDngrMapTableRow = FloodDngrMapApiItem & {
rowNo: number;
ctpvNm: string;
sggNm: string;
sbmrgDpthlDisplay: string;
sbmrgFrqDisplay: string;
};
type AdministrativeOption = {
val: unknown;
text: unknown;
};
export const FLOOD_DNGR_MAP_DEFAULT_FILTERS: FloodDngrMapFilters = {
ctpvCd: "0",
sggCd: "0",
sbmrgDpthlCd: "0",
sbmrgFrqExpln: "0",
};
export const FLOOD_DNGR_MAP_FREQUENCY_OPTIONS = ["030", "050", "080", "100"];
export const FLOOD_DNGR_MAP_DEPTH_LABELS: Record<string, string> = {
N330: "0.0~0.5m 미만",
N331: "0.5~1.0m 미만",
N332: "1.0~2.0m 미만",
N333: "2.0~5.0m 미만",
N334: "5.0m 이상",
};
export const FLOOD_DNGR_MAP_DEPTH_OPTIONS = Object.entries(FLOOD_DNGR_MAP_DEPTH_LABELS).map(
([value, label]) => ({ value, label }),
);
export const FLOOD_DNGR_MAP_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "NO", widthPercent: 8, sortable: true, sortKey: "rowNo" },
{ id: "ctpvNm", title: "시도", widthPercent: 18, sortable: true, sortKey: "ctpvCd" },
{ id: "sggNm", title: "시군구", widthPercent: 20, sortable: true, sortKey: "sggCd" },
{
id: "sbmrgDpthlDisplay",
title: "침수심도(최소~최대)",
widthPercent: 31,
sortable: true,
sortKey: "sbmrgDpthlCd",
},
{
id: "sbmrgFrqDisplay",
title: "침수빈도",
widthPercent: 23,
sortable: true,
sortKey: "sbmrgFrqExpln",
},
];
/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */
export const buildFloodDngrMapListQueryString = (
filters: FloodDngrMapFilters,
page: number,
size: number,
sort?: UiTableSort,
) => {
const params = new URLSearchParams({ page: String(page), size: String(size) });
if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5));
if (filters.sbmrgDpthlCd && filters.sbmrgDpthlCd !== "0") {
params.set("sbmrgDpthlCd", filters.sbmrgDpthlCd);
}
if (filters.sbmrgFrqExpln && filters.sbmrgFrqExpln !== "0") {
params.set("sbmrgFrqExpln", filters.sbmrgFrqExpln);
}
if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`);
return params.toString();
};
const resolveAdministrativeName = (
value: unknown,
options: AdministrativeOption[],
codeLength: number,
) => {
const code = String(value ?? "").trim().slice(0, codeLength);
const matched = options.find(
(option) => String(option.val ?? "").trim().slice(0, codeLength) === code,
);
return String(matched?.text ?? "").trim() || "-";
};
const toFrequencyDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized) return "-";
const numericValue = Number(normalized);
return Number.isFinite(numericValue) ? `${numericValue}년` : normalized;
};
const FLOOD_DNGR_MAP_DEPTH_BOUNDS: Record<string, { min: string; max: string; openEnded?: boolean }> = {
N330: { min: "0.0", max: "0.5" },
N331: { min: "0.5", max: "1.0" },
N332: { min: "1.0", max: "2.0" },
N333: { min: "2.0", max: "5.0" },
N334: { min: "5.0", max: "5.0", openEnded: true },
};
/** 그룹의 최소·최대 침수심도 코드를 하나의 범위 문구로 표시한다. */
const toDepthRangeDisplay = (minCode: string, maxCode: string) => {
if (!minCode && !maxCode) return "-";
if (minCode === maxCode) return FLOOD_DNGR_MAP_DEPTH_LABELS[minCode] || minCode || "-";
const minDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[minCode];
const maxDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[maxCode];
if (!minDepth || !maxDepth) return `${minCode || "-"} ~ ${maxCode || "-"}`;
return maxDepth.openEnded
? `${minDepth.min}m ~ ${maxDepth.min}m 이상`
: `${minDepth.min}m ~ ${maxDepth.max}m 미만`;
};
/** 서버 페이지 번호와 코드 표시값을 화면 행으로 변환한다. */
export const toFloodDngrMapTableRows = (
items: FloodDngrMapApiItem[],
page: number,
pageSize = FLOOD_DNGR_MAP_PAGE_SIZE,
ctpvOptions: AdministrativeOption[] = [],
sggOptions: AdministrativeOption[] = [],
total = items.length,
sort?: UiTableSort,
): FloodDngrMapTableRow[] =>
items.map((item, index) => ({
...item,
rowNo:
sort?.key === "rowNo" && sort.direction === "desc"
? total - page * pageSize - index
: page * pageSize + index + 1,
ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5),
sbmrgDpthlDisplay: toDepthRangeDisplay(item.minSbmrgDpthlCd, item.maxSbmrgDpthlCd),
sbmrgFrqDisplay: toFrequencyDisplay(item.sbmrgFrqExpln),
}));
const resolveNodeName = (node: any) =>
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
/** 빈도·침수심 자식 노드가 아니라 도시침수지도 루트만 목록보기 대상으로 판별한다. */
export const isFloodDngrMapRootNode = (node: any) =>
resolveNodeName(node).replace(/\s/g, "") === FLOOD_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
@@ -0,0 +1,267 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { UiTable } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore";
import {
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
SBMRG_DNGR_MAP_FREQUENCY_OPTIONS,
SBMRG_DNGR_MAP_HEADLIST,
SBMRG_DNGR_MAP_LAYER_TYPE,
SBMRG_DNGR_MAP_PAGE_SIZE,
SBMRG_DNGR_MAP_SCOPE_SIZE,
buildSbmrgDngrMapListQueryString,
extractAdministrativeCodes,
toSbmrgDngrMapTableRows,
type SbmrgDngrMapApiItem,
type SbmrgDngrMapFilters,
type SbmrgDngrMapTableRow,
} from "./sbmrgDngrMapTypes";
const unwrapPayload = <T,>(response: any): T => (response?.data ?? response) as T;
/** 시설물 레이어의 중점관리지역 침수위험지도 피처를 조건별 목록으로 제공한다. */
export default function SbmrgDngrMapPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 입력 중인 조건과 실제 조회에 적용된 조건을 분리해 페이지 이동 시 조건이 흔들리지 않게 한다.
const [filters, setFilters] = useState<SbmrgDngrMapFilters>(SBMRG_DNGR_MAP_DEFAULT_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<SbmrgDngrMapFilters>(SBMRG_DNGR_MAP_DEFAULT_FILTERS);
const [items, setItems] = useState<SbmrgDngrMapApiItem[]>([]);
const [administrativeScopeItems, setAdministrativeScopeItems] = useState<SbmrgDngrMapApiItem[] | null>(null);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [sort, setSort] = useState<UiTableSort>();
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
const availableCtpvCodes = useMemo(
() =>
new Set(
(administrativeScopeItems ?? []).flatMap((item) =>
extractAdministrativeCodes(item.ctpvCd, 2),
),
),
[administrativeScopeItems],
);
const availableSggCodes = useMemo(
() =>
new Set(
(administrativeScopeItems ?? []).flatMap((item) =>
extractAdministrativeCodes(item.sggCd, 5),
),
),
[administrativeScopeItems],
);
const ctpvOptions = useMemo(
() =>
administrativeScopeItems === null
? ctpv
: ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))),
[administrativeScopeItems, availableCtpvCodes, ctpv],
);
const sggOptions = useMemo(
() => {
const scopedOptions =
administrativeScopeItems === null
? sgg
: sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5)));
return filters.ctpvCd === "0"
? scopedOptions
: scopedOptions.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
);
},
[administrativeScopeItems, availableSggCodes, filters.ctpvCd, sgg],
);
const rows = useMemo(
() =>
toSbmrgDngrMapTableRows(
items,
currentPage - 1,
SBMRG_DNGR_MAP_PAGE_SIZE,
ctpv,
sgg,
total,
sort,
),
[ctpv, currentPage, items, sgg, sort, total],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */
const loadRows = useCallback(
async (page: number, nextFilters: SbmrgDngrMapFilters, nextSort?: UiTableSort) => {
setIsLoading(true);
try {
const queryString = buildSbmrgDngrMapListQueryString(
nextFilters,
page - 1,
SBMRG_DNGR_MAP_PAGE_SIZE,
nextSort,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
);
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[]; total?: number }>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
setItems(items);
setTotal(Number(payload?.total ?? 0));
setCurrentPage(page);
setAppliedFilters(nextFilters);
setSort(nextSort);
setAppliedSort(nextSort);
setSelectedIndex(-1);
} catch (error) {
console.error("침수위험지도 목록 조회 중 오류:", error);
setItems([]);
setTotal(0);
alert(error instanceof Error ? error.message : "침수위험지도 목록 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
},
[],
);
/** 전체 50개 그룹에서 실제 존재하는 행정구역 코드만 검색 선택지로 제한한다. */
const loadAdministrativeScope = useCallback(async () => {
try {
const queryString = buildSbmrgDngrMapListQueryString(
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
0,
SBMRG_DNGR_MAP_SCOPE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
);
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(response);
setAdministrativeScopeItems(Array.isArray(payload?.items) ? payload.items : []);
} catch (error) {
console.error("침수위험지도 행정구역 범위 조회 중 오류:", error);
setAdministrativeScopeItems(null);
}
}, []);
useEffect(() => {
void loadRows(1, SBMRG_DNGR_MAP_DEFAULT_FILTERS);
void loadAdministrativeScope();
}, [loadAdministrativeScope, loadRows]);
/** 현재 입력 조건을 적용하고 첫 페이지부터 다시 조회한다. */
const handleSearch = () => {
void loadRows(1, filters, sort);
};
/** 선택한 지역·빈도 그룹의 전체 fid를 부모 지도에 전달해 전체 범위 이동·강조를 실행한다. */
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!Array.isArray(row?.fids) || row.fids.length === 0) return;
setSelectedIndex(index);
const featureIds = row.fids.map((fid) =>
String(fid).startsWith(`${SBMRG_DNGR_MAP_LAYER_TYPE}.`)
? String(fid)
: `${SBMRG_DNGR_MAP_LAYER_TYPE}.${fid}`,
);
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
payload: { layerNm: SBMRG_DNGR_MAP_LAYER_TYPE, fids: JSON.stringify(featureIds) },
},
window.location.origin,
);
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">침수위험지도 목록</h1>
</header>
<section className="flex min-h-0 flex-1 flex-col p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[220px_220px_220px_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) => setFilters({ ...filters, ctpvCd: event.target.value, sggCd: "0" })}
>
<option value="0">전체</option>
{ctpvOptions.map((item) => (
<option key={item.val} value={String(item.val)}>{item.text}</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>{item.text}</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">홍수수위빈도값</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.floodWtlvFrqVl}
onChange={(event) => setFilters({ ...filters, floodWtlvFrqVl: event.target.value })}
>
<option value="0">전체</option>
{SBMRG_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
<option key={value} value={String(value)}>{value}년</option>
))}
</select>
</label>
<div className="flex justify-end md:pr-3">
<Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={handleSearch}>
조회
</Button>
</div>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<SbmrgDngrMapTableRow>
headName={<>침수위험지도 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={SBMRG_DNGR_MAP_HEADLIST}
pageSize={SBMRG_DNGR_MAP_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
sort={sort}
onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)}
/>
</div>
</section>
</main>
);
}
@@ -0,0 +1,153 @@
"use client";
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup";
export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map";
export const SBMRG_DNGR_MAP_LAYER_NAME = "침수위험지도(중점관리지역)";
export const SBMRG_DNGR_MAP_PAGE_SIZE = 20;
export const SBMRG_DNGR_MAP_SCOPE_SIZE = 2000;
export type SbmrgDngrMapFilters = {
ctpvCd: string;
sggCd: string;
floodWtlvFrqVl: string;
};
export type SbmrgDngrMapApiItem = {
fids: string[];
ctpvCd: string;
sggCd: string;
emphsMngRgnNm: string;
emphsMngRgnMngNo: string;
floodWtlvFrqVl: number | string | null;
minElvtVl: number | string | null;
maxElvtVl: number | string | null;
};
export type SbmrgDngrMapTableRow = SbmrgDngrMapApiItem & {
rowNo: number;
ctpvNm: string;
sggNm: string;
floodWtlvFrqVlDisplay: string;
elvtVlDisplay: string;
};
type AdministrativeOption = {
val: unknown;
text: unknown;
};
export const SBMRG_DNGR_MAP_DEFAULT_FILTERS: SbmrgDngrMapFilters = {
ctpvCd: "0",
sggCd: "0",
floodWtlvFrqVl: "0",
};
export const SBMRG_DNGR_MAP_FREQUENCY_OPTIONS = [30, 50, 80, 100];
export const SBMRG_DNGR_MAP_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "NO", widthPercent: 7, sortable: true, sortKey: "rowNo" },
{ id: "ctpvNm", title: "시도", widthPercent: 11, sortable: true, sortKey: "ctpvCd" },
{ id: "sggNm", title: "시군구", widthPercent: 12, sortable: true, sortKey: "sggCd" },
{ id: "emphsMngRgnNm", title: "중점관리지역명", widthPercent: 23, sortable: true },
{ id: "emphsMngRgnMngNo", title: "중점관리지역번호", widthPercent: 15, sortable: true },
{
id: "floodWtlvFrqVlDisplay",
title: "홍수수위빈도값",
widthPercent: 17,
sortable: true,
sortKey: "floodWtlvFrqVl",
},
{
id: "elvtVlDisplay",
title: "해발값(최소~최대)",
widthPercent: 15,
sortable: true,
sortKey: "elvtVl",
},
];
/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */
export const buildSbmrgDngrMapListQueryString = (
filters: SbmrgDngrMapFilters,
page: number,
size: number,
sort?: UiTableSort,
) => {
const params = new URLSearchParams({ page: String(page), size: String(size) });
if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5));
if (filters.floodWtlvFrqVl && filters.floodWtlvFrqVl !== "0") {
params.set("floodWtlvFrqVl", filters.floodWtlvFrqVl);
}
if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`);
return params.toString();
};
const toDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized || "-";
};
const toRangeDisplay = (minValue: unknown, maxValue: unknown) => {
const minDisplay = toDisplay(minValue);
const maxDisplay = toDisplay(maxValue);
if (minDisplay === "-" && maxDisplay === "-") return "-";
if (minDisplay === maxDisplay) return minDisplay;
return `${minDisplay} ~ ${maxDisplay}`;
};
/** 집계 응답에서 쉼표로 묶일 수 있는 행정구역 코드를 정규화한다. */
export const extractAdministrativeCodes = (value: unknown, codeLength: number) =>
String(value ?? "")
.split(",")
.map((code) => code.trim().slice(0, codeLength))
.filter(Boolean);
/** 쉼표로 묶인 행정구역 코드도 각각 한글명으로 변환하고 중복 명칭은 제거한다. */
const resolveAdministrativeNames = (
value: unknown,
options: AdministrativeOption[],
codeLength: number,
) => {
const names = extractAdministrativeCodes(value, codeLength)
.map((code) => {
const matched = options.find(
(option) => String(option.val ?? "").trim().slice(0, codeLength) === code,
);
return String(matched?.text ?? "").trim();
})
.filter(Boolean);
return names.length > 0 ? [...new Set(names)].join(", ") : "-";
};
/** 서버 페이지 번호를 화면 NO에 반영하고 숫자 표시값을 보정한다. */
export const toSbmrgDngrMapTableRows = (
items: SbmrgDngrMapApiItem[],
page: number,
pageSize = SBMRG_DNGR_MAP_PAGE_SIZE,
ctpvOptions: AdministrativeOption[] = [],
sggOptions: AdministrativeOption[] = [],
total = items.length,
sort?: UiTableSort,
): SbmrgDngrMapTableRow[] =>
items.map((item, index) => ({
...item,
rowNo:
sort?.key === "rowNo" && sort.direction === "desc"
? total - page * pageSize - index
: page * pageSize + index + 1,
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
elvtVlDisplay: toRangeDisplay(item.minElvtVl, item.maxElvtVl),
}));
const resolveNodeName = (node: any) =>
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
/** 빈도·침수심 자식 노드가 아니라 중점관리지역 위험지도 루트만 목록보기 대상으로 판별한다. */
export const isSbmrgDngrMapRootNode = (node: any) =>
resolveNodeName(node).replace(/\s/g, "") === SBMRG_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
+113 -2
View File
@@ -143,6 +143,15 @@ import {
isFldngHstryNode,
type FldngHstryItem,
} from "./components/fldngHstry/fldngHstryTypes";
import {
SBMRG_DNGR_MAP_POPUP_KEY,
isSbmrgDngrMapRootNode,
} from "./components/sbmrgDngrMap/sbmrgDngrMapTypes";
import {
FLOOD_DNGR_MAP_LAYER_TYPE,
FLOOD_DNGR_MAP_POPUP_KEY,
isFloodDngrMapRootNode,
} from "./components/floodDngrMap/floodDngrMapTypes";
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
import SearchModal from "./searchModal/searchModal";
import { WATER_DEPTH_LAYER_TYPES, extractWaterDepthLegendItems } from "./waterDepth/waterDepth";
@@ -417,6 +426,7 @@ const getLayerTypeName = (layerInfo: any) =>
layerInfo?.lyr_type_nm ??
"",
).trim();
const getLayerFilterSql = (layerInfo: any) =>
String(
layerInfo?.filtrSql ??
@@ -3148,6 +3158,71 @@ const Map = () => {
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FIT_EXTENT) {
const extent = Array.isArray(data.payload?.extent)
? data.payload.extent.map(Number)
: [];
const map = gisMaps.current;
if (extent.length !== 4 || extent.some((value: number) => !Number.isFinite(value)) || !map?.getView) {
return;
}
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
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 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>();
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;
const source = layer.getSource?.();
if (!source?.getFeatures) return;
selectLoadedGroupFeatures(source);
source.once?.("featuresloadend", () => selectLoadedGroupFeatures(source));
});
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED) {
const rawLayerTypeNames = data.payload?.refreshLayerTypeNames;
let layerTypeNames = ["tb_ms_swage_ppl"];
@@ -5579,6 +5654,24 @@ const Map = () => {
[sinkholeScope, sinkholeYearRange],
);
/** 침수위험지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */
const openSbmrgDngrMapPopupWindow = useCallback(() => {
const popup = openWindowPopup(SBMRG_DNGR_MAP_POPUP_KEY as WindowPopupKey);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
}
return popup;
}, []);
/** 도시침수지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */
const openFloodDngrMapPopupWindow = useCallback(() => {
const popup = openWindowPopup(FLOOD_DNGR_MAP_POPUP_KEY as WindowPopupKey);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
}
return popup;
}, []);
const handleOpenLayerPopup = useCallback(
(_layerNm: string, node?: any) => {
if (isAgedPipeLayerNode(node)) {
@@ -5589,6 +5682,14 @@ const Map = () => {
openSinkholePopupWindow();
return false;
}
if (isSbmrgDngrMapRootNode(node)) {
openSbmrgDngrMapPopupWindow();
return false;
}
if (isFloodDngrMapRootNode(node)) {
openFloodDngrMapPopupWindow();
return false;
}
if (isFldngHstryNode(node)) {
openFldngHstryPopupWindow();
return false;
@@ -5596,7 +5697,13 @@ const Map = () => {
return true;
},
[openAgedPipeList, openFldngHstryPopupWindow, openSinkholePopupWindow],
[
openAgedPipeList,
openFldngHstryPopupWindow,
openFloodDngrMapPopupWindow,
openSbmrgDngrMapPopupWindow,
openSinkholePopupWindow,
],
);
// 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다.
@@ -7859,7 +7966,11 @@ const Map = () => {
treeTogglePosition="center"
zoomGuide={zoomGuide}
layerIconVisible={(node: any) =>
isAgedPipeLayerNode(node) || isSinkholeRootNode(node) || isFldngHstryNode(node)
isAgedPipeLayerNode(node)
|| isSinkholeRootNode(node)
|| isSbmrgDngrMapRootNode(node)
|| isFloodDngrMapRootNode(node)
|| isFldngHstryNode(node)
}
defaultZoom={7.5}
@@ -0,0 +1,67 @@
# SN 도시침수지도(정보제공포털) 목록보기 통합 검증 계획
## 1. 문서 목적
- 도시침수지도 목록보기와 WMS·SLD 일반 표출의 정합성·성능·회귀 여부를 검증한다.
## 2. 검증 범위
- `sm_bs_sn.tb_sn_lyr_stng` 부모 3건과 자식 15건
- 30·50·80년 레이어 트리 ON/OFF
- TileWMS 요청 파라미터와 SLD 규칙
- 기존 침수심도 범례 색상·투명도·선 두께
- 기존 WFS 레이어 회귀
- 목록 팝업과 행 선택 지도 이동 회귀
## 3. 검증 기준
| 영역 | 통과 기준 |
|---|---|
| 부모 설정 | 58·59·60이 `wms/Y`, 빈도 필터가 각각 `080/030/050` |
| 자식 설정 | 부모별 5건, 전체 `wms/N`, `N330~N334` 각 1건 |
| 화면 | 30·50·80년 각각 ON/OFF 즉시 반영 |
| WMS | `GetMap`, PNG, 투명 배경, EPSG:5179 |
| 빈도 필터 | 선택 빈도에 맞는 `CQL_FILTER` |
| SLD | 접두사 없는 NamedLayer와 침수심도 Rule 5개 |
| 스타일 | DB의 면색·선색·opacity·선 두께와 일치 |
| 회귀 | 다른 WFS 레이어와 목록 팝업 기능 유지 |
| 성능 | 전국 초기 진입 시 대용량 GeoJSON 파싱·벡터 렌더링 없음 |
## 4. 검증 절차
### 4.1 정적·빌드 검증
1. SLD 생성 헬퍼에 자식 5건을 입력한다.
2. Rule 5개, 색상·opacity, NamedLayer, 누락·중복 차단을 확인한다.
3. 변경 파일 ESLint를 실행한다.
4. `@common/business_components`를 빌드한다.
### 4.2 DB 검증
1. 부모 58·59·60의 필터·서비스·실제 레이어 여부를 조회한다.
2. 부모별 자식 수와 `wms/N` 개수를 집계한다.
3. `N330~N334`가 부모별 1건인지 확인한다.
### 4.3 브라우저 검증
1. 30·50·80년을 각각 단독 ON/OFF한다.
2. Network에서 `GetMap`과 `tb_ms_flood_dngr_map`을 필터링한다.
3. `CQL_FILTER`가 각각 `030`, `050`, `080`인지 확인한다.
4. `SLD_BODY`에 `N330~N334`와 기존 색상값이 있는지 확인한다.
5. 응답 상태 200과 `image/png`을 확인한다.
6. 도시침수지도 관련 WFS `GetFeature`가 발생하지 않는지 확인한다.
7. 목록 팝업 검색·행 선택 지도 이동을 회귀 확인한다.
## 5. 관측 포인트
- 논리 WMS 레이어 수와 타일 HTTP 요청 수를 구분한다.
- GeoServer가 SLD 레이어명을 무시하고 기본 검정 스타일을 반환하지 않는지 결과 이미지를 확인한다.
- 자식 체크 상태는 범례 유지용이며 지도 레이어 객체가 생성되면 실패다.
## 6. 다음 검증 순서
- 개발환경 검증 완료 후 운영 프론트 배포 → 운영 DB DML → 운영 Network·체감 성능 순으로 확인한다.
## 7. 흡수한 기존 문서
- 별도 SN 검증 계획 문서는 없었다.
@@ -0,0 +1,69 @@
# SN 도시침수지도(정보제공포털) 목록보기 통합 검증 결과
## 1. 문서 목적
- 2026-08-05 WMS·SLD 전환 구현의 실제 수행 검증과 현재 완료 상태를 기록한다.
## 2. 수행한 검증
### 2.1 코드 검증
| 항목 | 결과 | 비고 |
|---|---|---|
| SLD Rule 생성 | 통과 | `N330~N334` 5개 |
| RGBA 변환 | 통과 | 색상과 fill/stroke opacity 분리 |
| 설정 누락·중복 차단 | 통과 | 정확히 5건이 아니면 생성 중단 |
| ESLint | 통과 | 오류 0건, 기존 import 정렬 경고만 존재 |
| `@common/business_components` 빌드 | 통과 | 의존 작업 포함 4/4 성공 |
| `git diff --check` | 통과 | whitespace 오류 없음 |
### 2.2 DB 검증
| 부모 | 빈도 필터 | 부모 상태 | 자식 수 | 자식 WMS 수 | 자식 메타데이터 수 | 결과 |
|---:|---|---|---:|---:|---:|---|
| 58 | `080` | `wms/Y` | 5 | 5 | 5 | 정상 |
| 59 | `030` | `wms/Y` | 5 | 5 | 5 | 정상 |
| 60 | `050` | `wms/Y` | 5 | 5 | 5 | 정상 |
- 각 부모의 자식은 `N330~N334`가 1건씩 존재했다.
- 자식의 `exprs_yn=N`, `actl_lyr_yn=N`, 기존 필터·색상·선 두께가 유지됐다.
- 100년 빈도는 전환 대상에 포함되지 않았다.
### 2.3 화면·네트워크 검증
- 사용자가 30·50·80년 레이어 ON/OFF 정상 동작을 확인했다.
- 30년 실제 요청에서 다음 값을 확인했다.
- `SERVICE=WMS`
- `VERSION=1.1.1`
- `REQUEST=GetMap`
- `LAYERS=hasudo:tb_ms_flood_dngr_map`
- `SRS=EPSG:5179`
- `FORMAT=image/png`
- `TRANSPARENT=true`
- `CQL_FILTER=sbmrg_frq_expln='030'`
- SLD `NamedLayer/Name=tb_ms_flood_dngr_map`
- SLD Rule `N330~N334` 5개
- DB와 동일한 채움색·경계선색·opacity·선 두께
## 3. 현재 검증 상태
- 개발 DB 전환, 화면 ON/OFF, 30년 WMS·SLD 요청, 공통 패키지 빌드까지 정상이다.
- 일반 표출의 최종 구조는 부모 3개 TileWMS와 자식 15개 SLD·범례 메타데이터로 확정한다.
- 기능 상태는 `완료`로 기록하고 운영 반영 확인은 배포 절차 점검으로 관리한다.
## 4. 미수행·운영 확인 항목
- 50년·80년의 개별 Network 파라미터 캡처는 남아 있다.
- 응답 헤더의 `Content-Type=image/png` 별도 캡처는 남아 있다.
- 전체 SN Next 빌드는 실행 중인 개발 서버 보호를 위해 수행하지 않았다.
- 운영 환경의 WFS 미발생과 최초 화면 체감 성능은 운영 적용 직후 확인한다.
## 5. 롤백 기준
- DB는 부모를 `wfs/N` 및 빈 필터로, 자식을 `wfs/Y`로 복원한다.
- 프론트는 `gis.wms-sld.js`와 WMS 연동 변경을 되돌리면 기존 WFS 구조로 복귀한다.
- 자식 필터·범례 색상값은 롤백 시에도 보존한다.
## 6. 흡수한 기존 문서
- 별도 SN 검증 결과 문서는 없었다.
@@ -0,0 +1,83 @@
# SN 도시침수지도(정보제공포털) 목록보기 통합 구현 기록
## 1. 문서 목적
- 도시침수지도 목록보기와 일반 지도 표출의 현재 구현 상태를 기능 단위로 통합 기록한다.
- 본 문서에는 2026-08-05 maintenance에서 확정된 WMS·SLD 전환을 최종 일반 표출 기준으로 기록한다.
## 2. 입력 근거
- 공통 설계: `IIPBS/docs/작업계획/SN-도시침수지도-정보제공포털-목록보기/001-de-SN-도시침수지도-정보제공포털-목록보기-설계.md`
- WMS·SLD 매뉴얼: `IIPBS/docs/작업계획/공통-GeoServer-WMS-SLD적용/001-공통-GeoServer-WMS-SLD적용-매뉴얼.md`
- 레이어 설정: `sm_bs_sn.tb_sn_lyr_stng`
- 대상 레이어: `tb_ms_flood_dngr_map`
## 3. 공통 계약
- 화면 노출 빈도는 30년, 50년, 80년이며 100년은 추가하지 않는다.
- 부모 59·60·58은 각각 빈도 `030`, `050`, `080`의 실제 WMS 레이어다.
- 자식 15건은 `actlLyrYn='N'`으로 유지하며 침수심도 SLD와 범례의 메타데이터로 사용한다.
- 백엔드는 기존 레이어 설정 응답 계약을 유지한다.
## 4. 구현 라운드 요약
### 4.1 2026-08-04 목록보기 구현
- 도시침수지도 목록 팝업, 검색·페이징·정렬, 그룹 범위 이동을 구현했다.
- 목록 데이터와 범위 조회는 대용량 원본 GeoJSON을 직접 반환하지 않는 구조로 정리했다.
- 이 라운드의 일반 지도 WFS 복원 기록은 2026-08-05 WMS 전환으로 대체한다.
### 4.2 2026-08-05 일반 지도 WMS·SLD 전환
- `gis.wms-sld.js`
- 부모 번호로 자식 레이어 설정 5건을 선별하고 순서를 정렬한다.
- 자식 필터에서 `N330~N334`를 추출해 SLD `Rule` 5개를 만든다.
- RGBA를 16진 색상과 opacity로 변환하고 XML 특수문자를 이스케이프한다.
- 정확히 5건의 유효 설정이 아닐 때 `null`을 반환해 잘못된 스타일 표출을 차단한다.
- `gis.layer.js`
- TileWMS 요청에 `CQL_FILTER`, `SLD_BODY`, PNG 투명 배경을 적용한다.
- SLD 내부 레이어명은 접두사 없는 `tb_ms_flood_dngr_map`을 사용한다.
- WMS 레이어의 `drawOrdr`, `minViewSize`, 문자열 ID를 정상 반영한다.
- `gis.core.js`
- SLD 검증 실패로 WMS 레이어가 생성되지 않은 경우 지도 추가를 안전하게 중단한다.
- `GisTree.js`
- 실제 WMS 부모를 자동 재생 대상에 포함한다.
- 숨겨진 자식은 범례 메타데이터로 유지하되 실제 레이어와 자동 재생 대상에서는 제외한다.
## 5. 기능별 현재 상태
| 기능 | 현재 상태 |
|---|---|
| 목록 팝업 | 기존 구현 유지 |
| 행 선택 지도 이동 | 기존 그룹 범위 이동 유지 |
| 일반 지도 통신 | 빈도 부모 3개 TileWMS |
| 빈도 제한 | 부모 `flterSql`을 WMS `CQL_FILTER`로 전달 |
| 침수심도 스타일 | 자식 5건 기반 동적 SLD |
| 범례 | 숨겨진 자식 설정의 기존 색상·명칭 유지 |
| 100년 빈도 | 화면 및 WMS 전환 대상에서 제외 |
| 기존 다른 레이어 | 기존 WFS 경로 유지 |
## 6. BE 공유 사항
- 신규 API나 응답 필드는 필요하지 않다.
- 레이어 설정 DTO의 `flterSql`, `upperLyrSn`, `lnColor`, `polgnColor`, `lnWdth`를 계속 제공해야 한다.
- DB 전환 DML은 공통 `IIPBS/docs/DB작업.sql`에서 관리한다.
## 7. 검증 요약
- SLD 단위검사 통과
- ESLint 오류 0건
- `@common/business_components` 빌드 성공
- 개발 DB 부모 3건·자식 15건 정합성 정상
- 30·50·80년 ON/OFF 정상
- 30년 WMS 요청과 SLD 규칙 5개 정상
## 8. 남은 리스크
- TileWMS는 논리 레이어 1개당 화면 타일 수만큼 `GetMap`을 요청하므로 HTTP 요청 개수를 정확히 3개로 판단하면 안 된다.
- 운영 프록시의 URL 길이 제한과 GeoServer `SLD_BODY` 허용 설정은 운영 반영 시 재확인한다.
- 전체 SN Next 빌드는 개발 서버 종료 후 별도 수행한다.
## 9. 흡수한 기존 문서
- 별도 SN 기능 문서는 없었다. 공통 2026-08-04 바이브코딩 기록의 목록보기 핵심 상태를 본 문서에 요약했다.
@@ -130,6 +130,18 @@ export const windowPopupDefinitions = {
width: 1380,
height: 820,
},
sbmrgDngrMapPopup: {
key: "sbmrgDngrMapPopup",
title: "침수위험지도 목록",
width: 1160,
height: 720,
},
floodDngrMapPopup: {
key: "floodDngrMapPopup",
title: "도시침수지도 목록",
width: 1160,
height: 720,
},
pipeAnalysisPopup: {
key: "pipeAnalysisPopup",
title: "관로분석",
@@ -38,6 +38,8 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple",
MAP_SEARCH_FIT_EXTENT: "sn:popup:mapSearchFitExtent",
MAP_SEARCH_SHOW_FLOOD_GROUP: "sn:popup:mapSearchShowFloodGroup",
GIS_EDIT_SESSION_READY: "sn:popup:gisEditSessionReady",
GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync",
GIS_EDIT_SESSION_SHOW_INLINE: "sn:popup:gisEditSessionShowInline",
@@ -18,6 +18,8 @@ import ReportFilesPopupContent from "./ui/ReportFilesPopupContent";
import ReportTabPopupContent from "./ui/ReportTabPopupContent";
import SearchModalPopupContent from "./ui/SearchModalPopupContent";
import SinkholePopupContent from "./ui/SinkholePopupContent";
import SbmrgDngrMapPopupContent from "./ui/SbmrgDngrMapPopupContent";
import FloodDngrMapPopupContent from "./ui/FloodDngrMapPopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
import type { WindowPopupContentProps } from "./model/types";
import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent";
@@ -111,6 +113,14 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.sinkholePopup,
component: SinkholePopupContent,
},
sbmrgDngrMapPopup: {
...windowPopupDefinitions.sbmrgDngrMapPopup,
component: SbmrgDngrMapPopupContent,
},
floodDngrMapPopup: {
...windowPopupDefinitions.floodDngrMapPopup,
component: FloodDngrMapPopupContent,
},
pipeAnalysisPopup: {
...windowPopupDefinitions.pipeAnalysisPopup,
component: PipeAnalysisPopupContent,
@@ -0,0 +1,7 @@
import FloodDngrMapPopupPage from "@/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage";
import type { WindowPopupContentProps } from "../model/types";
/** 도시침수지도 목록 WindowPopup을 실제 목록 화면에 연결한다. */
export default function FloodDngrMapPopupContent(_props: WindowPopupContentProps) {
return <FloodDngrMapPopupPage />;
}
@@ -0,0 +1,7 @@
import SbmrgDngrMapPopupPage from "@/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage";
import type { WindowPopupContentProps } from "../model/types";
/** 침수위험지도 목록 WindowPopup을 실제 목록 화면에 연결한다. */
export default function SbmrgDngrMapPopupContent(_props: WindowPopupContentProps) {
return <SbmrgDngrMapPopupPage />;
}
@@ -346,7 +346,13 @@ export default React.memo(function GisTree({
// orderedLayers 순서를 우선 적용하면서, 타입 필터에 맞는 타겟 노드만 선별
const getTargetNodesInOrder = () => {
const target = nodes.filter((n) => n.data?.actlLyrYn === 'N' && layerTypes.includes(n.data?.lyrTyNm));
const target = nodes.filter((node) => {
const hasChildren = nodes.some((child) => child.parent === node.id);
const isWmsGroupLayer = node.data?.srvcNm === 'wms' && hasChildren;
return node.data?.exprsYn === 'Y'
&& layerTypes.includes(node.data?.lyrTyNm)
&& (node.data?.actlLyrYn === 'N' || isWmsGroupLayer);
});
// drawOrdr 값을 기준으로 오름차순 정렬
target.sort((a, b) => {
@@ -392,6 +392,7 @@ GisApp.core.prototype = {
*/
addWmsLayer: function (layerCodeObj) {
let layer = GisApp.Module.layer.getWmsLayer(layerCodeObj);
if (!layer) return null;
this.deleteLayer(layer.get('id'));
this.map.addLayer(layer);
return layer;
@@ -20,6 +20,11 @@ import GisOlStyle from './gis.style.js';
import Tile from 'ol/layer/Tile.js';
import TileWMS from 'ol/source/TileWMS.js';
import gisCore from './gis.core.js';
import {
buildFloodDngrSldBody,
FLOOD_DNGR_MAP_LAYER_TYPE,
getFloodDngrSldSettings,
} from './gis.wms-sld.js';
/**
* GisApp.layer
@@ -179,6 +184,7 @@ GisOlLayer.prototype = {
let url = GisApp.GEOSERVER_WMS_URL;
let toProj = GisApp.TO_PROJ;
const lyrTyNm = options.lyrTyNm;
const filter = String(options?.flterSql ?? '').trim();
let params = {};
params['LAYERS'] = this.storageNm + lyrTyNm;
params['SERVICE'] = 'WMS';
@@ -186,7 +192,21 @@ GisOlLayer.prototype = {
params['REQUEST'] = 'GetMap';
params['SRS'] = toProj;
params['FORMAT'] = 'image/png';
params['TRANSPARENT'] = true;
params['TILED'] = true;
params['STYLES'] = '';
if (filter) params['CQL_FILTER'] = filter;
if (lyrTyNm === FLOOD_DNGR_MAP_LAYER_TYPE) {
const sldSettings = getFloodDngrSldSettings(GisApp.layerCode, options?.id);
const sldBody = buildFloodDngrSldBody(lyrTyNm, sldSettings);
if (!sldBody) {
console.warn('[GisApp] 도시침수지도 WMS SLD 생성에 필요한 침수심도 설정 5건이 유효하지 않습니다.', options);
return null;
}
params['SLD_BODY'] = sldBody;
}
const wmsLayer = new Tile({
...layerOptions,
id: layerOptions?.id,
@@ -196,8 +216,9 @@ GisOlLayer.prototype = {
serverType: 'geoserver',
}),
});
wmsLayer.setZIndex(options.draw0rdr);
wmsLayer.setMinZoom(options.minViewSize);
wmsLayer.set('id', String(layerOptions?.id));
wmsLayer.setZIndex(Number(options?.drawOrdr) || 1);
wmsLayer.setMinZoom(Number(options?.minViewSize) || 0);
this.layers.push(wmsLayer);
return wmsLayer;
},
@@ -29,9 +29,11 @@ GisOlSelect.prototype = {
dragBox: null,
dragPolygon: null,
selectedFeatures: [],
focusedBoundaryFeatures: null,
selectFeatureFlag: false,
initialize: function (map, options) {
this.map = map;
this.focusedBoundaryFeatures = new WeakSet();
Object.assign(this.defaults, options);
this.setInteraction();
this.pointerMoveEventHandler();
@@ -107,7 +109,8 @@ GisOlSelect.prototype = {
}
}
const geometryType = feature?.getGeometry?.()?.getType?.();
if (geometryType === 'LineString' || geometryType === 'MultiLineString') {
const focusedBoundary = me.focusedBoundaryFeatures.has(feature);
if (focusedBoundary || geometryType === 'LineString' || geometryType === 'MultiLineString') {
styles.push(...focusedLineStyle);
} else {
styles.push(baseStyle);
@@ -329,6 +332,7 @@ GisOlSelect.prototype = {
selectedFeatures.remove(feature);
});
selectedFeatures.clear();
this.focusedBoundaryFeatures = new WeakSet();
},
/**
* SELECT 이벤트 활성화
@@ -491,6 +495,20 @@ GisOlSelect.prototype = {
auto: auto,
});
},
/**
* 폴리곤을 선형 피처와 동일한 다중 경계 스타일로 강조한다.
*/
selectFocusedBoundaryFeature: function (feature, auto) {
let me = this;
me.focusedBoundaryFeatures.add(feature);
me.selectedFeatures.push(feature);
me.selectInteraction.dispatchEvent({
type: 'select',
selected: me.selectedFeatures,
deselected: [],
auto: auto,
});
},
};
export default GisOlSelect;
@@ -0,0 +1,103 @@
const FLOOD_DNGR_MAP_LAYER_TYPE = 'tb_ms_flood_dngr_map';
const FLOOD_DEPTH_ATTRIBUTE = 'sbmrg_dpthl_cd';
const FLOOD_DEPTH_CODES = ['N330', 'N331', 'N332', 'N333', 'N334'];
const escapeXml = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
const toHex = (value) => Math.round(value).toString(16).padStart(2, '0');
const parseCssColor = (value) => {
const normalized = String(value ?? '').trim();
const rgbaMatch = normalized.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?))?\s*\)$/i);
if (rgbaMatch) {
const red = Number(rgbaMatch[1]);
const green = Number(rgbaMatch[2]);
const blue = Number(rgbaMatch[3]);
const opacity = rgbaMatch[4] == null ? 1 : Number(rgbaMatch[4]);
if ([red, green, blue].some((channel) => channel < 0 || channel > 255) || opacity < 0 || opacity > 1) {
return null;
}
return {
hex: `#${toHex(red)}${toHex(green)}${toHex(blue)}`,
opacity,
};
}
const hexMatch = normalized.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!hexMatch) return null;
const rawHex = hexMatch[1].length === 3
? hexMatch[1].split('').map((character) => character + character).join('')
: hexMatch[1];
return {
hex: `#${rawHex.toLowerCase()}`,
opacity: 1,
};
};
const getFloodDepthCode = (filterSql) => {
const match = String(filterSql ?? '').match(/\bsbmrg_dpthl_cd\s*=\s*'([^']+)'/i);
const depthCode = match?.[1]?.trim();
return FLOOD_DEPTH_CODES.includes(depthCode) ? depthCode : null;
};
const getLineWidth = (value) => {
const lineWidth = Number(value);
return Number.isFinite(lineWidth) && lineWidth >= 0 && lineWidth <= 20 ? lineWidth : null;
};
/**
* 빈도 부모 레이어 바로 아래에 있는 침수심도 설정만 선별한다.
* 부모는 WMS 요청을 담당하고, 반환된 자식 5건은 SLD 규칙의 필터와 색상 원본으로 사용한다.
*/
export const getFloodDngrSldSettings = (layerCode, parentLayerId) => Object.values(layerCode ?? {})
.filter((layerSetting) => (
String(layerSetting?.upperLyrSn ?? '') === String(parentLayerId ?? '')
&& layerSetting?.lyrTyNm === FLOOD_DNGR_MAP_LAYER_TYPE
))
.sort((left, right) => Number(left?.sortOrdr ?? 0) - Number(right?.sortOrdr ?? 0));
/**
* DB의 침수심도 필터와 RGBA 스타일을 GeoServer가 해석할 수 있는 SLD 1.0 XML로 변환한다.
* 다섯 침수심도 코드나 색상값 중 하나라도 유효하지 않으면 잘못된 기본 스타일 표출을 막기 위해 null을 반환한다.
*/
export const buildFloodDngrSldBody = (layerName, layerSettings) => {
if (!Array.isArray(layerSettings) || layerSettings.length !== FLOOD_DEPTH_CODES.length) return null;
const settingsByDepthCode = new Map();
for (const layerSetting of layerSettings) {
const depthCode = getFloodDepthCode(layerSetting?.flterSql);
if (depthCode && !settingsByDepthCode.has(depthCode)) {
settingsByDepthCode.set(depthCode, layerSetting);
}
}
if (settingsByDepthCode.size !== FLOOD_DEPTH_CODES.length) return null;
const rules = [];
for (const depthCode of FLOOD_DEPTH_CODES) {
const layerSetting = settingsByDepthCode.get(depthCode);
const fillColor = parseCssColor(layerSetting?.polgnColor);
const strokeColor = parseCssColor(layerSetting?.lnColor);
const lineWidth = getLineWidth(layerSetting?.lnWdth);
if (!fillColor || !strokeColor || lineWidth == null) return null;
rules.push(
`<sld:Rule><sld:Name>${escapeXml(depthCode)}</sld:Name><sld:Title>${escapeXml(layerSetting?.lyrNm || depthCode)}</sld:Title>`
+ `<ogc:Filter><ogc:PropertyIsEqualTo><ogc:PropertyName>${FLOOD_DEPTH_ATTRIBUTE}</ogc:PropertyName><ogc:Literal>${escapeXml(depthCode)}</ogc:Literal></ogc:PropertyIsEqualTo></ogc:Filter>`
+ `<sld:PolygonSymbolizer><sld:Fill><sld:CssParameter name="fill">${fillColor.hex}</sld:CssParameter><sld:CssParameter name="fill-opacity">${fillColor.opacity}</sld:CssParameter></sld:Fill>`
+ `<sld:Stroke><sld:CssParameter name="stroke">${strokeColor.hex}</sld:CssParameter><sld:CssParameter name="stroke-opacity">${strokeColor.opacity}</sld:CssParameter><sld:CssParameter name="stroke-width">${lineWidth}</sld:CssParameter></sld:Stroke></sld:PolygonSymbolizer></sld:Rule>`
);
}
return `<?xml version="1.0" encoding="UTF-8"?>`
+ `<sld:StyledLayerDescriptor version="1.0.0" xmlns="http://www.opengis.net/sld" xmlns:sld="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc">`
+ `<sld:NamedLayer><sld:Name>${escapeXml(layerName)}</sld:Name><sld:UserStyle><sld:FeatureTypeStyle>${rules.join('')}</sld:FeatureTypeStyle></sld:UserStyle></sld:NamedLayer>`
+ `</sld:StyledLayerDescriptor>`;
};
export { FLOOD_DNGR_MAP_LAYER_TYPE };