Merge branch 'iipbs/sn/dev_kic' into iipbs/sn/dev
This commit is contained in:
@@ -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, "");
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user