침수흔적도 목록/지도 상세 팝업 mock 구현 및 md 업데이트
This commit is contained in:
@@ -5,7 +5,6 @@ import {
|
||||
ChartColumnBig,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
History,
|
||||
PanelsTopLeft,
|
||||
PencilRuler,
|
||||
Search,
|
||||
@@ -31,7 +30,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
{ key: "infoSearch", label: "정보검색", icon: Search },
|
||||
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
|
||||
{ key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves },
|
||||
{ key: "floodHistoryPopup", label: "침수발생이력", icon: History },
|
||||
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
|
||||
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
|
||||
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./floodHistoryTypes";
|
||||
|
||||
type Props = {
|
||||
detail: FloodHistoryItem;
|
||||
onClose: () => void;
|
||||
position: {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
};
|
||||
|
||||
type DetailTab = "year" | "disaster" | "rainfall";
|
||||
|
||||
const renderRows = (rows: Array<[string, string]>) => (
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[42%]" />
|
||||
<col className="w-[58%]" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{rows.map(([label, value], index) => (
|
||||
<tr key={`${label}-${index}`}>
|
||||
<th className="border border-[#d8e3ee] bg-[#f8fbfe] px-3 py-[7px] text-left text-[1.02rem] font-semibold text-[#36506b]">
|
||||
{label}
|
||||
</th>
|
||||
<td className="border border-[#d8e3ee] bg-white px-3 py-[7px] text-[1.02rem] text-[#1f2937]">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) {
|
||||
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("year");
|
||||
|
||||
const yearRows = useMemo(
|
||||
() => [
|
||||
["침수심", `${detail.floodDepthM.toFixed(2)} m`],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수년도", `${detail.floodYear}년`],
|
||||
["등급", detail.gradeLabel],
|
||||
["침수구역", detail.districtName],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const disasterRows = useMemo(
|
||||
() => [
|
||||
["침수재해명", detail.disasterName],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수원인상세명", detail.causeDetailName],
|
||||
["침수시작년월일", detail.startDate],
|
||||
["침수종료년월일", detail.endDate],
|
||||
["침수시작시간", detail.startTime],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const rainfallRows = useMemo(
|
||||
() => [
|
||||
["침수시작일자", detail.startDate],
|
||||
["침수종료일자", detail.endDate],
|
||||
["침수시작시간", detail.startTime],
|
||||
["침수종료시간", detail.endTime],
|
||||
["침수시간", detail.floodHour],
|
||||
["강우시작시간", detail.rainStartTime],
|
||||
["강우종료시간", detail.rainEndTime],
|
||||
["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`],
|
||||
["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`],
|
||||
["침수시간", detail.floodHour],
|
||||
["침수원인상세코드", detail.causeDetailCode],
|
||||
["피해금액", detail.damageAmount],
|
||||
] as Array<[string, string]>,
|
||||
[detail],
|
||||
);
|
||||
|
||||
const currentRows = activeTab === "year"
|
||||
? yearRows
|
||||
: activeTab === "disaster"
|
||||
? disasterRows
|
||||
: rainfallRows;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-[#cfdbe8] bg-[rgba(255,255,255,0.98)] shadow-[0_14px_28px_rgba(15,23,42,0.18)]"
|
||||
style={{
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-[28px] items-center justify-between border-b border-[#d8e3ee] bg-[linear-gradient(180deg,#ffffff_0%,#f6faff_100%)] px-[10px]">
|
||||
<div className="text-[10px] font-bold tracking-[-0.01em] text-[#243b53]">상세정보</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-[4px] px-[4px] text-[13px] leading-none text-[#7b8ba1] transition hover:bg-[#eef5fb] hover:text-[#35597d]"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-[#d8e3ee] bg-[#f8fbfe] px-[6px] pt-[6px]">
|
||||
<div className="flex gap-[4px]">
|
||||
{[
|
||||
["year", "년도탭"],
|
||||
["disaster", "재해명탭"],
|
||||
["rainfall", "침수발생강우탭"],
|
||||
].map(([tabId, label]) => {
|
||||
const isActive = activeTab === tabId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tabId}
|
||||
type="button"
|
||||
className={`h-[24px] flex-1 rounded-t-[6px] border px-[6px] text-[9px] font-semibold leading-none transition ${
|
||||
isActive
|
||||
? "border-[#b8d4f0] border-b-white bg-white text-[#1f5d99]"
|
||||
: "border-[#d8e3ee] bg-[#f3f7fb] text-[#5f7388] hover:bg-[#eef5fb]"
|
||||
}`}
|
||||
onClick={() => setActiveTab(tabId as DetailTab)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-[6px]">
|
||||
<div className="overflow-hidden rounded-[6px] border border-[#d8e3ee] bg-white">
|
||||
{renderRows(currentRows)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import useStore from "@/store/useStore";
|
||||
import {
|
||||
buildFloodHistoryYearOptions,
|
||||
buildFloodHistoryTableRows,
|
||||
FLOOD_HISTORY_DEFAULT_FILTERS,
|
||||
FLOOD_HISTORY_DOWNLOAD_HEADERS,
|
||||
FLOOD_HISTORY_ITEMS,
|
||||
FLOOD_HISTORY_PAGE_SIZE,
|
||||
FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
formatFloodArea,
|
||||
formatFloodDepth,
|
||||
normalizeFloodHistoryFilters,
|
||||
type FloodHistoryFilters,
|
||||
type FloodHistoryItem,
|
||||
} from "./floodHistoryTypes";
|
||||
|
||||
const resolveOptionText = (
|
||||
code: string,
|
||||
options: Array<{ text: string; val: string | number }>,
|
||||
prefixLength?: number,
|
||||
) => {
|
||||
const normalized = String(code ?? "").trim();
|
||||
if (!normalized || normalized === "0") return "";
|
||||
const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized;
|
||||
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
|
||||
};
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ id: "rowNo", title: "NO", width: "64px", align: "center" },
|
||||
{ id: "ctpvNm", title: "시도", width: "90px", align: "center" },
|
||||
{ id: "sggNm", title: "시군구", width: "100px", align: "center" },
|
||||
{ id: "startDateDisplay", title: "침수시작일자", width: "118px", align: "center" },
|
||||
{ id: "endDateDisplay", title: "침수종료일자", width: "118px", align: "center" },
|
||||
{ id: "startTimeDisplay", title: "침수시작시간", width: "98px", align: "center" },
|
||||
{ id: "endTimeDisplay", title: "침수종료시간", width: "98px", align: "center" },
|
||||
{ id: "floodYearDisplay", title: "침수년도", width: "96px", align: "center" },
|
||||
{ id: "floodHourDisplay", title: "침수시간", width: "100px", align: "center" },
|
||||
{ id: "disasterName", title: "침수재해명", width: "190px", align: "left" },
|
||||
{ id: "districtNameDisplay", title: "침수지구명", width: "150px", align: "left" },
|
||||
{ id: "causeCodeDisplay", title: "침수원인상세코드", width: "132px", align: "center" },
|
||||
{ id: "causeDetailDisplay", title: "침수원인상세", width: "190px", align: "left" },
|
||||
{ id: "avgFloodDepthDisplay", title: "평균침수위", width: "104px", align: "right" },
|
||||
{ id: "floodAreaDisplay", title: "침수면적", width: "104px", align: "right" },
|
||||
{ id: "exmnYearDisplay", title: "조사년도", width: "96px", align: "center" },
|
||||
{ id: "damageAmountDisplay", title: "피해금액", width: "132px", align: "right" },
|
||||
] as const;
|
||||
|
||||
const buildDownloadRows = (rows: FloodHistoryItem[]) =>
|
||||
rows.map((row, index) => ({
|
||||
rowNo: index + 1,
|
||||
ctpvNm: row.ctpvNm,
|
||||
sggNm: row.sggNm,
|
||||
disasterName: row.disasterName,
|
||||
exmnYearDisplay: `${row.exmnYear}년`,
|
||||
avgFloodDepthDisplay: formatFloodDepth(row.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFloodArea(row.floodAreaHa),
|
||||
causeDetailDisplay: row.causeDetailName,
|
||||
startDateDisplay: row.startDate,
|
||||
endDateDisplay: row.endDate,
|
||||
startTimeDisplay: row.startTime,
|
||||
endTimeDisplay: row.endTime,
|
||||
causeCodeDisplay: row.causeDetailCode,
|
||||
districtNameDisplay: row.districtName,
|
||||
damageAmountDisplay: row.damageAmount,
|
||||
floodHourDisplay: row.floodHour,
|
||||
floodYearDisplay: `${row.floodYear}년`,
|
||||
}));
|
||||
|
||||
export default function FloodHistoryPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
// 조회조건 draft는 popup 내부에서 사용자가 편집 중인 값이다.
|
||||
const [filters, setFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
|
||||
// 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다.
|
||||
const [appliedFilters, setAppliedFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedHistoryId, setSelectedHistoryId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const floodYearOptions = useMemo(
|
||||
() => buildFloodHistoryYearOptions(FLOOD_HISTORY_ITEMS),
|
||||
[],
|
||||
);
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
|
||||
[filters.ctpvCd, sgg],
|
||||
);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalized = normalizeFloodHistoryFilters(appliedFilters);
|
||||
const selectedCtpvName = resolveOptionText(normalized.ctpvCd, ctpv as any, 2);
|
||||
const selectedSggName = resolveOptionText(normalized.sggCd, sgg as any, 5);
|
||||
const disasterToken = normalized.disasterName.toLocaleLowerCase("ko-KR");
|
||||
|
||||
return FLOOD_HISTORY_ITEMS.filter((item) => {
|
||||
if (selectedCtpvName && item.ctpvNm !== selectedCtpvName) return false;
|
||||
if (selectedSggName && item.sggNm !== selectedSggName) return false;
|
||||
if (normalized.floodYear && item.floodYear !== normalized.floodYear) return false;
|
||||
if (
|
||||
disasterToken &&
|
||||
!item.disasterName.toLocaleLowerCase("ko-KR").includes(disasterToken) &&
|
||||
!item.districtName.toLocaleLowerCase("ko-KR").includes(disasterToken)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).sort((left, right) => {
|
||||
if (left.floodYear !== right.floodYear) {
|
||||
return Number(right.floodYear) - Number(left.floodYear);
|
||||
}
|
||||
return left.disasterName.localeCompare(right.disasterName, "ko");
|
||||
});
|
||||
}, [appliedFilters, ctpv, sgg]);
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = (currentPage - 1) * FLOOD_HISTORY_PAGE_SIZE;
|
||||
const end = start + FLOOD_HISTORY_PAGE_SIZE;
|
||||
return buildFloodHistoryTableRows(
|
||||
filteredRows.slice(start, end),
|
||||
currentPage,
|
||||
FLOOD_HISTORY_PAGE_SIZE,
|
||||
);
|
||||
}, [currentPage, filteredRows]);
|
||||
|
||||
const handleSearch = () => {
|
||||
const normalized = normalizeFloodHistoryFilters(filters);
|
||||
|
||||
if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") {
|
||||
alert("시도를 먼저 선택하세요.");
|
||||
return;
|
||||
}
|
||||
if (normalized.floodYear && normalized.floodYear.length !== 4) {
|
||||
alert("침수발생년도는 4자리 연도로 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setAppliedFilters(normalized);
|
||||
setCurrentPage(1);
|
||||
setSelectedHistoryId("");
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (filteredRows.length === 0) {
|
||||
alert("다운로드할 침수발생이력 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: buildDownloadRows(filteredRows) as unknown as JSON[],
|
||||
fileName: `침수흔적도_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||||
sheetNameArray: ["침수흔적도"],
|
||||
headerRange: "A1:Q1",
|
||||
headerMapping: FLOOD_HISTORY_DOWNLOAD_HEADERS,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRowDoubleClick = (row: FloodHistoryItem) => {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
openDetail: false,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
window.close();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: FloodHistoryItem) => {
|
||||
setSelectedHistoryId(row.historyId);
|
||||
// popup을 유지한 채 메인 지도에 mock 침수흔적/침수심 preview를 먼저 보여준다.
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
row,
|
||||
openDetail: false,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen min-w-0 flex-col overflow-hidden bg-white">
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">침수흔적도 목록</h1>
|
||||
<div />
|
||||
</header>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden p-4">
|
||||
<div className="border-b border-slate-200 bg-slate-50 pb-4">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-[180px_180px_180px_minmax(0,1fr)_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((current) => ({
|
||||
...current,
|
||||
ctpvCd: event.target.value,
|
||||
sggCd: "0",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{ctpv.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((current) => ({
|
||||
...current,
|
||||
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.floodYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
floodYear: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{floodYearOptions.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}년
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">침수재해명</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={filters.disasterName}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
disasterName: event.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="card !m-[1.2rem] !mt-0 min-h-0 flex-1 overflow-x-auto overflow-y-auto">
|
||||
<Table variant="secondary" className="w-max min-w-full">
|
||||
<colgroup>
|
||||
{GRID_COLUMNS.map((head) => (
|
||||
<col key={head.id} style={{ width: head.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<TableHeader className="font-bold">
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">NO</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">시도</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">시군구</TableHead>
|
||||
<TableHead colSpan={4} className="whitespace-nowrap">강우 분석정보</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수재해명</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수년도</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수시간</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수지구명</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세코드</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">평균침수위</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">침수면적</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">조사년도</TableHead>
|
||||
<TableHead rowSpan={2} className="whitespace-nowrap">피해금액</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="whitespace-nowrap">침수시작일자</TableHead>
|
||||
<TableHead className="whitespace-nowrap">침수종료일자</TableHead>
|
||||
<TableHead className="whitespace-nowrap">침수시작시간</TableHead>
|
||||
<TableHead className="whitespace-nowrap">침수종료시간</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-center">
|
||||
{pagedRows.length > 0 ? (
|
||||
pagedRows.map((row) => {
|
||||
const isSelected = selectedHistoryId === row.historyId;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.historyId}
|
||||
className={`cursor-pointer ${isSelected ? "bg-blue-50" : "hover:bg-slate-50"}`}
|
||||
onClick={() => handleRowClick(row)}
|
||||
onDoubleClick={() => handleRowDoubleClick(row)}
|
||||
>
|
||||
<TableCell>{row.rowNo}</TableCell>
|
||||
<TableCell>{row.ctpvNm}</TableCell>
|
||||
<TableCell>{row.sggNm}</TableCell>
|
||||
<TableCell>{row.startDateDisplay}</TableCell>
|
||||
<TableCell>{row.endDateDisplay}</TableCell>
|
||||
<TableCell>{row.startTimeDisplay}</TableCell>
|
||||
<TableCell>{row.endTimeDisplay}</TableCell>
|
||||
<TableCell className="text-left">{row.disasterName}</TableCell>
|
||||
<TableCell>{row.floodYearDisplay}</TableCell>
|
||||
<TableCell>{row.floodHourDisplay}</TableCell>
|
||||
<TableCell className="text-left">{row.districtNameDisplay}</TableCell>
|
||||
<TableCell>{row.causeCodeDisplay}</TableCell>
|
||||
<TableCell className="text-left">{row.causeDetailDisplay}</TableCell>
|
||||
<TableCell className="text-right">{row.avgFloodDepthDisplay}</TableCell>
|
||||
<TableCell className="text-right">{row.floodAreaDisplay}</TableCell>
|
||||
<TableCell>{row.exmnYearDisplay}</TableCell>
|
||||
<TableCell className="text-right">{row.damageAmountDisplay}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={GRID_COLUMNS.length} className="h-24 text-center text-gray-500">
|
||||
조회된 데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{filteredRows.length > 0 ? (
|
||||
<UiPageNations
|
||||
total={filteredRows.length}
|
||||
cur={currentPage}
|
||||
pageSize={FLOOD_HISTORY_PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-[1.05rem] text-slate-600">
|
||||
행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
export const FLOOD_HISTORY_POPUP_KEY = "floodHistoryPopup";
|
||||
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
|
||||
export const FLOOD_HISTORY_LAYER_TYPE = "tb_sn_flood_trace_mock";
|
||||
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도";
|
||||
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLOOD_HISTORY_PAGE_SIZE = 10;
|
||||
|
||||
export type FloodHistoryFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
floodYear: string;
|
||||
disasterName: string;
|
||||
};
|
||||
|
||||
export type FloodHistoryDepthZone = {
|
||||
zoneId: string;
|
||||
depthLabel: string;
|
||||
color: string;
|
||||
ring: [number, number][];
|
||||
};
|
||||
|
||||
export type FloodHistoryItem = {
|
||||
historyId: string;
|
||||
ctpvNm: string;
|
||||
sggNm: string;
|
||||
floodYear: string;
|
||||
exmnYear: string;
|
||||
disasterName: string;
|
||||
districtName: string;
|
||||
floodAreaHa: number;
|
||||
avgFloodDepthM: number;
|
||||
floodDepthM: number;
|
||||
gradeLabel: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
floodHour: string;
|
||||
causeDetailCode: string;
|
||||
causeDetailName: string;
|
||||
damageAmount: string;
|
||||
rainStartTime: string;
|
||||
rainEndTime: string;
|
||||
totalRainfallMm: number;
|
||||
rainfallIntensityMmHr: number;
|
||||
areaRing: [number, number][];
|
||||
depthZones: FloodHistoryDepthZone[];
|
||||
};
|
||||
|
||||
export type FloodHistoryTableRow = FloodHistoryItem & {
|
||||
rowNo: number;
|
||||
exmnYearDisplay: string;
|
||||
avgFloodDepthDisplay: string;
|
||||
floodAreaDisplay: string;
|
||||
causeDetailDisplay: string;
|
||||
startDateDisplay: string;
|
||||
endDateDisplay: string;
|
||||
startTimeDisplay: string;
|
||||
endTimeDisplay: string;
|
||||
causeCodeDisplay: string;
|
||||
districtNameDisplay: string;
|
||||
damageAmountDisplay: string;
|
||||
floodHourDisplay: string;
|
||||
floodYearDisplay: string;
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_DEFAULT_FILTERS: FloodHistoryFilters = {
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
floodYear: "",
|
||||
disasterName: "",
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_DOWNLOAD_HEADERS: Record<string, string> = {
|
||||
rowNo: "NO",
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
disasterName: "침수재해명",
|
||||
exmnYearDisplay: "조사년도",
|
||||
avgFloodDepthDisplay: "평균침수위",
|
||||
floodAreaDisplay: "침수면적",
|
||||
causeDetailDisplay: "침수원인상세",
|
||||
startDateDisplay: "침수시작일자",
|
||||
endDateDisplay: "침수종료일자",
|
||||
startTimeDisplay: "침수시작시간",
|
||||
endTimeDisplay: "침수종료시간",
|
||||
causeCodeDisplay: "침수원인상세코드",
|
||||
districtNameDisplay: "침수지구명",
|
||||
damageAmountDisplay: "피해금액",
|
||||
floodHourDisplay: "침수시간",
|
||||
floodYearDisplay: "침수년도",
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_DEPTH_LEGEND_ITEMS = [
|
||||
{ color: "#fff27a", label: "0.0~0.5m" },
|
||||
{ color: "#c2f13d", label: "0.5~1.0m" },
|
||||
{ color: "#67d8ff", label: "1.0~2.0m" },
|
||||
{ color: "#8f8bff", label: "2.0~5.0m" },
|
||||
{ color: "#f2528b", label: "5.0m 이상" },
|
||||
] as const;
|
||||
|
||||
const toRadian = (degree: number) => (degree * Math.PI) / 180;
|
||||
|
||||
const createIrregularRing = ({
|
||||
centerX,
|
||||
centerY,
|
||||
radiusX,
|
||||
radiusY,
|
||||
scales,
|
||||
rotationDeg = 0,
|
||||
}: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
radiusX: number;
|
||||
radiusY: number;
|
||||
scales: number[];
|
||||
rotationDeg?: number;
|
||||
}): [number, number][] => {
|
||||
const rotation = toRadian(rotationDeg);
|
||||
const points = scales.map((scale, index) => {
|
||||
const angle = rotation + (Math.PI * 2 * index) / scales.length;
|
||||
const jitterX = radiusX * scale * Math.cos(angle);
|
||||
const jitterY = radiusY * scale * Math.sin(angle);
|
||||
return [centerX + jitterX, centerY + jitterY] as [number, number];
|
||||
});
|
||||
|
||||
return [...points, points[0]];
|
||||
};
|
||||
|
||||
const createDepthZones = (
|
||||
historyId: string,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): FloodHistoryDepthZone[] => {
|
||||
return [
|
||||
{
|
||||
zoneId: `${historyId}-depth-1`,
|
||||
depthLabel: "0.0~0.5m",
|
||||
color: "#fff27a",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX - 5200,
|
||||
centerY: centerY - 2600,
|
||||
radiusX: 3600,
|
||||
radiusY: 2900,
|
||||
rotationDeg: -18,
|
||||
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-2`,
|
||||
depthLabel: "0.5~1.0m",
|
||||
color: "#c2f13d",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 2600,
|
||||
centerY: centerY - 1200,
|
||||
radiusX: 4300,
|
||||
radiusY: 3600,
|
||||
rotationDeg: 12,
|
||||
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-3`,
|
||||
depthLabel: "1.0~2.0m",
|
||||
color: "#67d8ff",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX - 600,
|
||||
centerY: centerY + 2600,
|
||||
radiusX: 5600,
|
||||
radiusY: 4700,
|
||||
rotationDeg: 8,
|
||||
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-4`,
|
||||
depthLabel: "2.0~5.0m",
|
||||
color: "#8f8bff",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 4200,
|
||||
centerY: centerY + 5200,
|
||||
radiusX: 3200,
|
||||
radiusY: 2500,
|
||||
rotationDeg: 26,
|
||||
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
|
||||
}),
|
||||
},
|
||||
{
|
||||
zoneId: `${historyId}-depth-5`,
|
||||
depthLabel: "5.0m 이상",
|
||||
color: "#f2528b",
|
||||
ring: createIrregularRing({
|
||||
centerX: centerX + 600,
|
||||
centerY: centerY - 5200,
|
||||
radiusX: 1500,
|
||||
radiusY: 1350,
|
||||
rotationDeg: -10,
|
||||
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const createHistory = (
|
||||
item: Omit<FloodHistoryItem, "areaRing" | "depthZones"> & {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
): FloodHistoryItem => {
|
||||
const { centerX, centerY, width, height, ...detail } = item;
|
||||
|
||||
return {
|
||||
...detail,
|
||||
areaRing: createIrregularRing({
|
||||
centerX,
|
||||
centerY,
|
||||
radiusX: width / 2,
|
||||
radiusY: height / 2,
|
||||
rotationDeg: 9,
|
||||
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
|
||||
}),
|
||||
depthZones: createDepthZones(detail.historyId, centerX, centerY),
|
||||
};
|
||||
};
|
||||
|
||||
export const FLOOD_HISTORY_ITEMS: FloodHistoryItem[] = [
|
||||
createHistory({
|
||||
historyId: "FH-2024-001",
|
||||
ctpvNm: "부산광역시",
|
||||
sggNm: "중구",
|
||||
floodYear: "2024",
|
||||
exmnYear: "2025",
|
||||
disasterName: "중앙동 저지대 침수",
|
||||
districtName: "중앙동 저지대",
|
||||
floodAreaHa: 1.8,
|
||||
avgFloodDepthM: 0.62,
|
||||
floodDepthM: 0.62,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2024-07-18",
|
||||
endDate: "2024-07-18",
|
||||
startTime: "14:10",
|
||||
endTime: "18:20",
|
||||
floodHour: "4.2시간",
|
||||
causeDetailCode: "FC-01",
|
||||
causeDetailName: "집중강우",
|
||||
damageAmount: "120,000,000원",
|
||||
rainStartTime: "13:50",
|
||||
rainEndTime: "18:20",
|
||||
totalRainfallMm: 91,
|
||||
rainfallIntensityMmHr: 31,
|
||||
centerX: 1122000,
|
||||
centerY: 1705000,
|
||||
width: 18000,
|
||||
height: 14000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2023-002",
|
||||
ctpvNm: "경상남도",
|
||||
sggNm: "창원시",
|
||||
floodYear: "2023",
|
||||
exmnYear: "2024",
|
||||
disasterName: "팔용동 공단 배수지연 침수",
|
||||
districtName: "팔용동 공단",
|
||||
floodAreaHa: 4.8,
|
||||
avgFloodDepthM: 1.92,
|
||||
floodDepthM: 1.92,
|
||||
gradeLabel: "2등급",
|
||||
startDate: "2023-08-11",
|
||||
endDate: "2023-08-11",
|
||||
startTime: "09:40",
|
||||
endTime: "15:10",
|
||||
floodHour: "5.5시간",
|
||||
causeDetailCode: "FC-04",
|
||||
causeDetailName: "배수시설 처리능력 초과",
|
||||
damageAmount: "340,000,000원",
|
||||
rainStartTime: "08:55",
|
||||
rainEndTime: "15:05",
|
||||
totalRainfallMm: 138,
|
||||
rainfallIntensityMmHr: 42,
|
||||
centerX: 1081000,
|
||||
centerY: 1778000,
|
||||
width: 25000,
|
||||
height: 17000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2022-003",
|
||||
ctpvNm: "울산광역시",
|
||||
sggNm: "남구",
|
||||
floodYear: "2022",
|
||||
exmnYear: "2023",
|
||||
disasterName: "태화강 제내지 도로 침수",
|
||||
districtName: "여천동 하류부",
|
||||
floodAreaHa: 3.1,
|
||||
avgFloodDepthM: 1.15,
|
||||
floodDepthM: 1.15,
|
||||
gradeLabel: "2등급",
|
||||
startDate: "2022-09-06",
|
||||
endDate: "2022-09-06",
|
||||
startTime: "07:20",
|
||||
endTime: "12:40",
|
||||
floodHour: "5.3시간",
|
||||
causeDetailCode: "FC-02",
|
||||
causeDetailName: "하천 수위 상승",
|
||||
damageAmount: "210,000,000원",
|
||||
rainStartTime: "06:30",
|
||||
rainEndTime: "11:50",
|
||||
totalRainfallMm: 117,
|
||||
rainfallIntensityMmHr: 37,
|
||||
centerX: 1168000,
|
||||
centerY: 1811000,
|
||||
width: 22000,
|
||||
height: 16000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2021-004",
|
||||
ctpvNm: "부산광역시",
|
||||
sggNm: "서구",
|
||||
floodYear: "2021",
|
||||
exmnYear: "2022",
|
||||
disasterName: "서대신동 급경사지 유출 침수",
|
||||
districtName: "서대신동 일원",
|
||||
floodAreaHa: 2.4,
|
||||
avgFloodDepthM: 0.85,
|
||||
floodDepthM: 0.85,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2021-07-07",
|
||||
endDate: "2021-07-07",
|
||||
startTime: "16:00",
|
||||
endTime: "20:10",
|
||||
floodHour: "4.1시간",
|
||||
causeDetailCode: "FC-03",
|
||||
causeDetailName: "우수관로 월류",
|
||||
damageAmount: "175,000,000원",
|
||||
rainStartTime: "15:20",
|
||||
rainEndTime: "19:50",
|
||||
totalRainfallMm: 104,
|
||||
rainfallIntensityMmHr: 29,
|
||||
centerX: 1111000,
|
||||
centerY: 1709000,
|
||||
width: 20000,
|
||||
height: 14500,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2020-005",
|
||||
ctpvNm: "부산광역시",
|
||||
sggNm: "영도구",
|
||||
floodYear: "2020",
|
||||
exmnYear: "2021",
|
||||
disasterName: "청학동 해안도로 월파 침수",
|
||||
districtName: "청학동 해안부",
|
||||
floodAreaHa: 1.2,
|
||||
avgFloodDepthM: 0.48,
|
||||
floodDepthM: 0.48,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2020-10-22",
|
||||
endDate: "2020-10-22",
|
||||
startTime: "03:10",
|
||||
endTime: "06:30",
|
||||
floodHour: "3.3시간",
|
||||
causeDetailCode: "FC-06",
|
||||
causeDetailName: "해안 월파",
|
||||
damageAmount: "82,000,000원",
|
||||
rainStartTime: "02:40",
|
||||
rainEndTime: "05:50",
|
||||
totalRainfallMm: 55,
|
||||
rainfallIntensityMmHr: 18,
|
||||
centerX: 1129000,
|
||||
centerY: 1695000,
|
||||
width: 15000,
|
||||
height: 12000,
|
||||
}),
|
||||
createHistory({
|
||||
historyId: "FH-2019-006",
|
||||
ctpvNm: "경상남도",
|
||||
sggNm: "김해시",
|
||||
floodYear: "2019",
|
||||
exmnYear: "2020",
|
||||
disasterName: "장유 배수분구 내수 침수",
|
||||
districtName: "장유 배수분구",
|
||||
floodAreaHa: 2.9,
|
||||
avgFloodDepthM: 0.96,
|
||||
floodDepthM: 0.96,
|
||||
gradeLabel: "1등급",
|
||||
startDate: "2019-09-21",
|
||||
endDate: "2019-09-21",
|
||||
startTime: "11:30",
|
||||
endTime: "16:15",
|
||||
floodHour: "4.8시간",
|
||||
causeDetailCode: "FC-05",
|
||||
causeDetailName: "배수펌프 지연 가동",
|
||||
damageAmount: "143,000,000원",
|
||||
rainStartTime: "10:45",
|
||||
rainEndTime: "15:50",
|
||||
totalRainfallMm: 88,
|
||||
rainfallIntensityMmHr: 24,
|
||||
centerX: 1098000,
|
||||
centerY: 1734000,
|
||||
width: 21000,
|
||||
height: 15000,
|
||||
}),
|
||||
];
|
||||
|
||||
export const normalizeFloodHistoryFilters = (
|
||||
filters: FloodHistoryFilters,
|
||||
): FloodHistoryFilters => ({
|
||||
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
|
||||
sggCd: String(filters.sggCd ?? "0") || "0",
|
||||
floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4),
|
||||
disasterName: String(filters.disasterName ?? "").trim(),
|
||||
});
|
||||
|
||||
export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`;
|
||||
|
||||
export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`;
|
||||
|
||||
export const buildFloodHistoryTableRows = (
|
||||
items: FloodHistoryItem[],
|
||||
page: number,
|
||||
pageSize = FLOOD_HISTORY_PAGE_SIZE,
|
||||
): FloodHistoryTableRow[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo: (page - 1) * pageSize + index + 1,
|
||||
exmnYearDisplay: `${item.exmnYear}년`,
|
||||
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
|
||||
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
|
||||
causeDetailDisplay: item.causeDetailName,
|
||||
startDateDisplay: item.startDate,
|
||||
endDateDisplay: item.endDate,
|
||||
startTimeDisplay: item.startTime,
|
||||
endTimeDisplay: item.endTime,
|
||||
causeCodeDisplay: item.causeDetailCode,
|
||||
districtNameDisplay: item.districtName,
|
||||
damageAmountDisplay: item.damageAmount,
|
||||
floodHourDisplay: item.floodHour,
|
||||
floodYearDisplay: `${item.floodYear}년`,
|
||||
}));
|
||||
|
||||
export const buildFloodHistoryYearOptions = (items: FloodHistoryItem[]) =>
|
||||
Array.from(new Set(items.map((item) => item.floodYear)))
|
||||
.sort((left, right) => Number(right) - Number(left));
|
||||
|
||||
const resolveFloodHistoryNodeName = (node: any) =>
|
||||
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
|
||||
|
||||
const resolveFloodHistoryLayerType = (node: any) =>
|
||||
String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim();
|
||||
|
||||
export const isFloodHistoryNode = (node: any) => {
|
||||
const name = resolveFloodHistoryNodeName(node);
|
||||
const layerType = resolveFloodHistoryLayerType(node);
|
||||
return name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE;
|
||||
};
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport";
|
||||
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
|
||||
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
|
||||
import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel";
|
||||
import {
|
||||
applySinkholeNodeFilters,
|
||||
applySinkholeYearFilters,
|
||||
@@ -109,6 +110,15 @@ import {
|
||||
type SinkholeNearbyPipe,
|
||||
type SinkholeSyncSummary,
|
||||
} from "./components/sinkhole/sinkholeTypes";
|
||||
import {
|
||||
FLOOD_HISTORY_AREA_LAYER_ID,
|
||||
FLOOD_HISTORY_DEPTH_LAYER_ID,
|
||||
FLOOD_HISTORY_LAYER_TYPE,
|
||||
FLOOD_HISTORY_ROW_SELECT_MESSAGE,
|
||||
FLOOD_HISTORY_POPUP_KEY,
|
||||
isFloodHistoryNode,
|
||||
type FloodHistoryItem,
|
||||
} from "./components/floodHistory/floodHistoryTypes";
|
||||
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
|
||||
import SearchModal from "./searchModal/searchModal";
|
||||
import { WATER_DEPTH_LAYER_TYPES, WATER_DEPTH_LEGEND_ITEMS } from "./waterDepth/waterDepth";
|
||||
@@ -157,6 +167,7 @@ const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES";
|
||||
const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA";
|
||||
const WATER_RISK_GRID_LAYER_ID = "SN_WATER_RISK_GRID";
|
||||
const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE";
|
||||
const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [100, 380, 120, 120];
|
||||
|
||||
type FlowDirectionUsageStyle = {
|
||||
color: string;
|
||||
@@ -727,6 +738,44 @@ const getSelectedFeatureLayerTypeName = (data: any) => {
|
||||
return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim();
|
||||
};
|
||||
|
||||
const createFloodHistoryAreaStyle = (label: string) =>
|
||||
new Style({
|
||||
fill: new Fill({
|
||||
color: "rgba(30, 136, 229, 0.16)",
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: "rgba(30, 136, 229, 0.95)",
|
||||
width: 3,
|
||||
}),
|
||||
text: new Text({
|
||||
text: label,
|
||||
font: "700 12px sans-serif",
|
||||
fill: new Fill({ color: "#ffffff" }),
|
||||
backgroundFill: new Fill({ color: "rgba(15, 23, 42, 0.88)" }),
|
||||
padding: [4, 7, 4, 7],
|
||||
overflow: true,
|
||||
}),
|
||||
zIndex: 9994,
|
||||
});
|
||||
|
||||
const createFloodHistoryDepthStyle = (label: string, color: string) =>
|
||||
new Style({
|
||||
fill: new Fill({
|
||||
color,
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: "rgba(71, 85, 105, 0.72)",
|
||||
width: 1.4,
|
||||
}),
|
||||
text: new Text({
|
||||
text: label,
|
||||
font: "700 11px sans-serif",
|
||||
fill: new Fill({ color: "#0f172a" }),
|
||||
overflow: true,
|
||||
}),
|
||||
zIndex: 9993,
|
||||
});
|
||||
|
||||
const normalizeGisEditFid = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
@@ -2242,6 +2291,7 @@ const Map = () => {
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
|
||||
const [isSinkholeChecked, setIsSinkholeChecked] = useState(false);
|
||||
const [isFloodHistoryChecked, setIsFloodHistoryChecked] = useState(false);
|
||||
const [draftAgedPipeFilters, setDraftAgedPipeFilters] =
|
||||
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
||||
const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] =
|
||||
@@ -2251,6 +2301,8 @@ const Map = () => {
|
||||
);
|
||||
const [isSinkholePopupFilterActive, setIsSinkholePopupFilterActive] = useState(false);
|
||||
const [sinkholeRadiusMeters, setSinkholeRadiusMeters] = useState(SINKHOLE_DEFAULT_RADIUS);
|
||||
const [selectedFloodHistoryDetail, setSelectedFloodHistoryDetail] = useState<FloodHistoryItem | null>(null);
|
||||
const [floodHistoryDetailPopupPosition, setFloodHistoryDetailPopupPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
const [selectedSinkholeDetail, setSelectedSinkholeDetail] = useState<SinkholeDetail | null>(null);
|
||||
const [selectedSinkholeNearbyPipes, setSelectedSinkholeNearbyPipes] = useState<SinkholeNearbyPipe[]>([]);
|
||||
const [lastSinkholeSyncSummary, setLastSinkholeSyncSummary] = useState<SinkholeSyncSummary | null>(null);
|
||||
@@ -2269,6 +2321,9 @@ const Map = () => {
|
||||
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
|
||||
const agedPipePopupWindowRef = useRef<Window | null>(null);
|
||||
const sinkholePopupWindowRef = useRef<Window | null>(null);
|
||||
// 침수발생이력 overlay layer는 지도 드래그 중에도 유지돼야 하므로 React state 대신 ref로 보관한다.
|
||||
const floodHistoryAreaLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const floodHistoryDepthLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||
const sinkholePopupCloseWatcherRef = useRef<number | null>(null);
|
||||
const pipeAnalysisPopupWindowRef = useRef<Window | null>(null);
|
||||
const gisEditSessionPopupWindowRef = useRef<Window | null>(null);
|
||||
@@ -3857,6 +3912,121 @@ const Map = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const ensureFloodHistoryAreaSource = useCallback(() => {
|
||||
if (!mapReady || !gisMaps.current) return null;
|
||||
|
||||
if (!floodHistoryAreaLayerRef.current) {
|
||||
const source = new VectorSource();
|
||||
const layer = new VectorLayer({
|
||||
source,
|
||||
zIndex: 9993,
|
||||
});
|
||||
layer.set("id", FLOOD_HISTORY_AREA_LAYER_ID);
|
||||
layer.set("lyrNm", "침수흔적도");
|
||||
layer.set("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE);
|
||||
layer.set("clickPsblYn", "N");
|
||||
gisMaps.current.addLayer(layer);
|
||||
floodHistoryAreaLayerRef.current = layer;
|
||||
}
|
||||
|
||||
return floodHistoryAreaLayerRef.current.getSource();
|
||||
}, [mapReady]);
|
||||
|
||||
const ensureFloodHistoryDepthSource = useCallback(() => {
|
||||
if (!mapReady || !gisMaps.current) return null;
|
||||
|
||||
if (!floodHistoryDepthLayerRef.current) {
|
||||
const source = new VectorSource();
|
||||
const layer = new VectorLayer({
|
||||
source,
|
||||
zIndex: 9992,
|
||||
});
|
||||
layer.set("id", FLOOD_HISTORY_DEPTH_LAYER_ID);
|
||||
layer.set("lyrNm", "침수흔적도");
|
||||
layer.set("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE);
|
||||
layer.set("clickPsblYn", "N");
|
||||
gisMaps.current.addLayer(layer);
|
||||
floodHistoryDepthLayerRef.current = layer;
|
||||
}
|
||||
|
||||
return floodHistoryDepthLayerRef.current.getSource();
|
||||
}, [mapReady]);
|
||||
|
||||
const clearFloodHistoryLayers = useCallback(() => {
|
||||
floodHistoryAreaLayerRef.current?.getSource?.()?.clear?.();
|
||||
floodHistoryDepthLayerRef.current?.getSource?.()?.clear?.();
|
||||
}, []);
|
||||
|
||||
const renderSelectedFloodHistory = useCallback(
|
||||
(detail: FloodHistoryItem | null) => {
|
||||
const areaSource = ensureFloodHistoryAreaSource();
|
||||
const depthSource = ensureFloodHistoryDepthSource();
|
||||
if (!areaSource || !depthSource) return;
|
||||
|
||||
areaSource.clear();
|
||||
depthSource.clear();
|
||||
|
||||
if (!detail) return;
|
||||
|
||||
const areaFeature = new Feature({
|
||||
geometry: new Polygon([detail.areaRing]),
|
||||
});
|
||||
areaFeature.setStyle(createFloodHistoryAreaStyle(`${detail.floodYear}년 ${detail.districtName}`));
|
||||
areaSource.addFeature(areaFeature);
|
||||
|
||||
detail.depthZones.forEach((zone) => {
|
||||
const zoneFeature = new Feature({
|
||||
geometry: new Polygon([zone.ring]),
|
||||
});
|
||||
zoneFeature.setStyle(createFloodHistoryDepthStyle(zone.depthLabel, zone.color));
|
||||
depthSource.addFeature(zoneFeature);
|
||||
});
|
||||
},
|
||||
[ensureFloodHistoryAreaSource, ensureFloodHistoryDepthSource],
|
||||
);
|
||||
|
||||
const focusFloodHistoryOnMap = useCallback((detail: FloodHistoryItem) => {
|
||||
if (!gisMaps.current) return;
|
||||
|
||||
const geometry = new Polygon([detail.areaRing]);
|
||||
gisMaps.current.getView?.().fit?.(geometry.getExtent(), {
|
||||
duration: 350,
|
||||
padding: FLOOD_HISTORY_SELECTION_PADDING,
|
||||
maxZoom: 15,
|
||||
constrainResolution: true,
|
||||
nearest: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearFloodHistorySelection = useCallback(() => {
|
||||
setSelectedFloodHistoryDetail(null);
|
||||
setFloodHistoryDetailPopupPosition(null);
|
||||
clearFloodHistoryLayers();
|
||||
}, [clearFloodHistoryLayers]);
|
||||
|
||||
const closeFloodHistoryDetail = useCallback(() => {
|
||||
setFloodHistoryDetailPopupPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleFloodHistoryRowSelect = useCallback(
|
||||
(detail: FloodHistoryItem) => {
|
||||
setSelectedFloodHistoryDetail(detail);
|
||||
renderSelectedFloodHistory(detail);
|
||||
focusFloodHistoryOnMap(detail);
|
||||
setIsSearchModalOpen(false);
|
||||
},
|
||||
[focusFloodHistoryOnMap, renderSelectedFloodHistory, setIsSearchModalOpen],
|
||||
);
|
||||
|
||||
const openFloodHistoryPopupWindow = useCallback(() => {
|
||||
const popup = openWindowPopup(FLOOD_HISTORY_POPUP_KEY as WindowPopupKey);
|
||||
if (!popup) {
|
||||
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
||||
return null;
|
||||
}
|
||||
return popup;
|
||||
}, []);
|
||||
|
||||
const buildSinkholeQueryString = useCallback(
|
||||
(filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT, checkedNodesData = checkedNodesDataRef.current) =>
|
||||
buildSinkholeListQueryString(applySinkholeYearFilters(filters, checkedNodesData), ctpv, sgg, page, size),
|
||||
@@ -4329,6 +4499,18 @@ const Map = () => {
|
||||
return () => window.removeEventListener("message", handleSinkholePopupMessage);
|
||||
}, [loadSinkholeDetail, sinkholeScope]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFloodHistoryPopupMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== FLOOD_HISTORY_ROW_SELECT_MESSAGE || !event.data?.row) return;
|
||||
|
||||
handleFloodHistoryRowSelect(event.data.row as FloodHistoryItem);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleFloodHistoryPopupMessage);
|
||||
return () => window.removeEventListener("message", handleFloodHistoryPopupMessage);
|
||||
}, [handleFloodHistoryRowSelect]);
|
||||
|
||||
// WaterDepth 표시 여부를 결정하는 함수
|
||||
const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => {
|
||||
const waterDepthLayers = checkedNodesData.filter(node =>
|
||||
@@ -4378,6 +4560,11 @@ const Map = () => {
|
||||
|
||||
handleWaterDepthVisibility(checkedIds, checkedNodesData);
|
||||
setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node)));
|
||||
const floodHistoryChecked = checkedNodesData.some((node) => isFloodHistoryNode(node));
|
||||
setIsFloodHistoryChecked(floodHistoryChecked);
|
||||
if (!floodHistoryChecked) {
|
||||
clearFloodHistorySelection();
|
||||
}
|
||||
const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node));
|
||||
setIsSinkholeChecked(sinkholeChecked);
|
||||
if (sinkholeChecked) {
|
||||
@@ -4387,7 +4574,7 @@ const Map = () => {
|
||||
removeSinkholeLayer();
|
||||
closeSinkholeDetail();
|
||||
}
|
||||
}, [closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]);
|
||||
}, [clearFloodHistorySelection, closeSinkholeDetail, handleWaterDepthVisibility, removeFlowDirectionLayer, removeSinkholeLayer, retainFlowDirectionUsageCodes]);
|
||||
|
||||
const handleOpenLayerPopup = useCallback(
|
||||
(_layerNm: string, node?: any) => {
|
||||
@@ -4399,13 +4586,17 @@ const Map = () => {
|
||||
openSinkholePopupWindow();
|
||||
return false;
|
||||
}
|
||||
if (isFloodHistoryNode(node)) {
|
||||
openFloodHistoryPopupWindow();
|
||||
return false;
|
||||
}
|
||||
if (isSinkholeYearNode(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[openAgedPipeList, openSinkholePopupWindow],
|
||||
[openAgedPipeList, openFloodHistoryPopupWindow, openSinkholePopupWindow],
|
||||
);
|
||||
|
||||
// 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다.
|
||||
@@ -5191,9 +5382,11 @@ const Map = () => {
|
||||
);
|
||||
}, [
|
||||
closeSinkholeDetail,
|
||||
closeFloodHistoryDetail,
|
||||
clearSinkholeFocusedPipeLayer,
|
||||
focusSinkholeNearbyPipe,
|
||||
handleApplyGisEditPipeBasicInfo,
|
||||
selectedFloodHistoryDetail,
|
||||
selectedSinkholeDetail,
|
||||
selectedSinkholeNearbyPipes,
|
||||
setIsSearchModalOpen,
|
||||
@@ -6211,6 +6404,58 @@ const Map = () => {
|
||||
renderSelectedSinkhole(selectedSinkholeDetail, sinkholeRadiusMeters);
|
||||
}, [renderSelectedSinkhole, selectedSinkholeDetail, sinkholeRadiusMeters]);
|
||||
|
||||
useEffect(() => {
|
||||
renderSelectedFloodHistory(selectedFloodHistoryDetail);
|
||||
}, [renderSelectedFloodHistory, selectedFloodHistoryDetail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady || !gisMaps.current) return;
|
||||
|
||||
const map = gisMaps.current;
|
||||
const clickKey = map.on("singleclick", (event: any) => {
|
||||
if (!selectedFloodHistoryDetail || !isFloodHistoryChecked) return;
|
||||
|
||||
let clickedFloodHistory = false;
|
||||
map.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
(feature: any, layer: any) => {
|
||||
if (
|
||||
layer === floodHistoryAreaLayerRef.current ||
|
||||
layer === floodHistoryDepthLayerRef.current
|
||||
) {
|
||||
clickedFloodHistory = true;
|
||||
return feature;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
{ hitTolerance: 4 },
|
||||
);
|
||||
|
||||
if (!clickedFloodHistory) return;
|
||||
|
||||
const size = map.getSize?.() ?? [1200, 800];
|
||||
const popupWidth = 220;
|
||||
const popupHeight = 210;
|
||||
const nextLeft = Math.min(Math.max(event.pixel[0] + 12, 12), size[0] - popupWidth - 12);
|
||||
const nextTop = Math.min(Math.max(event.pixel[1] - 12, 12), size[1] - popupHeight - 12);
|
||||
setFloodHistoryDetailPopupPosition({
|
||||
left: nextLeft,
|
||||
top: nextTop,
|
||||
});
|
||||
setIsSearchModalOpen(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unByKey(clickKey);
|
||||
};
|
||||
}, [isFloodHistoryChecked, mapReady, selectedFloodHistoryDetail, setIsSearchModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearFloodHistoryLayers();
|
||||
};
|
||||
}, [clearFloodHistoryLayers]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -6337,6 +6582,13 @@ const Map = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedFloodHistoryDetail && floodHistoryDetailPopupPosition ? (
|
||||
<FloodHistoryDetailPanel
|
||||
detail={selectedFloodHistoryDetail}
|
||||
position={floodHistoryDetailPopupPosition}
|
||||
onClose={closeFloodHistoryDetail}
|
||||
/>
|
||||
) : null}
|
||||
<AnalysisPopupMenu
|
||||
onOpenPopup={openAnalysisPopup}
|
||||
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
|
||||
@@ -6397,7 +6649,9 @@ const Map = () => {
|
||||
showFloatingSearchButton={false}
|
||||
treeTogglePosition="center"
|
||||
zoomGuide={zoomGuide}
|
||||
layerIconVisible={(node: any) => isAgedPipeLayerNode(node) || isSinkholeRootNode(node)}
|
||||
layerIconVisible={(node: any) =>
|
||||
isAgedPipeLayerNode(node) || isSinkholeRootNode(node) || isFloodHistoryNode(node)
|
||||
}
|
||||
|
||||
defaultZoom={7.5}
|
||||
defaultLonLat={[129, 36.3]}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# SFR-011-009 침수발생이력 통합 검증 계획
|
||||
|
||||
## 1. 문서 목적
|
||||
|
||||
- `SFR-011-009 침수발생이력`의 FE 검증 계획을 기능당 1개 문서로 누적 관리한다.
|
||||
- 현재 문서는 docs-first 설계 완료 직후 기준으로, FE-only 범위 검증 항목과 후속 통합 검증 항목을 분리한다.
|
||||
|
||||
## 2. 검증 기준 요약
|
||||
|
||||
- 레이어 트리 `침수흔적도` 목록 popup이 정상적으로 열린다.
|
||||
- `침수흔적도` on/off가 지도 표시와 맞게 동작한다.
|
||||
- 조회조건 유효성 검증이 동작한다.
|
||||
- `침수발생년도` 선택이 동작한다.
|
||||
- 목록이 조건에 따라 필터링된다.
|
||||
- 목록 다운로드가 현재 조회 결과 기준으로 동작한다.
|
||||
- 행 클릭 시 메인 지도 preview가 뜬다.
|
||||
- 지도 위 큰 침수흔적 레이어 클릭 시 작은 상세 팝업이 열린다.
|
||||
|
||||
## 3. FE-only 검증 계획
|
||||
|
||||
### 3.1 popup 진입
|
||||
|
||||
- 레이어 트리 `침수흔적도` 체크
|
||||
- 레이어 트리 `침수흔적도`의 목록 버튼 클릭
|
||||
- popup open 여부
|
||||
- popup 제목, 조회조건 영역, 목록 그리드 노출 여부
|
||||
|
||||
### 3.2 조회조건 검증
|
||||
|
||||
- 시도 미선택 상태에서 시군구 선택 후 조회 시 차단되는지 확인
|
||||
- 침수발생년도 선택 시 목록 필터가 반영되는지 확인
|
||||
- 침수재해명 키워드 입력 시 목록 필터가 적용되는지 확인
|
||||
|
||||
### 3.3 목록/다운로드
|
||||
|
||||
- 조회 결과 총 건수 표시
|
||||
- `강우 분석정보` 묶음 헤더와 하위 4개 컬럼 표시
|
||||
- 나머지 상세 필드가 가로 스크롤로 모두 노출되는지 확인
|
||||
- 10건 초과 시 페이지네이션 노출
|
||||
- 다운로드 버튼 클릭 시 현재 필터링 결과 기준 파일이 생성되는지 확인
|
||||
- 결과 없음 상태에서 다운로드가 차단되는지 확인
|
||||
|
||||
### 3.4 지도 연동
|
||||
|
||||
- 행 클릭 시 지도 preview 표시 여부
|
||||
- 행 더블클릭 시 popup close 여부
|
||||
- 메인 지도 영역으로 fit 이동되는지 확인
|
||||
- `침수흔적도` 체크 시 mock polygon과 침수심 overlay가 함께 표시되는지 확인
|
||||
- `침수흔적도`를 끄면 관련 표시가 함께 숨겨지는지 확인
|
||||
- 큰 침수흔적 polygon 클릭 시 상세 팝업이 뜨는지 확인
|
||||
|
||||
### 3.5 지도 상세 팝업
|
||||
|
||||
- `침수년도탭`, `재해명탭`, `침수발생강우탭` 전환
|
||||
- 기술 컬럼 제외 후 한글 업무 항목만 보이는지 확인
|
||||
- 각 탭의 테이블 구조와 값 표시
|
||||
- 닫기 시 팝업만 닫히는지 확인
|
||||
|
||||
## 4. BE 연동 후 검증 계획
|
||||
|
||||
- 실제 목록 조회 API 필드 매핑
|
||||
- 실제 geometry 및 지도 위치 정합성
|
||||
- 지도 click 상세조회
|
||||
- 실제 레이어 트리 `침수흔적도` 토글 연계
|
||||
- 다운로드 권한 및 파일 포맷 검증
|
||||
|
||||
## 5. 차단 사유 분류 기준
|
||||
|
||||
- `기능 결함`
|
||||
- `인증 차단`
|
||||
- `환경 차단`
|
||||
- `데이터 차단`
|
||||
- `자동화 한계`
|
||||
|
||||
## 6. 현재 상태
|
||||
|
||||
- 공통 설계서 작성 완료
|
||||
- FE 작업카드 작성 완료
|
||||
- 구현은 진행 중이며, 검증은 FE-only 기준으로 먼저 수행한다.
|
||||
@@ -0,0 +1,52 @@
|
||||
# SFR-011-009 침수발생이력 통합 검증 결과
|
||||
|
||||
## 1. 문서 목적
|
||||
|
||||
- `SFR-011-009 침수발생이력`의 현재 검증 결과와 남은 수동 확인 항목을 누적 관리한다.
|
||||
- 현재 문서는 FE draft 구현 직후 기준의 로컬 응답 확인과 자동화 한계 항목을 기록한다.
|
||||
|
||||
## 2. 이번 라운드 결과 요약
|
||||
|
||||
- 상태: 부분 확인
|
||||
- 확인 완료
|
||||
- `localhost:3002` 기본 응답 200 확인
|
||||
- `localhost:3002/map` 응답 200 확인
|
||||
- 공통 설계서, FE 작업카드, FE 검증계획 작성 완료
|
||||
- 미완료
|
||||
- popup 실제 진입 확인
|
||||
- 목록 조회/행 클릭 preview/메인 지도 플로팅 상세팝업 수동 확인
|
||||
- 다운로드 파일 수동 확인
|
||||
|
||||
## 3. 세부 결과
|
||||
|
||||
### 3.1 환경 확인
|
||||
|
||||
- `pnpm --filter sn run dev`는 샌드박스 내에서 `spawn EPERM`으로 실패했다.
|
||||
- 권한 상승 후 로컬 서버를 열어 `http://127.0.0.1:3002`, `http://127.0.0.1:3002/map` 응답 200을 확인했다.
|
||||
|
||||
### 3.2 자동/간접 확인
|
||||
|
||||
| 항목 | 결과 | 메모 |
|
||||
| --- | --- | --- |
|
||||
| 로컬 서버 기본 응답 | 통과 | `/` 200 |
|
||||
| 지도 메인 경로 응답 | 통과 | `/map` 200 |
|
||||
| popup 경로 직접 요청 | 보류 | 초기 컴파일 지연 또는 자동 요청 timeout 가능성 |
|
||||
| 실제 popup 클릭 | 보류 | 사용자가 로컬 수동 검증 예정 |
|
||||
| 행 클릭 preview | 보류 | 사용자가 로컬 수동 검증 예정 |
|
||||
| 더블클릭 후 지도 이동 | 보류 | 사용자가 로컬 수동 검증 예정 |
|
||||
| 지도 클릭 플로팅 팝업 | 보류 | 사용자가 로컬 수동 검증 예정 |
|
||||
|
||||
## 4. 남은 수동 검증 항목
|
||||
|
||||
1. 레이어 트리 `침수흔적도` 목록 버튼으로 popup이 정상적으로 열리는지 확인
|
||||
2. 조회조건 입력과 목록 필터가 기대대로 동작하는지 확인
|
||||
3. 목록 행 클릭 시 메인 지도에 테스트 레이어가 보이는지 확인
|
||||
4. 목록 행 더블클릭 시 popup이 닫히고 메인 지도 이동이 유지되는지 확인
|
||||
5. 큰 침수흔적 polygon 클릭 시 작은 상세 팝업이 뜨는지 확인
|
||||
6. `침수흔적도` on/off에 따라 영역/침수심 overlay가 메인 지도에 맞게 보이는지 확인
|
||||
7. 상세 팝업 탭과 표 구성이 자연스러운지 확인
|
||||
|
||||
## 5. 결론
|
||||
|
||||
- FE draft 구현과 문서 선행 정리는 완료됐다.
|
||||
- 최종 상태는 사용자 로컬 수동 검증 결과를 반영한 뒤 `검증 통과` 또는 `추가 보완 필요`로 갱신한다.
|
||||
@@ -0,0 +1,117 @@
|
||||
# SFR-011-009 침수발생이력 작업카드
|
||||
|
||||
## 1. 작업 개요
|
||||
|
||||
- 작업 ID: `SFR-011-009-FE`
|
||||
- 작업명: 침수발생이력 프론트엔드 구현
|
||||
- 상태: 검증
|
||||
- 작성일: 2026-06-15
|
||||
- 담당 역할: FE
|
||||
- 관련 역할: DE, QA
|
||||
- 담당 범위: `apps/sn` 지도 화면에서 레이어 트리 `침수흔적도` 목록 popup, 목록 조회, 지도 preview, 지도 클릭 플로팅 상세 팝업, mock 침수심 overlay 구현
|
||||
- 근거 문서
|
||||
- `COMMON_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-de-SFR-011-009-침수발생이력-설계.md`
|
||||
- `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/0.침수발생이력_요구사항명세서_v2.0.docx`
|
||||
- `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/5.침수발생이력_화면시나리오_V2.0.md.docx`
|
||||
- `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력 목록화면설계서.png`
|
||||
- `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력 지도상세정보화면설계서.png`
|
||||
- `COMMON_DOCS_ROOT/작업지시/SFR-011-009-침수발생이력/침수발생이력_화면.html`
|
||||
|
||||
## 1-1. 공통 동기화 확인
|
||||
|
||||
- 공통 작업현황판 확인 여부: 확인
|
||||
- 파일점유현황 확인 여부: 확인
|
||||
- 결정로그 확인 여부: 확인
|
||||
- 기존 진행 중 작업과의 관계: `map/page.tsx`, `windowPopup/**`는 다른 지도형 기능과 최근 수정 이력이 있어 충돌 주의가 필요하다.
|
||||
|
||||
## 2. 작업 목적
|
||||
|
||||
- 레이어 트리 `침수흔적도` 목록 진입을 placeholder가 아닌 실제 popup UI로 연결한다.
|
||||
- popup 조회 결과를 메인 지도와 연동해 사용자가 선택한 침수발생이력을 즉시 확인할 수 있게 한다.
|
||||
- 설계 이미지의 `침수흔적도 on/off + 목록 popup + 지도 강조 + 지도 위 상세 팝업` 흐름을 현재 `sn` 지도형 구조 안에서 자연스럽게 재현한다.
|
||||
|
||||
## 3. 수정 대상 파일
|
||||
|
||||
| 구분 | 파일 경로 | 수정 여부 | 비고 |
|
||||
| --- | --- | --- | --- |
|
||||
| 메인 지도 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 진행 | popup 선택 결과 수신, 지도 overlay, 우측 상세패널 연결 |
|
||||
| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 진행 | tree 목록 진입 popup 키/크기 정의 |
|
||||
| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 진행 | placeholder에서 실제 popup content로 교체 |
|
||||
| 분석도구 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 진행 | 분석도구에서 `침수발생이력` 제거 |
|
||||
| popup content | `FE_APP_ROOT/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx` | 진행 | popup 본문 연결 |
|
||||
| 신규 popup 페이지 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx` | 진행 | 조회조건/목록/다운로드 |
|
||||
| 신규 상세패널 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx` | 진행 | 기본정보/발생일시/원인·피해 탭 |
|
||||
| 신규 타입/샘플데이터 | `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts` | 진행 | mock 데이터, postMessage 타입, legend 정의 |
|
||||
|
||||
## 4. 구현 범위
|
||||
|
||||
### 4.1 이번 라운드 포함
|
||||
|
||||
- 레이어 트리 `침수흔적도` 목록 popup 진입
|
||||
- 레이어 트리 `침수흔적도` on/off
|
||||
- 시도/시군구/침수발생년도 선택/침수재해명 조회조건
|
||||
- mock 목록 조회/페이징
|
||||
- 목록 다운로드
|
||||
- 목록 행 클릭 preview + 더블클릭 후 메인 지도 이동
|
||||
- 메인 지도 mock polygon/침수심 overlay
|
||||
- 지도 클릭 플로팅 상세 팝업
|
||||
- `강우 분석정보` 묶음 헤더 유지 + 상세 필드 가로 스크롤 그리드
|
||||
|
||||
### 4.2 이번 라운드 제외
|
||||
|
||||
- 실제 백엔드 API 연동
|
||||
- 실제 레이어 트리 토글 연계
|
||||
- 지도 click 상세조회
|
||||
- 실제 기준데이터 기반 GIS layer 렌더링
|
||||
|
||||
## 5. 화면 흐름
|
||||
|
||||
1. 사용자가 레이어 트리에서 `침수흔적도`를 체크한다.
|
||||
2. 사용자가 `침수흔적도` 목록 버튼을 클릭한다.
|
||||
3. popup이 열린다.
|
||||
4. 사용자가 조회조건을 입력하고 `조회`를 클릭한다.
|
||||
5. 목록이 popup 그리드에 표시된다.
|
||||
6. 사용자가 행을 클릭하면 메인 지도에 테스트 레이어가 먼저 표시된다.
|
||||
7. 사용자가 행을 더블클릭하면 popup이 닫히고 메인 지도는 해당 침수 이력 영역으로 이동한다.
|
||||
8. `침수흔적도` 체크 상태에 따라 영역/침수심 표현이 함께 보인다.
|
||||
9. 메인 지도 위 큰 침수흔적 영역을 클릭하면 작은 상세 팝업이 열린다.
|
||||
|
||||
## 6. 구현 메모
|
||||
|
||||
- popup은 기존 `windowPopup` 체계를 그대로 사용하되, 분석도구가 아니라 레이어 트리 `침수흔적도` 목록 버튼으로 연다.
|
||||
- 목록 선택 결과는 `window.opener.postMessage`로 메인 지도에 전달한다.
|
||||
- popup의 `침수발생년도`는 같은 필터 줄 안의 선택 컴포넌트로 처리한다.
|
||||
- 메인 지도는 `VectorLayer` 두 개를 사용해
|
||||
- 침수발생이력상세 영역
|
||||
- 침수심 표현 영역
|
||||
을 임시 렌더링한다.
|
||||
- 두 overlay는 `침수흔적도` 체크 상태를 따르도록 맞춘다.
|
||||
- 상세정보는 우측 패널이 아니라 지도 위 플로팅 팝업으로 노출하고, 탭은 `침수년도탭`, `재해명탭`, `침수발생강우탭` 기준으로 보여준다.
|
||||
|
||||
## 7. 검증 방법
|
||||
|
||||
- 레이어 트리 `침수흔적도` 목록 popup이 열린다.
|
||||
- `침수흔적도` 체크 on/off에 따라 영역/침수심 overlay가 함께 바뀐다.
|
||||
- 시도 없이 시군구를 선택하면 조회가 차단된다.
|
||||
- 침수발생년도 선택이 동작한다.
|
||||
- 조건 변경 시 목록 필터가 바뀐다.
|
||||
- 행 클릭 시 지도 preview가 표시된다.
|
||||
- 지도 위 큰 polygon 클릭 시 작은 상세 팝업이 열린다.
|
||||
- 상세 팝업 탭 전환이 동작한다.
|
||||
|
||||
## 8. 현재 메모
|
||||
|
||||
- 문서 우선 원칙에 맞춰 이번 라운드는 설계서/작업카드/검증계획을 먼저 정리한 뒤 구현을 이어간다.
|
||||
- 현재 코드에는 일부 draft 구현이 이미 들어가 있으므로, 이후 단계에서 이 작업카드 기준으로 정합성을 다시 맞춘다.
|
||||
|
||||
## 9. 롤백 방법
|
||||
|
||||
- 레이어 트리 목록 진입 연결 해제
|
||||
- `floodHistory` 신규 컴포넌트 삭제
|
||||
- `map/page.tsx`의 floodHistory overlay/상세패널 분기 제거
|
||||
|
||||
## 10. 남은 리스크
|
||||
|
||||
- 실제 API 필드명이 mock 데이터와 다를 수 있다.
|
||||
- 실제 지도 레이어 기준데이터가 준비되면 현재 mock overlay 방식과 상태 관리가 달라질 수 있다.
|
||||
- popup 상세정보를 우측 패널로 둘지 지도 내부 floating popup으로 둘지는 실제 사용성 피드백 후 재조정 가능성이 있다.
|
||||
@@ -0,0 +1,73 @@
|
||||
# SFR-011-009 침수발생이력 통합 구현 기록
|
||||
|
||||
## 1. 문서 목적
|
||||
|
||||
- `SFR-011-009 침수발생이력`의 FE 구현 이력과 남은 리스크를 기능당 1개 문서로 누적 관리한다.
|
||||
- 현재 문서는 docs-first 재정렬 이후의 1차 FE draft 구현 상태를 기록한다.
|
||||
|
||||
## 2. 입력 근거
|
||||
|
||||
- `COMMON_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-de-SFR-011-009-침수발생이력-설계.md`
|
||||
- `COMMON_DOCS_ROOT/작업내용/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-통합공유기록.md`
|
||||
- `FE_APP_DOCS_ROOT/작업계획/SFR-011-009-침수발생이력/001-fe-SFR-011-009-침수발생이력-작업카드.md`
|
||||
|
||||
## 3. 구현 라운드 요약
|
||||
|
||||
### 3.1 2026-06-15 docs-first 재정렬
|
||||
|
||||
- 수행 내용
|
||||
- 작업지시 DOCX, 시나리오 DOCX, 설계 PNG, HTML 시안을 먼저 읽고 기능 범위를 재정리했다.
|
||||
- 공통 설계서, FE 작업카드, FE 통합검증계획을 먼저 작성했다.
|
||||
- 공통 작업현황판/파일점유현황에 `SFR-011-009-DE-FE-PLAN` 진행 상태를 등록했다.
|
||||
|
||||
### 3.2 2026-06-15 FE draft 구현
|
||||
|
||||
- 반영 파일
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
|
||||
- `FE_APP_ROOT/src/features/windowPopup/ui/FloodHistoryPopupContent.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/FloodHistoryDetailPanel.tsx`
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/floodHistory/floodHistoryTypes.ts`
|
||||
- 구현 내용
|
||||
- 분석도구의 `침수발생이력` 항목은 제거하고, 레이어 트리 `침수흔적도` 목록 진입으로 popup을 열도록 바꿨다.
|
||||
- `침수발생이력` popup placeholder를 실제 popup 페이지로 교체했다.
|
||||
- 시도/시군구/침수발생년도 선택/침수재해명 조회조건과 mock 목록 조회/다운로드를 연결했다.
|
||||
- 그리드는 현재 파란 톤을 유지하되, 사용안함 항목을 제외한 상세 필드를 모두 노출하도록 바꾸고 가로 스크롤을 허용했다. `강우 분석정보` 묶음은 유지하면서 뒤쪽 컬럼을 재배치했다.
|
||||
- 행 클릭 시 popup을 유지한 채 `postMessage`로 메인 지도에 mock preview를 보내고, 더블클릭 시 popup을 닫도록 구성했다.
|
||||
- 메인 지도는 mock polygon layer와 침수심 표현 layer를 임시 `VectorLayer`로 그리도록 구성했고, 표시 여부는 `침수흔적도` 체크 상태를 따르도록 보정했다.
|
||||
- 메인 지도 우측 상세패널은 제거하고, 지도 위 큰 침수흔적 영역 클릭 시 작은 플로팅 팝업이 열리도록 바꿨다.
|
||||
- 상세 팝업 탭은 `침수년도탭`, `재해명탭`, `침수발생강우탭` 구조에 맞춰 재배치했고, 탭별 표 항목도 캡처 기준에 가깝게 맞췄다.
|
||||
|
||||
## 4. 기능별 현재 상태
|
||||
|
||||
### 4.1 popup 조회
|
||||
|
||||
- FE-only draft 구현 완료
|
||||
- 레이어 트리 `침수흔적도` 목록 진입 기준
|
||||
- 시도/시군구/년도/재해명 조건과 목록 필터, 페이지네이션, 다운로드를 포함한다.
|
||||
|
||||
### 4.2 지도 이동 및 강조
|
||||
|
||||
- FE-only draft 구현 완료
|
||||
- 더블클릭 시 popup close 후 메인 지도 fit 이동과 mock polygon/침수심 표현 overlay를 수행한다.
|
||||
- `침수흔적도` 체크 상태에 따라 영역과 침수심 overlay를 함께 표시/숨김한다.
|
||||
|
||||
### 4.3 지도 상세 팝업
|
||||
|
||||
- FE-only draft 구현 완료
|
||||
- 선택 이력 기준 3탭 플로팅 팝업을 제공한다.
|
||||
|
||||
## 5. 현재 검증 메모
|
||||
|
||||
- 로컬 `localhost:3002` 응답은 확인했다.
|
||||
- `next dev`는 샌드박스 내에서 `spawn EPERM`이 발생해 권한 상승 후 로컬 서버를 띄웠다.
|
||||
- 사용자가 직접 로컬 화면을 수동 검증할 예정이므로, popup 실제 진입/더블클릭/상세패널 체감 확인은 후속 결과를 받아 갱신한다.
|
||||
|
||||
## 6. 남은 리스크
|
||||
|
||||
- popup 라우트의 초기 컴파일 시간이 길어 자동 요청에서 timeout이 발생할 수 있다.
|
||||
- `침수흔적도` 기준데이터 이름/구조와 FE 판별식이 다를 수 있다.
|
||||
- 실제 API 필드가 확정되면 상세패널 항목명/정렬이 일부 바뀔 수 있다.
|
||||
@@ -104,8 +104,8 @@ export const windowPopupDefinitions = {
|
||||
floodHistoryPopup: {
|
||||
key: "floodHistoryPopup",
|
||||
title: "침수발생이력",
|
||||
width: 1080,
|
||||
height: 760,
|
||||
width: 1160,
|
||||
height: 820,
|
||||
},
|
||||
gisEditPopup: {
|
||||
key: "gisEditPopup",
|
||||
|
||||
@@ -14,24 +14,19 @@ import SinkholePopupContent from "./ui/SinkholePopupContent";
|
||||
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
|
||||
import type { WindowPopupContentProps } from "./model/types";
|
||||
import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent";
|
||||
import FloodHistoryPopupContent from "./ui/FloodHistoryPopupContent";
|
||||
import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent";
|
||||
import GisManagementPopupContent from "./ui/GisManagementPopupContent";
|
||||
import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent";
|
||||
import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent";
|
||||
import WaterRiskPopupContent from "./ui/WaterRiskPopupContent";
|
||||
|
||||
const FloodPredictionMapPopupContent = (_props: WindowPopupContentProps) => (
|
||||
const FloodPredictionPlaceholderPopupContent = (_props: WindowPopupContentProps) => (
|
||||
<FeaturePlaceholderPopupContent
|
||||
title={windowPopupDefinitions.floodPredictionMapPopup.title}
|
||||
/>
|
||||
);
|
||||
|
||||
const FloodHistoryPopupContent = (_props: WindowPopupContentProps) => (
|
||||
<FeaturePlaceholderPopupContent
|
||||
title={windowPopupDefinitions.floodHistoryPopup.title}
|
||||
/>
|
||||
);
|
||||
|
||||
const GisEditPopupContent = (_props: WindowPopupContentProps) => (
|
||||
<FeaturePlaceholderPopupContent title={windowPopupDefinitions.gisEditPopup.title} />
|
||||
);
|
||||
@@ -95,7 +90,7 @@ export const windowPopupRegistry = {
|
||||
},
|
||||
floodPredictionMapPopup: {
|
||||
...windowPopupDefinitions.floodPredictionMapPopup,
|
||||
component: FloodPredictionMapPopupContent,
|
||||
component: FloodPredictionPlaceholderPopupContent,
|
||||
},
|
||||
floodHistoryPopup: {
|
||||
...windowPopupDefinitions.floodHistoryPopup,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import FloodHistoryPopupPage from "@/app/(map-layout)/map/components/floodHistory/FloodHistoryPopupPage";
|
||||
|
||||
export default function FloodHistoryPopupContent() {
|
||||
return <FloodHistoryPopupPage />;
|
||||
}
|
||||
@@ -550,9 +550,13 @@ export default React.memo(function GisTree({
|
||||
(child) =>
|
||||
child.parent === node.id && child.data.exprsYn === "Y"
|
||||
);
|
||||
const nodeLabel = String(node?.data?.lyrNm ?? "").trim();
|
||||
const isSinkholeRootTreeNode =
|
||||
String(node.id) === "SN_SINKHOLE" ||
|
||||
String(node?.data?.lyrNm ?? "").trim() === "지반침하정보";
|
||||
nodeLabel === "지반침하정보";
|
||||
const isFloodHistoryRootTreeNode = nodeLabel === "침수흔적도";
|
||||
const hideTreeLegendIcon =
|
||||
isSinkholeRootTreeNode || isFloodHistoryRootTreeNode;
|
||||
const showLayerActionButton = layerIconVisible?.(node);
|
||||
const showLayerActionIcon = depth > 0 && imageVisible;
|
||||
|
||||
@@ -582,7 +586,7 @@ export default React.memo(function GisTree({
|
||||
onChange={() => handleCheck(node.id)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{!isSinkholeRootTreeNode && node.icon && <span>{node.icon}</span>}
|
||||
{!hideTreeLegendIcon && node.icon && <span>{node.icon}</span>}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate whitespace-nowrap text-[1.3rem] font-medium text-label"
|
||||
title={String(node.text ?? "")}
|
||||
|
||||
Reference in New Issue
Block a user