sn: featureClickEventPopup에서 침수흔적도 관련 분리

This commit is contained in:
2026-09-08 17:13:36 +09:00
parent f742feca4b
commit b99f4904b0
9 changed files with 1054 additions and 994 deletions
@@ -5,11 +5,8 @@
"use client";
import { useMemo, useState } from "react";
import {
formatFloodArea,
formatFloodDepth,
type FldngHstryItem,
} from "./fldngHstryTypes";
import type { FldngHstryItem } from "./fldngHstryTypes";
import { formatFldngHstryArea, formatFldngHstryDepth } from "./fldngHstryUtils";
type Props = {
detail: FldngHstryItem;
@@ -54,8 +51,8 @@ export default function FldngHstryDetailPanel({
const yearRows = useMemo(
() =>
[
["침수심", `${detail.floodDepthM.toFixed(2)} m`],
["침수면적", formatFloodArea(detail.floodAreaHa)],
["침수심", `${formatFldngHstryDepth(detail.floodDepthM)} m`],
["침수면적", formatFldngHstryArea(detail.floodAreaHa)],
["침수년도", `${detail.floodYear}년`],
["등급", detail.gradeLabel],
["침수구역", detail.districtName],
@@ -67,7 +64,7 @@ export default function FldngHstryDetailPanel({
() =>
[
["침수재해명", detail.disasterName],
["침수면적", formatFloodArea(detail.floodAreaHa)],
["침수면적", formatFldngHstryArea(detail.floodAreaHa)],
["침수원인상세명", detail.causeDetailName],
["침수시작년월일", detail.startDate],
["침수종료년월일", detail.endDate],
@@ -0,0 +1,330 @@
/**
* 화면명: 침수흔적위선 연계정보
* 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type Feature from "ol/Feature.js";
import VectorSource from "ol/source/Vector.js";
import { loadFldngHstryDetail } from "./fldngHstryClient";
import {
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
type FldngHstryDetailCacheValue,
} from "./fldngHstryTypes";
type FldngHstryGisLayer = {
searchLayer?: { getSource?: () => VectorSource | null } | null;
clearSearchLayer: (option: unknown) => void;
ensureSearchLayer: (option: unknown) => {
set: (key: string, value: unknown) => void;
setSource: (source: VectorSource) => void;
};
showSearchResultsFromGeoJSON: (
layerTypeName: string,
featureId: string,
) => void | Promise<void>;
};
const cloneFldngHstryFeature = (feature: Feature) => {
const clonedFeature = feature.clone();
const featureId = feature.getId();
if (featureId !== undefined) clonedFeature.setId(featureId);
return clonedFeature;
};
const displayCachedFldngHstryFeatures = (
gisLayer: FldngHstryGisLayer,
layerTypeName: string,
features: Feature[],
) => {
const matched = Object.values(GisApp.layerCode ?? {}).find(
(item: any) => item?.lyrTyNm === layerTypeName,
) as any;
if (!matched || features.length === 0) return;
gisLayer.clearSearchLayer(matched);
const layer = gisLayer.ensureSearchLayer(matched);
layer.set("gobjTy", matched.gobjTy);
const clonedFeatures = features.map(cloneFldngHstryFeature);
layer.setSource(new VectorSource({ features: clonedFeatures }));
(GisApp.Module.select as any)?.selectFeature(clonedFeatures[0], false);
};
const showFldngHstryFeature = async (
cache: Map<string, Promise<Feature[]>>,
gisLayer: FldngHstryGisLayer,
layerTypeName: string,
featureId: string,
) => {
const cacheKey = `${layerTypeName}:${featureId}`;
const cachedRequest = cache.get(cacheKey);
if (cachedRequest) {
displayCachedFldngHstryFeatures(
gisLayer,
layerTypeName,
await cachedRequest,
);
return;
}
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId);
const source = gisLayer.searchLayer?.getSource?.();
if (!source) return;
const featureRequest = new Promise<Feature[]>((resolve, reject) => {
const resolveFeatures = () => {
const features = source.getFeatures();
if (features.length > 0) {
resolve(features.map(cloneFldngHstryFeature));
} else {
reject(new Error("침수흔적 위치 정보가 없습니다."));
}
};
const rejectFeatures = () =>
reject(new Error("침수흔적 위치 조회에 실패했습니다."));
if (source.getFeatures().length > 0) {
resolveFeatures();
return;
}
source.once("featuresloadend", resolveFeatures);
source.once("featuresloaderror", rejectFeatures);
});
cache.set(cacheKey, featureRequest);
try {
await featureRequest;
} catch (error) {
cache.delete(cacheKey);
console.warn(error);
}
};
export const useFldngHstryFeatureClick = () => {
const fldngHstryDetailCacheRef = useRef(
new Map<string, Promise<FldngHstryDetailCacheValue>>(),
);
const fldngHstryFeatureCacheRef = useRef(
new Map<string, Promise<Feature[]>>(),
);
const [fldngHstryDepthRows, setFldngHstryDepthRows] = useState<
Record<string, unknown>[]
>([]);
const [fldngHstryWtlvRows, setFldngHstryWtlvRows] = useState<
Record<string, unknown>[]
>([]);
const [selectedFldngHstryDepthFid, setSelectedFldngHstryDepthFid] = useState<
string | null
>(null);
const [selectedFldngHstryWtlvFid, setSelectedFldngHstryWtlvFid] = useState<
string | null
>(null);
const fldngHstryDepthYearListRef = useRef<HTMLDivElement>(null);
const fldngHstryWtlvListRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const detailCache = fldngHstryDetailCacheRef.current;
const featureCache = fldngHstryFeatureCacheRef.current;
return () => {
detailCache.clear();
featureCache.clear();
};
}, []);
const resetFldngHstryFeatureClick = useCallback(() => {
setFldngHstryDepthRows([]);
setFldngHstryWtlvRows([]);
setSelectedFldngHstryDepthFid(null);
setSelectedFldngHstryWtlvFid(null);
}, []);
const loadFldngHstryFeatureClickDetail = useCallback(
async (fldngHstryFid: string) => {
const cacheKey = `${FLDNG_HSTRY_LAYER_TYPE}:${fldngHstryFid}`;
let request = fldngHstryDetailCacheRef.current.get(cacheKey);
if (!request) {
request = loadFldngHstryDetail(fldngHstryFid);
fldngHstryDetailCacheRef.current.set(cacheKey, request);
}
const detail = await request;
if (!detail.complete) fldngHstryDetailCacheRef.current.delete(cacheKey);
setFldngHstryDepthRows(detail.fldngHstryDepthRows);
setFldngHstryWtlvRows(detail.fldngHstryWtlvRows);
setSelectedFldngHstryDepthFid(null);
setSelectedFldngHstryWtlvFid(null);
return detail;
},
[],
);
const headName = useMemo(
() => (
<div className="flex w-full min-w-0 flex-col items-start gap-2">
<span>기본정보</span>
{fldngHstryDepthRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
심선
</span>
<button
type="button"
aria-label="이전 연도"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
fldngHstryDepthYearListRef.current?.scrollBy({
left: -120,
behavior: "smooth",
})
}
>
‹
</button>
<div
ref={fldngHstryDepthYearListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{[...fldngHstryDepthRows]
.map((row, index) => ({
row,
index,
year: String(
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
).trim(),
}))
.filter(({ year }) => Boolean(year))
.sort((left, right) => {
const yearOrder = left.year.localeCompare(
right.year,
undefined,
{ numeric: true },
);
return yearOrder !== 0 ? yearOrder : left.index - right.index;
})
.map(({ row, index, year }) => {
const depthFid = String(
row.fldng_dpth_map_sn ?? row.fid ?? "",
).trim();
return (
<button
key={`${depthFid || "row"}-${index}`}
type="button"
onClick={async () => {
if (!depthFid) return;
setSelectedFldngHstryDepthFid(depthFid);
setSelectedFldngHstryWtlvFid(null);
await showFldngHstryFeature(
fldngHstryFeatureCacheRef.current,
GisApp.Module.layer as any,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
`${FLDNG_HSTRY_DEPTH_LAYER_TYPE}.${depthFid.replace(new RegExp(`^${FLDNG_HSTRY_DEPTH_LAYER_TYPE}\\.`), "")}`,
);
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryDepthFid === depthFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{year}년
</button>
);
})}
</div>
<button
type="button"
aria-label="다음 연도"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
fldngHstryDepthYearListRef.current?.scrollBy({
left: 120,
behavior: "smooth",
})
}
>
›
</button>
</div>
)}
{fldngHstryWtlvRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
위선
</span>
<button
type="button"
aria-label="이전 위선"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
fldngHstryWtlvListRef.current?.scrollBy({
left: -120,
behavior: "smooth",
})
}
>
‹
</button>
<div
ref={fldngHstryWtlvListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{fldngHstryWtlvRows.map((row, index) => {
const waterLevelFid = String(row.fid ?? "").trim();
const disasterName = String(
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
).trim();
return (
<button
key={`${waterLevelFid || "wtlv"}-${index}`}
type="button"
onClick={async () => {
if (!waterLevelFid) return;
setSelectedFldngHstryWtlvFid(waterLevelFid);
setSelectedFldngHstryDepthFid(null);
await showFldngHstryFeature(
fldngHstryFeatureCacheRef.current,
GisApp.Module.layer as any,
FLDNG_HSTRY_LAYER_TYPE,
`${FLDNG_HSTRY_LAYER_TYPE}.${waterLevelFid.replace(new RegExp(`^${FLDNG_HSTRY_LAYER_TYPE}\\.`), "")}`,
);
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{disasterName || "재해명 없음"}
</button>
);
})}
</div>
<button
type="button"
aria-label="다음 위선"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
fldngHstryWtlvListRef.current?.scrollBy({
left: 120,
behavior: "smooth",
})
}
>
›
</button>
</div>
)}
</div>
),
[
fldngHstryDepthRows,
fldngHstryWtlvRows,
selectedFldngHstryDepthFid,
selectedFldngHstryWtlvFid,
],
);
return {
hasFldngHstryLinkedFeatures:
fldngHstryDepthRows.length > 0 || fldngHstryWtlvRows.length > 0,
fldngHstryFeatureClickHeadName: headName,
loadFldngHstryFeatureClickDetail,
resetFldngHstryFeatureClick,
};
};
@@ -6,16 +6,16 @@
import { SelectBox } from "@common/business_components/ui";
import {
FLOOD_HISTORY_MIN_YEAR,
normalizeFloodHistoryYearRange,
type FloodHistoryYearRange,
FLDNG_HSTRY_MIN_YEAR,
type FldngHstryYearRange,
} from "./fldngHstryTypes";
import { normalizeFldngHstryYearRange } from "./fldngHstryUtils";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
type Props = {
yearRange: FloodHistoryYearRange;
yearRange: FldngHstryYearRange;
enabled: boolean;
onYearRangeChange: (yearRange: FloodHistoryYearRange) => void;
onYearRangeChange: (yearRange: FldngHstryYearRange) => void;
onEnabledChange: (enabled: boolean) => void;
onClose: () => void;
};
@@ -28,10 +28,10 @@ export default function FldngHstryLayerSettingPanel({
onEnabledChange,
onClose,
}: Props) {
const normalizedRange = normalizeFloodHistoryYearRange(yearRange);
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from(
{ length: currentYear - FLOOD_HISTORY_MIN_YEAR + 1 },
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
(_, index) => String(currentYear - index),
);
const startYearOptions = yearOptions
@@ -43,9 +43,9 @@ export default function FldngHstryLayerSettingPanel({
const yearSelectClass =
"!h-[26px] w-full !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
const changeYear = (key: keyof FloodHistoryYearRange, value: string) => {
const changeYear = (key: keyof FldngHstryYearRange, value: string) => {
onYearRangeChange(
normalizeFloodHistoryYearRange({
normalizeFldngHstryYearRange({
...normalizedRange,
[key]: value,
}),
@@ -17,22 +17,24 @@ import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
FLOOD_HISTORY_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_DEFAULT_FILTERS,
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
FLDNG_HSTRY_DOWNLOAD_HEADERS,
FLDNG_HSTRY_LIST_HEADLIST,
FLDNG_HSTRY_PAGE_SIZE,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
type FldngHstryApiItem,
type FldngHstryFilters,
type FldngHstryTableRow,
} from "./fldngHstryTypes";
import {
attachFldngHstryGeometry,
buildFldngHstryListQueryString,
buildFldngHstryYearOptions,
normalizeFldngHstryFilters,
toFldngHstryTableRows,
type FldngHstryApiItem,
type FldngHstryFilters,
type FldngHstryTableRow,
} from "./fldngHstryTypes";
} from "./fldngHstryUtils";
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
@@ -50,8 +52,8 @@ const resolveOptionText = (
const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
const fid = String(row.fid ?? "").trim();
if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid;
if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`;
if (fid.startsWith(`${FLDNG_HSTRY_LAYER_TYPE}.`)) return fid;
if (fid) return `${FLDNG_HSTRY_LAYER_TYPE}.${fid}`;
return "";
};
@@ -123,7 +125,6 @@ export default function FldngHstryPopupPage() {
const fldngYearOptions = useMemo(() => buildFldngHstryYearOptions(), []);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
@@ -157,11 +158,15 @@ export default function FldngHstryPopupPage() {
...hydrated,
ctpvNm:
resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2) ||
String((hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "").trim() ||
String(
(hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "",
).trim() ||
String(hydrated.ctpvCd ?? "").trim(),
sggNm:
resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5) ||
String((hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "").trim() ||
String(
(hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "",
).trim() ||
String(hydrated.sggCd ?? "").trim(),
};
});
@@ -233,7 +238,7 @@ export default function FldngHstryPopupPage() {
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
payload: {
layerNm: FLOOD_HISTORY_LAYER_TYPE,
layerNm: FLDNG_HSTRY_LAYER_TYPE,
fid: mapFeatureFid,
},
},
@@ -262,8 +267,7 @@ export default function FldngHstryPopupPage() {
try {
const response = await fetchAllPages({
batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
fetchPage: (page, size) =>
fetchRows(page + 1, appliedFilters, size),
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
getItems: (page) => page.rows,
getTotal: (page) => page.total,
});
@@ -362,7 +366,9 @@ export default function FldngHstryPopupPage() {
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">강우시작일</span>
<span className="whitespace-nowrap text-[1.3rem] text-label">
강우시작일
</span>
<Input
className="w-[11rem]"
type="date"
@@ -370,13 +376,32 @@ export default function FldngHstryPopupPage() {
min={filters.floodYear ? `${filters.floodYear}-01-01` : undefined}
max={filters.floodYear ? `${filters.floodYear}-12-31` : undefined}
disabled={!filters.floodYear}
onChange={(event) => setFilters((current) => ({ ...current, rainStartDate: event.target.value }))}
onChange={(event) =>
setFilters((current) => ({
...current,
rainStartDate: event.target.value,
}))
}
/>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">침수지구명</span>
<Input type="text" value={filters.districtName} onChange={(event) => setFilters((current) => ({ ...current, districtName: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") void handleSearch(1); }} />
<span className="whitespace-nowrap text-[1.3rem] text-label">
침수지구명
</span>
<Input
type="text"
value={filters.districtName}
onChange={(event) =>
setFilters((current) => ({
...current,
districtName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<label className="flex items-center gap-2">
@@ -0,0 +1,61 @@
/** 침수흔적 강우분석과 연결된 심선·위선 목록 조회를 담당한다. */
import { getData } from "@/components/lib/api";
import type { FldngHstryDetailCacheValue } from "./fldngHstryTypes";
const normalizeFldngHstryRainAnalysisPayload = (data: any) => {
if (!data || typeof data !== "object") return {};
return {
rain_bgng_tm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rainBgngTm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rain_end_tm: data.rainEndTm ?? data.rain_end_tm ?? null,
rainEndTm: data.rainEndTm ?? data.rain_end_tm ?? null,
rain_tot: data.rainTot ?? data.rain_tot ?? null,
rainTot: data.rainTot ?? data.rain_tot ?? null,
rain_str: data.rainStr ?? data.rain_str ?? null,
rainStr: data.rainStr ?? data.rain_str ?? null,
};
};
export const loadFldngHstryDetail = async (
fldngHstryFid: string,
): Promise<FldngHstryDetailCacheValue> => {
const [rainResult, depthResult] = await Promise.allSettled([
getData(`v1/fldng/hstry/rain-anls/${encodeURIComponent(fldngHstryFid)}`),
getData(`v1/fldng/hstry/depth-map/${encodeURIComponent(fldngHstryFid)}`),
]);
if (rainResult.status === "rejected") {
console.warn("침수강우분석 조회 실패:", rainResult.reason);
}
if (depthResult.status === "rejected") {
console.warn("침수깊이지도 조회 실패:", depthResult.reason);
}
const depthPayload =
depthResult.status === "fulfilled"
? (depthResult.value?.data ?? depthResult.value)
: null;
const depthItems = Array.isArray(depthPayload)
? depthPayload
: depthPayload?.depthMaps;
const waterLevelItems = Array.isArray(depthPayload?.waterLevelMaps)
? depthPayload.waterLevelMaps
: [];
return {
rainAnalysis:
rainResult.status === "fulfilled"
? normalizeFldngHstryRainAnalysisPayload(rainResult.value)
: {},
fldngHstryDepthRows: Array.isArray(depthItems)
? depthItems.filter((row) => row && typeof row === "object")
: [],
fldngHstryWtlvRows: waterLevelItems.filter(
(row: unknown) => row && typeof row === "object",
),
complete:
rainResult.status === "fulfilled" && depthResult.status === "fulfilled",
};
};
@@ -1,238 +1,27 @@
/**
* 침수흔적 화면에서 사용하는 타입·상수·조회 조건과 feature 캐시 유틸을 제공한다.
*/
"use client";
/** 침수흔적 화면에서 사용하는 타입·상수와 화면 설정값을 정의한다. */
import type { UiHeadProps } from "@common/business_components/ui";
import { appendApiSearchParams } from "@/shared/util/util";
import { getData } from "@/components/lib/api";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type Feature from "ol/Feature.js";
import VectorSource from "ol/source/Vector.js";
export const FLOOD_HISTORY_POPUP_KEY = "fldngHstryPopup";
export const FLDNG_HSTRY_POPUP_KEY = "fldngHstryPopup";
export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
export const FLOOD_HISTORY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
export const FLOOD_HISTORY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
export const FLOOD_HISTORY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적위선";
export const FLOOD_HISTORY_DEPTH_LAYER_NAME = "침수흔적심선";
export const FLOOD_HISTORY_ROOT_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 FLDNG_HSTRY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
export const FLDNG_HSTRY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
export const FLDNG_HSTRY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLDNG_HSTRY_LAYER_NAME = "침수흔적위선";
export const FLDNG_HSTRY_DEPTH_LAYER_NAME = "침수흔적심선";
export const FLDNG_HSTRY_ROOT_NAME = "침수흔적도";
export const FLDNG_HSTRY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
export const FLDNG_HSTRY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
export const FLDNG_HSTRY_PAGE_SIZE = 10;
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
export const FLOOD_HISTORY_MIN_YEAR = 2002;
export const FLDNG_HSTRY_MIN_YEAR = 2002;
export type FldngHstryDetailCacheValue = {
rainAnalysis: Record<string, unknown>;
depthRows: Record<string, unknown>[];
waterLevelRows: Record<string, unknown>[];
fldngHstryDepthRows: Record<string, unknown>[];
fldngHstryWtlvRows: Record<string, unknown>[];
complete: boolean;
};
const normalizeFldngHstryRainAnalysisPayload = (data: any) => {
if (!data || typeof data !== "object") return {};
return {
rain_bgng_tm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rainBgngTm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rain_end_tm: data.rainEndTm ?? data.rain_end_tm ?? null,
rainEndTm: data.rainEndTm ?? data.rain_end_tm ?? null,
rain_tot: data.rainTot ?? data.rain_tot ?? null,
rainTot: data.rainTot ?? data.rain_tot ?? null,
rain_str: data.rainStr ?? data.rain_str ?? null,
rainStr: data.rainStr ?? data.rain_str ?? null,
};
};
/** 침수흔적 강우분석과 연결된 심선·위선 목록을 함께 조회한다. */
export const loadFldngHstryDetail = async (
fldngHstryFid: string,
): Promise<FldngHstryDetailCacheValue> => {
const [rainResult, depthResult] = await Promise.allSettled([
getData(
`v1/fldng/hstry/rain-anls/${encodeURIComponent(fldngHstryFid)}`,
),
getData(
`v1/fldng/hstry/depth-map/${encodeURIComponent(fldngHstryFid)}`,
),
]);
if (rainResult.status === "rejected") {
console.warn("침수강우분석 조회 실패:", rainResult.reason);
}
if (depthResult.status === "rejected") {
console.warn("침수깊이지도 조회 실패:", depthResult.reason);
}
const depthPayload =
depthResult.status === "fulfilled"
? (depthResult.value?.data ?? depthResult.value)
: null;
const depthItems = Array.isArray(depthPayload)
? depthPayload
: depthPayload?.depthMaps;
const waterLevelItems = Array.isArray(depthPayload?.waterLevelMaps)
? depthPayload.waterLevelMaps
: [];
return {
rainAnalysis:
rainResult.status === "fulfilled"
? normalizeFldngHstryRainAnalysisPayload(rainResult.value)
: {},
depthRows: Array.isArray(depthItems)
? depthItems.filter((row) => row && typeof row === "object")
: [],
waterLevelRows: waterLevelItems.filter(
(row: unknown) => row && typeof row === "object",
),
complete:
rainResult.status === "fulfilled" && depthResult.status === "fulfilled",
};
};
type FldngHstryGisLayer = {
searchLayer?: {
getSource?: () => VectorSource | null;
} | null;
clearSearchLayer: (option: unknown) => void;
ensureSearchLayer: (option: unknown) => {
set: (key: string, value: unknown) => void;
setSource: (source: VectorSource) => void;
};
showSearchResultsFromGeoJSON: (
layerTypeName: string,
featureId: string,
) => void | Promise<void>;
};
/** 침수흔적 심선·위선의 GeoServer 조회 결과를 화면 생명주기와 분리해 보관한다. */
export const fldngHstryFeatureCache = new Map<
string,
Promise<Feature[]>
>();
/** OpenLayers Feature.clone()이 복사하지 않는 feature ID까지 보존한다. */
const cloneFldngHstryFeature = (feature: Feature) => {
const clonedFeature = feature.clone();
const featureId = feature.getId();
if (featureId !== undefined) {
clonedFeature.setId(featureId);
}
return clonedFeature;
};
const displayCachedFldngHstryFeatures = (
gisLayer: FldngHstryGisLayer,
layerTypeName: string,
features: Feature[],
) => {
const matched = Object.values(GisApp.layerCode ?? {}).find(
(item: any) => item?.lyrTyNm === layerTypeName,
) as any;
if (!matched || features.length === 0) return;
gisLayer.clearSearchLayer(matched);
const layer = gisLayer.ensureSearchLayer(matched);
layer.set("gobjTy", matched.gobjTy);
const clonedFeatures = features.map(cloneFldngHstryFeature);
layer.setSource(new VectorSource({ features: clonedFeatures }));
(GisApp.Module.select as any)?.selectFeature(clonedFeatures[0], false);
};
/** 침수흔적 심선·위선을 최초 한 번만 조회하고 이후에는 캐시로 highlight한다. */
export const showFldngHstryFeature = async (
gisLayer: FldngHstryGisLayer,
layerTypeName: string,
featureId: string,
) => {
const cacheKey = `${layerTypeName}:${featureId}`;
const cachedRequest = fldngHstryFeatureCache.get(cacheKey);
if (cachedRequest) {
const cachedFeatures = await cachedRequest;
displayCachedFldngHstryFeatures(
gisLayer,
layerTypeName,
cachedFeatures,
);
return;
}
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId);
const source = gisLayer.searchLayer?.getSource?.();
if (!source) return;
const featureRequest = new Promise<Feature[]>((resolve, reject) => {
const resolveFeatures = () => {
const features = source.getFeatures();
if (features.length > 0) {
resolve(features.map(cloneFldngHstryFeature));
} else {
reject(new Error("침수흔적 위치 정보가 없습니다."));
}
};
const rejectFeatures = () =>
reject(new Error("침수흔적 위치 조회에 실패했습니다."));
if (source.getFeatures().length > 0) {
resolveFeatures();
return;
}
source.once("featuresloadend", resolveFeatures);
source.once("featuresloaderror", rejectFeatures);
});
fldngHstryFeatureCache.set(cacheKey, featureRequest);
try {
await featureRequest;
} catch (error) {
fldngHstryFeatureCache.delete(cacheKey);
console.warn(error);
}
};
/** 지도 침수흔적위선·심선이 공통으로 사용하는 침수년도 범위다. */
export type FloodHistoryYearRange = {
startYear: string;
endYear: string;
};
/** 침수년도 설정의 초기 범위는 데이터 최소연도부터 현재년도까지다. */
export const buildFloodHistoryDefaultYearRange = (
currentYear = new Date().getFullYear(),
): FloodHistoryYearRange => ({
startYear: String(FLOOD_HISTORY_MIN_YEAR),
endYear: String(currentYear),
});
/** 연도 설정값을 4자리 숫자로 정규화하고 유효하지 않으면 기본 범위를 사용한다. */
export const normalizeFloodHistoryYearRange = (
range?: Partial<FloodHistoryYearRange> | null,
fallback: FloodHistoryYearRange = buildFloodHistoryDefaultYearRange(),
): FloodHistoryYearRange => {
const normalizeYear = (value: unknown) =>
String(value ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 4);
const startYear = normalizeYear(range?.startYear);
const endYear = normalizeYear(range?.endYear);
return {
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
};
};
/** 위선·심선 WFS에 동일하게 결합할 침수년도 CQL 조건을 생성한다. */
export const buildFloodHistoryYearCql = (
range: FloodHistoryYearRange,
enabled: boolean,
) => {
if (!enabled) return "";
const normalized = normalizeFloodHistoryYearRange(range);
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
};
export type FldngHstryYearRange = { startYear: string; endYear: string };
export type FldngHstryFilters = {
ctpvCd: string;
sggCd: string;
@@ -241,47 +30,44 @@ export type FldngHstryFilters = {
rainStartDate: string;
districtName: string;
};
export type FldngHstryDepthZone = {
zoneId: string;
depthLabel: string;
color: string;
ring: [number, number][];
};
export type FldngHstryApiItem = {
fid: string; // FID
fldngMapFid?: string; // 침수흔적도지도 FID
fldngWtlvMapSn?: number | null; // 침수흔적도지도 일련번호
taskClCd?: string; // 업무 구분 코드
fldngDstrNm?: string; // 침수재해명
exmnYr?: string; // 조사년도
avgFldngWtlv?: number | null; // 평균침수위
fldngArea?: number | null; // 침수면적
dtlOcrnCsCn?: string; // 상세발생원인내용
stdgCd?: string; // 표준지역코드
ctpvCd?: string; // 시도코드
sggCd?: string; // 시군구코드
dataSn?: number | null; // 데이터 일련번호
bgngYmd?: string; // 시작일자
endYmd?: string; // 종료일자
fldngBgngHm?: string; // 침수시작시분
fldngEndHm?: string; // 침수종료시분
fldngCsDtlCd?: string; // 침수원인상세코드
fldngDstrctNm?: string; // 침수지구명
damAmt?: number | null; // 피해금액
fldngHr?: number | null; // 침수시간
fldngYr?: string; // 침수년도
cstrnFbctnYn?: string; // 공사여부
atchFileSn?: number | null; // 첨부파일 일련번호
confArea?: number | null; // 확정면적
confLen?: number | null; // 확정연장
rainBgngTm?: string | null; // 강우시작일시
rainEndTm?: string | null; // 강우종료일시
rainTot?: number | null; // 총강우량
rainStr?: number | null; // 강우강도
fid: string;
fldngMapFid?: string;
fldngWtlvMapSn?: number | null;
taskClCd?: string;
fldngDstrNm?: string;
exmnYr?: string;
avgFldngWtlv?: number | null;
fldngArea?: number | null;
dtlOcrnCsCn?: string;
stdgCd?: string;
ctpvCd?: string;
sggCd?: string;
dataSn?: number | null;
bgngYmd?: string;
endYmd?: string;
fldngBgngHm?: string;
fldngEndHm?: string;
fldngCsDtlCd?: string;
fldngDstrctNm?: string;
damAmt?: number | null;
fldngHr?: number | null;
fldngYr?: string;
cstrnFbctnYn?: string;
atchFileSn?: number | null;
confArea?: number | null;
confLen?: number | null;
rainBgngTm?: string | null;
rainEndTm?: string | null;
rainTot?: number | null;
rainStr?: number | null;
};
export type FldngHstryItem = FldngHstryApiItem & {
historyId: string;
ctpvNm: string;
@@ -311,7 +97,6 @@ export type FldngHstryItem = FldngHstryApiItem & {
areaRing: [number, number][];
depthZones: FldngHstryDepthZone[];
};
export type FldngHstryTableRow = FldngHstryItem & {
rowNo: number;
floodYearDisplay: string;
@@ -334,7 +119,6 @@ export const FLDNG_HSTRY_DEFAULT_FILTERS: FldngHstryFilters = {
rainStartDate: "",
districtName: "",
};
export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
{ color: "#fff27a", label: "0.0~0.5m" },
{ color: "#c2f13d", label: "0.5~1.0m" },
@@ -342,7 +126,6 @@ export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
{ color: "#8f8bff", label: "2.0~5.0m" },
{ color: "#f2528b", label: "5.0m 이상" },
] as const;
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 3 },
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 4 },
@@ -356,11 +139,10 @@ export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7 },
{ id: "districtName", title: "침수지구명", widthPercent: 10 },
{ id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7 },
{ id: "floodAreaDisplay", title: "침수\n면적\(ha)", widthPercent: 7 },
{ id: "floodAreaDisplay", title: "침수\n면적(ha)", widthPercent: 7 },
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6 },
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9 },
];
export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
rowNo: "순번",
floodYearDisplay: "침수년도",
@@ -380,370 +162,3 @@ export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
exmnYearDisplay: "조사년도",
damageAmountDisplay: "피해금액",
};
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.length > 0 ? [...points, points[0]!] : [];
};
const toNumericSeed = (value: string) =>
Array.from(value).reduce(
(sum, char, index) => sum + char.charCodeAt(0) * (index + 1),
0,
);
const buildGeometrySeed = (fid: string) => {
const seed = toNumericSeed(fid);
return {
centerX: 1050000 + (seed % 120000),
centerY: 1680000 + (seed % 150000),
width: 14000 + (seed % 9000),
height: 11000 + (seed % 7000),
};
};
const createFldngHstryDepthZones = (
historyId: string,
centerX: number,
centerY: number,
): FldngHstryDepthZone[] => {
return [
{
zoneId: `${historyId}-depth-1`,
depthLabel: "0.0~0.5m",
color: "#fff27a",
ring: createIrregularRing({
centerX: centerX - 2600,
centerY: centerY - 1200,
radiusX: 2900,
radiusY: 2300,
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 + 1800,
centerY: centerY - 600,
radiusX: 3500,
radiusY: 2800,
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,
centerY: centerY + 1800,
radiusX: 4700,
radiusY: 3900,
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 + 3200,
centerY: centerY + 3200,
radiusX: 2600,
radiusY: 2000,
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 + 400,
centerY: centerY - 3600,
radiusX: 1200,
radiusY: 1050,
rotationDeg: -10,
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
}),
},
];
};
const normalizeDate = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!/^\d{8}$/.test(source)) return "";
return `${source.slice(0, 4)}-${source.slice(4, 6)}-${source.slice(6, 8)}`;
};
const normalizeHm = (value?: string | null) => {
const digits = String(value ?? "").replace(/[^0-9]/g, "");
if (!digits) return "";
if (digits.length === 1) return `0${digits}:00`;
if (digits.length === 2) return `${digits}:00`;
if (digits.length === 3)
return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`;
};
const normalizeDateTime = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!source) return "";
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
const date = new Date(source);
if (Number.isNaN(date.getTime()))
return source.slice(0, 16).replace("T", " ");
const yyyy = date.getFullYear();
const mm = `${date.getMonth() + 1}`.padStart(2, "0");
const dd = `${date.getDate()}`.padStart(2, "0");
const hh = `${date.getHours()}`.padStart(2, "0");
const mi = `${date.getMinutes()}`.padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
};
const formatNumber = (value?: number | null, digits = 1, suffix = "") => {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) return `0${suffix}`;
return `${numeric.toFixed(digits)}${suffix}`;
};
const formatInteger = (value?: number | null, suffix = "") => {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) return `0${suffix}`;
return `${numeric.toLocaleString("ko-KR")}${suffix}`;
};
const formatOptionalNumber = (
value?: number | null,
digits = 1,
suffix = "",
) => {
if (value === null || value === undefined) return "-";
const numeric = Number(value);
if (!Number.isFinite(numeric)) return "-";
return `${numeric.toFixed(digits)}${suffix}`;
};
const buildCauseDetailName = (item: FldngHstryApiItem) => {
const direct = String(item.dtlOcrnCsCn ?? "").trim();
if (direct) return direct;
const code = String(item.fldngCsDtlCd ?? "").trim();
return code ? `원인코드 ${code}` : "-";
};
const buildGradeLabel = (avgFloodDepthM: number) => {
if (avgFloodDepthM >= 2) return "3등급";
if (avgFloodDepthM >= 1) return "2등급";
return "1등급";
};
export const normalizeFldngHstryFilters = (
filters: FldngHstryFilters,
): FldngHstryFilters => ({
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(),
rainStartDate: String(filters.rainStartDate ?? "").trim(),
districtName: String(filters.districtName ?? "").trim(),
});
export const buildFldngHstryYearOptions = () => {
const currentYear = new Date().getFullYear();
return Array.from({ length: 30 }, (_, index) => String(currentYear - index));
};
export const buildFldngHstryListQueryString = (
filters: FldngHstryFilters,
page: number,
size: number,
) => {
const normalized = normalizeFldngHstryFilters(filters);
const params = new URLSearchParams({
page: String(page - 1),
size: String(size),
});
appendApiSearchParams(params, {
ctpvCd: normalized.ctpvCd === "0" ? "0" : normalized.ctpvCd.slice(0, 2),
sggCd: normalized.sggCd === "0" ? "0" : normalized.sggCd.slice(0, 5),
fldngYr: normalized.floodYear,
fldngDstrNm: normalized.disasterName,
rainBgngTm: normalized.rainStartDate,
fldngDstrctNm: normalized.districtName,
});
return params.toString();
};
export const formatFloodArea = (value: number) =>
Number(value ?? 0).toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
export const formatFloodDepth = (value: number) => value.toFixed(2);
export const attachFldngHstryGeometry = (
item: FldngHstryApiItem,
): FldngHstryItem => {
const historyId = String(item.fid ?? "");
const geometrySeed = buildGeometrySeed(historyId);
const startDate = normalizeDate(item.bgngYmd);
const endDate = normalizeDate(item.endYmd);
const startTime = normalizeHm(item.fldngBgngHm);
const endTime = normalizeHm(item.fldngEndHm);
const avgFloodDepthM = Number(item.avgFldngWtlv ?? 0);
const floodAreaHa = Number(item.fldngArea ?? 0);
return {
...item,
historyId,
ctpvNm: "",
sggNm: "",
floodYear: String(item.fldngYr ?? "").trim(),
exmnYear: String(item.exmnYr ?? "").trim(),
disasterName: String(item.fldngDstrNm ?? "").trim(),
districtName: String(item.fldngDstrctNm ?? "").trim(),
floodAreaHa,
avgFloodDepthM,
floodDepthM: avgFloodDepthM,
gradeLabel: buildGradeLabel(avgFloodDepthM),
startDate,
endDate,
startTime,
endTime,
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1),
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
causeDetailName: buildCauseDetailName(item),
damageAmount: formatInteger(Number(item.damAmt ?? 0)),
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
rainEndDateTime: normalizeDateTime(item.rainEndTm),
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
rainEndTime: normalizeDateTime(item.rainEndTm).slice(11, 16),
totalRainfallMm: Number(item.rainTot ?? 0),
rainfallIntensityMmHr: Number(item.rainStr ?? 0),
areaRing: createIrregularRing({
centerX: geometrySeed.centerX,
centerY: geometrySeed.centerY,
radiusX: geometrySeed.width / 2,
radiusY: geometrySeed.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: createFldngHstryDepthZones(
historyId,
geometrySeed.centerX,
geometrySeed.centerY,
),
};
};
export const toFldngHstryTableRows = (
items: FldngHstryItem[],
page: number,
pageSize = FLDNG_HSTRY_PAGE_SIZE,
): FldngHstryTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
floodYearDisplay: item.floodYear || "-",
floodHourDisplay: item.floodHour || "-",
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
exmnYearDisplay: item.exmnYear || "-",
damageAmountDisplay: item.damageAmount || "-",
rainBgngTmDisplay: item.rainStartDateTime || "-",
rainEndTmDisplay: item.rainEndDateTime || "-",
rainTotDisplay: formatOptionalNumber(item.rainTot, 1),
rainStrDisplay: formatOptionalNumber(item.rainStr, 1),
}));
const resolveFldngHstryNodeName = (node: any) =>
String(
node?.text ??
node?.data?.lyrNm ??
node?.data?.name ??
node?.lyrNm ??
node?.name ??
"",
).trim();
const resolveFldngHstryLayerType = (node: any) =>
String(
node?.data?.lyrTyNm ??
node?.data?.lyrTypeNm ??
node?.lyrTyNm ??
node?.lyrTypeNm ??
"",
).trim();
export const isFldngHstryNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLOOD_HISTORY_LAYER_NAME ||
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
layerType === FLOOD_HISTORY_LAYER_TYPE ||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE ||
layerType === FLOOD_HISTORY_PREVIEW_LAYER_TYPE
);
};
/** 침수흔적위선 체크 상태를 심선과 분리해 판별한다. */
export const isFloodHistoryWaterLevelNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE
);
};
/** 침수흔적심선 체크 상태를 위선과 분리해 판별한다. */
export const isFloodHistoryDepthNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE
);
};
export const isFldngHstryRootNode = (node: any) =>
resolveFldngHstryNodeName(node) === FLOOD_HISTORY_ROOT_NAME;
export const isFloodHistoryNode = isFldngHstryNode;
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = FLDNG_HSTRY_ROW_SELECT_MESSAGE;
export const FLOOD_HISTORY_PAGE_SIZE = FLDNG_HSTRY_PAGE_SIZE;
export const FLOOD_HISTORY_DOWNLOAD_BATCH_SIZE =
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE;
@@ -0,0 +1,440 @@
/** 침수흔적 검색조건·표시값·geometry·레이어 판별 변환을 제공한다. */
import { appendApiSearchParams } from "@/shared/util/util";
import {
FLDNG_HSTRY_PAGE_SIZE,
FLDNG_HSTRY_DEPTH_LAYER_NAME,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_LAYER_NAME,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_MIN_YEAR,
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
FLDNG_HSTRY_ROOT_NAME,
type FldngHstryApiItem,
type FldngHstryDepthZone,
type FldngHstryFilters,
type FldngHstryItem,
type FldngHstryTableRow,
type FldngHstryYearRange,
} from "./fldngHstryTypes";
export const normalizeFldngHstryWtlvDisplayId = (value: unknown) => {
const normalized = String(value ?? "").trim();
const prefix = `${FLDNG_HSTRY_LAYER_TYPE}.`;
return normalized.startsWith(prefix)
? normalized.slice(prefix.length)
: normalized;
};
export const normalizeFldngHstryWtlvApiFid = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalizeFldngHstryWtlvDisplayId(normalized) : "";
};
export const formatFldngHstryDateTime = (
dateValue: unknown,
timeValue: unknown,
) => {
const rawDate = String(dateValue ?? "").trim();
const rawTime = String(timeValue ?? "").trim();
const normalizedDate = /^\d{8}$/.test(rawDate)
? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`
: rawDate;
const timeDigits = rawTime.replace(/[^0-9]/g, "");
const normalizedTime = !timeDigits
? ""
: timeDigits.length <= 2
? `${timeDigits.padStart(2, "0")}:00`
: `${timeDigits.padStart(4, "0").slice(0, 2)}:${timeDigits.padStart(4, "0").slice(2, 4)}`;
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
};
export const buildFldngHstryDefaultYearRange = (
currentYear = new Date().getFullYear(),
): FldngHstryYearRange => ({
startYear: String(FLDNG_HSTRY_MIN_YEAR),
endYear: String(currentYear),
});
export const normalizeFldngHstryYearRange = (
range?: Partial<FldngHstryYearRange> | null,
fallback: FldngHstryYearRange = buildFldngHstryDefaultYearRange(),
): FldngHstryYearRange => {
const normalizeYear = (value: unknown) =>
String(value ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 4);
const startYear = normalizeYear(range?.startYear);
const endYear = normalizeYear(range?.endYear);
return {
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
};
};
export const buildFldngHstryYearCql = (
range: FldngHstryYearRange,
enabled: boolean,
) => {
if (!enabled) return "";
const normalized = normalizeFldngHstryYearRange(range);
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
};
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;
return [
centerX + radiusX * scale * Math.cos(angle),
centerY + radiusY * scale * Math.sin(angle),
] as [number, number];
});
return points.length > 0 ? [...points, points[0]!] : [];
};
const toNumericSeed = (value: string) =>
Array.from(value).reduce(
(sum, char, index) => sum + char.charCodeAt(0) * (index + 1),
0,
);
const buildGeometrySeed = (fid: string) => {
const seed = toNumericSeed(fid);
return {
centerX: 1050000 + (seed % 120000),
centerY: 1680000 + (seed % 150000),
width: 14000 + (seed % 9000),
height: 11000 + (seed % 7000),
};
};
const createFldngHstryDepthZones = (
historyId: string,
centerX: number,
centerY: number,
): FldngHstryDepthZone[] => [
{
zoneId: `${historyId}-depth-1`,
depthLabel: "0.0~0.5m",
color: "#fff27a",
ring: createIrregularRing({
centerX: centerX - 2600,
centerY: centerY - 1200,
radiusX: 2900,
radiusY: 2300,
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 + 1800,
centerY: centerY - 600,
radiusX: 3500,
radiusY: 2800,
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,
centerY: centerY + 1800,
radiusX: 4700,
radiusY: 3900,
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 + 3200,
centerY: centerY + 3200,
radiusX: 2600,
radiusY: 2000,
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 + 400,
centerY: centerY - 3600,
radiusX: 1200,
radiusY: 1050,
rotationDeg: -10,
scales: [1, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
}),
},
];
const normalizeDate = (value?: string | null) => {
const source = String(value ?? "").trim();
return /^\d{8}$/.test(source)
? `${source.slice(0, 4)}-${source.slice(4, 6)}-${source.slice(6, 8)}`
: "";
};
const normalizeHm = (value?: string | null) => {
const digits = String(value ?? "").replace(/[^0-9]/g, "");
if (!digits) return "";
if (digits.length === 1) return `0${digits}:00`;
if (digits.length === 2) return `${digits}:00`;
if (digits.length === 3)
return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`;
};
const normalizeDateTime = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!source) return "";
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
const date = new Date(source);
if (Number.isNaN(date.getTime()))
return source.slice(0, 16).replace("T", " ");
const yyyy = date.getFullYear();
const mm = `${date.getMonth() + 1}`.padStart(2, "0");
const dd = `${date.getDate()}`.padStart(2, "0");
const hh = `${date.getHours()}`.padStart(2, "0");
const mi = `${date.getMinutes()}`.padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
};
const formatNumber = (value?: number | null, digits = 1, suffix = "") => {
const numeric = Number(value ?? 0);
return Number.isFinite(numeric)
? `${numeric.toFixed(digits)}${suffix}`
: `0${suffix}`;
};
const formatInteger = (value?: number | null, suffix = "") => {
const numeric = Number(value ?? 0);
return Number.isFinite(numeric)
? `${numeric.toLocaleString("ko-KR")}${suffix}`
: `0${suffix}`;
};
const formatOptionalNumber = (
value?: number | null,
digits = 1,
suffix = "",
) => {
if (value === null || value === undefined) return "-";
const numeric = Number(value);
return Number.isFinite(numeric) ? `${numeric.toFixed(digits)}${suffix}` : "-";
};
const buildCauseDetailName = (item: FldngHstryApiItem) => {
const direct = String(item.dtlOcrnCsCn ?? "").trim();
if (direct) return direct;
const code = String(item.fldngCsDtlCd ?? "").trim();
return code ? `원인코드 ${code}` : "-";
};
const buildGradeLabel = (avgFloodDepthM: number) => {
if (avgFloodDepthM >= 2) return "3등급";
if (avgFloodDepthM >= 1) return "2등급";
return "1등급";
};
export const normalizeFldngHstryFilters = (
filters: FldngHstryFilters,
): FldngHstryFilters => ({
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(),
rainStartDate: String(filters.rainStartDate ?? "").trim(),
districtName: String(filters.districtName ?? "").trim(),
});
export const buildFldngHstryYearOptions = () => {
const currentYear = new Date().getFullYear();
return Array.from({ length: 30 }, (_, index) => String(currentYear - index));
};
export const buildFldngHstryListQueryString = (
filters: FldngHstryFilters,
page: number,
size: number,
) => {
const normalized = normalizeFldngHstryFilters(filters);
const params = new URLSearchParams({
page: String(page - 1),
size: String(size),
});
appendApiSearchParams(params, {
ctpvCd: normalized.ctpvCd === "0" ? "0" : normalized.ctpvCd.slice(0, 2),
sggCd: normalized.sggCd === "0" ? "0" : normalized.sggCd.slice(0, 5),
fldngYr: normalized.floodYear,
fldngDstrNm: normalized.disasterName,
rainBgngTm: normalized.rainStartDate,
fldngDstrctNm: normalized.districtName,
});
return params.toString();
};
export const formatFldngHstryArea = (value: number) =>
Number(value ?? 0).toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
export const formatFldngHstryDepth = (value: number) => value.toFixed(2);
export const attachFldngHstryGeometry = (
item: FldngHstryApiItem,
): FldngHstryItem => {
const historyId = String(item.fid ?? "");
const geometrySeed = buildGeometrySeed(historyId);
const startDate = normalizeDate(item.bgngYmd);
const endDate = normalizeDate(item.endYmd);
const startTime = normalizeHm(item.fldngBgngHm);
const endTime = normalizeHm(item.fldngEndHm);
const avgFloodDepthM = Number(item.avgFldngWtlv ?? 0);
const floodAreaHa = Number(item.fldngArea ?? 0);
return {
...item,
historyId,
ctpvNm: "",
sggNm: "",
floodYear: String(item.fldngYr ?? "").trim(),
exmnYear: String(item.exmnYr ?? "").trim(),
disasterName: String(item.fldngDstrNm ?? "").trim(),
districtName: String(item.fldngDstrctNm ?? "").trim(),
floodAreaHa,
avgFloodDepthM,
floodDepthM: avgFloodDepthM,
gradeLabel: buildGradeLabel(avgFloodDepthM),
startDate,
endDate,
startTime,
endTime,
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1),
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
causeDetailName: buildCauseDetailName(item),
damageAmount: formatInteger(Number(item.damAmt ?? 0)),
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
rainEndDateTime: normalizeDateTime(item.rainEndTm),
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
rainEndTime: normalizeDateTime(item.rainEndTm).slice(11, 16),
totalRainfallMm: Number(item.rainTot ?? 0),
rainfallIntensityMmHr: Number(item.rainStr ?? 0),
areaRing: createIrregularRing({
centerX: geometrySeed.centerX,
centerY: geometrySeed.centerY,
radiusX: geometrySeed.width / 2,
radiusY: geometrySeed.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: createFldngHstryDepthZones(
historyId,
geometrySeed.centerX,
geometrySeed.centerY,
),
};
};
export const toFldngHstryTableRows = (
items: FldngHstryItem[],
page: number,
pageSize = FLDNG_HSTRY_PAGE_SIZE,
): FldngHstryTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
floodYearDisplay: item.floodYear || "-",
floodHourDisplay: item.floodHour || "-",
avgFloodDepthDisplay: formatFldngHstryDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFldngHstryArea(item.floodAreaHa),
exmnYearDisplay: item.exmnYear || "-",
damageAmountDisplay: item.damageAmount || "-",
rainBgngTmDisplay: item.rainStartDateTime || "-",
rainEndTmDisplay: item.rainEndDateTime || "-",
rainTotDisplay: formatOptionalNumber(item.rainTot, 1),
rainStrDisplay: formatOptionalNumber(item.rainStr, 1),
}));
const resolveFldngHstryNodeName = (node: any) =>
String(
node?.text ??
node?.data?.lyrNm ??
node?.data?.name ??
node?.lyrNm ??
node?.name ??
"",
).trim();
const resolveFldngHstryLayerType = (node: any) =>
String(
node?.data?.lyrTyNm ??
node?.data?.lyrTypeNm ??
node?.lyrTyNm ??
node?.lyrTypeNm ??
"",
).trim();
export const isFldngHstryNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLDNG_HSTRY_LAYER_NAME ||
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
layerType === FLDNG_HSTRY_LAYER_TYPE ||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE ||
layerType === FLDNG_HSTRY_PREVIEW_LAYER_TYPE
);
};
export const isFldngHstryWtlvNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLDNG_HSTRY_LAYER_NAME || layerType === FLDNG_HSTRY_LAYER_TYPE
);
};
export const isFldngHstryDepthNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE
);
};
export const isFldngHstryRootNode = (node: any) =>
resolveFldngHstryNodeName(node) === FLDNG_HSTRY_ROOT_NAME;
@@ -71,10 +71,11 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import useStore from "@/store/useStore";
import ExmnSurveyResultSection from "../exmn/ExmnSurveyResultSection";
import {
loadFldngHstryDetail,
showFldngHstryFeature,
type FldngHstryDetailCacheValue,
} from "../fldngHstry/fldngHstryTypes";
formatFldngHstryDateTime,
normalizeFldngHstryWtlvApiFid,
normalizeFldngHstryWtlvDisplayId,
} from "../fldngHstry/fldngHstryUtils";
import { useFldngHstryFeatureClick } from "../fldngHstry/FldngHstryFeatureClick";
const linkedFacilityHighlightStyle = new Style({
image: new CircleStyle({
@@ -97,39 +98,6 @@ const linkedFacilityHighlightStyle = new Style({
zIndex: 9999,
});
const normalizeFldngWtlvMapDisplayId = (value: unknown) => {
const normalized = String(value ?? "").trim();
const prefix = "tb_ms_fldng_wtlv_map.";
return normalized.startsWith(prefix)
? normalized.slice(prefix.length)
: normalized;
};
const normalizeFldngWtlvMapApiFid = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized) return "";
return normalizeFldngWtlvMapDisplayId(normalized);
};
// 침수흔적도 침수시작일시, 침수종료일시 한줄로 보이도록
const formatFldngWtlvDateTime = (dateValue: unknown, timeValue: unknown) => {
const rawDate = String(dateValue ?? "").trim();
const rawTime = String(timeValue ?? "").trim();
const normalizedDate = /^\d{8}$/.test(rawDate)
? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`
: rawDate;
const timeDigits = rawTime.replace(/[^0-9]/g, "");
const normalizedTime = !timeDigits
? ""
: timeDigits.length <= 2
? `${timeDigits.padStart(2, "0")}:00`
: `${timeDigits.padStart(4, "0").slice(0, 2)}:${timeDigits.padStart(4, "0").slice(2, 4)}`;
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
};
type MngInstOption = {
mngInstCd: string;
lwrInstNm: string;
@@ -174,24 +142,13 @@ const FeatureClickEventPopup = ({
const flowDirectionBasisRequestCacheRef = useRef(
new Map<string, Promise<any | null>>(),
);
const fldngHstryDetailCacheRef = useRef(
new Map<string, Promise<FldngHstryDetailCacheValue>>(),
);
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
const [floodDepthRows, setFloodDepthRows] = useState<
Record<string, unknown>[]
>([]);
const [floodWtlvRows, setFloodWtlvRows] = useState<Record<string, unknown>[]>(
[],
);
const [selectedFloodDepthFid, setSelectedFloodDepthFid] = useState<
string | null
>(null);
const [selectedFloodWtlvFid, setSelectedFloodWtlvFid] = useState<
string | null
>(null);
const floodDepthYearListRef = useRef<HTMLDivElement>(null);
const floodWtlvListRef = useRef<HTMLDivElement>(null);
const {
hasFldngHstryLinkedFeatures,
fldngHstryFeatureClickHeadName,
loadFldngHstryFeatureClickDetail,
resetFldngHstryFeatureClick,
} = useFldngHstryFeatureClick();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 🎯 관로관리번호 추출
@@ -483,7 +440,7 @@ const FeatureClickEventPopup = ({
result = "침수흔적위선";
setHeadlist(FldngWtlvMapCol);
setLayerSpecificNm(
normalizeFldngWtlvMapDisplayId(
normalizeFldngHstryWtlvDisplayId(
featureData.values_.fid ?? featureId,
),
);
@@ -563,7 +520,7 @@ const FeatureClickEventPopup = ({
case "tb_ms_fldng_wtlv_map": {
result = "침수흔적위선";
setHeadlist(FldngWtlvMapCol2);
setLayerSpecificNm(normalizeFldngWtlvMapDisplayId(featureData.fid));
setLayerSpecificNm(normalizeFldngHstryWtlvDisplayId(featureData.fid));
break;
}
case "tb_ms_fldng_dpth_map": {
@@ -637,7 +594,7 @@ const FeatureClickEventPopup = ({
formatDisplayData(normalizedRegionData),
);
if (selectedLayerName === "침수흔적위선") {
const fldngBgngDtm = formatFldngWtlvDateTime(
const fldngBgngDtm = formatFldngHstryDateTime(
formattedData.bgngYmd ??
formattedData.bgng_ymd ??
data.bgngYmd ??
@@ -647,7 +604,7 @@ const FeatureClickEventPopup = ({
data.fldngBgngHm ??
data.fldng_bgng_hm,
);
const fldngEndDtm = formatFldngWtlvDateTime(
const fldngEndDtm = formatFldngHstryDateTime(
formattedData.endYmd ??
formattedData.end_ymd ??
data.endYmd ??
@@ -850,41 +807,20 @@ const FeatureClickEventPopup = ({
}
if (selectedLayerName === "침수흔적위선" && featureId) {
setFloodDepthRows([]);
setFloodWtlvRows([]);
setSelectedFloodDepthFid(null);
setSelectedFloodWtlvFid(null);
const fldngHstryFid = normalizeFldngWtlvMapApiFid(featureId);
resetFldngHstryFeatureClick();
const fldngHstryFid = normalizeFldngHstryWtlvApiFid(featureId);
if (!fldngHstryFid) {
throw new Error("침수흔적도 fid가 비어 있습니다.");
}
const fldngHstryCacheKey = `tb_ms_fldng_wtlv_map:${fldngHstryFid}`;
let fldngHstryDetailRequest =
fldngHstryDetailCacheRef.current.get(fldngHstryCacheKey);
if (!fldngHstryDetailRequest) {
fldngHstryDetailRequest = loadFldngHstryDetail(fldngHstryFid);
fldngHstryDetailCacheRef.current.set(
fldngHstryCacheKey,
fldngHstryDetailRequest,
);
}
const fldngHstryDetail = await fldngHstryDetailRequest;
if (!fldngHstryDetail.complete) {
fldngHstryDetailCacheRef.current.delete(fldngHstryCacheKey);
}
const fldngHstryDetail =
await loadFldngHstryFeatureClickDetail(fldngHstryFid);
const mergedFldngHstryData = {
...formattedData,
...fldngHstryDetail.rainAnalysis,
};
setFetchData(mergedFldngHstryData);
setTableData(mergedFldngHstryData);
setFloodDepthRows(fldngHstryDetail.depthRows);
setFloodWtlvRows(fldngHstryDetail.waterLevelRows);
// 최초에는 연계 항목을 선택하지 않고 버튼 목록만 표시한다.
setSelectedFloodDepthFid(null);
setSelectedFloodWtlvFid(null);
}
} catch (error) {
console.error("데이터 로딩 중 오류:", error);
@@ -1089,156 +1025,8 @@ const FeatureClickEventPopup = ({
>
<VerticalTable
headName={
layerNm === "침수흔적위선" &&
(floodDepthRows.length > 0 || floodWtlvRows.length > 0) ? (
<div className="flex w-full min-w-0 flex-col items-start gap-2">
<span>기본정보</span>
{floodDepthRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
심선
</span>
<button
type="button"
aria-label="이전 연도"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
floodDepthYearListRef.current?.scrollBy({
left: -120,
behavior: "smooth",
})
}
>
‹
</button>
<div
ref={floodDepthYearListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{[...floodDepthRows]
.map((row, index) => ({
row,
index,
year: String(
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
).trim(),
}))
.filter(({ year }) => Boolean(year))
.sort((left, right) => {
const yearOrder = left.year.localeCompare(
right.year,
undefined,
{ numeric: true },
);
return yearOrder !== 0
? yearOrder
: left.index - right.index;
})
.map(({ row, index, year }) => (
<button
key={`${String(row.fldng_dpth_map_sn ?? row.fid ?? "row")}-${index}`}
type="button"
onClick={async () => {
const depthFid = String(
row.fldng_dpth_map_sn ?? row.fid ?? "",
).trim();
if (depthFid) {
setSelectedFloodDepthFid(depthFid);
setSelectedFloodWtlvFid(null);
const core = GisApp.Module.layer as any;
await showFldngHstryFeature(
core,
"tb_ms_fldng_dpth_map",
`tb_ms_fldng_dpth_map.${depthFid.replace(/^tb_ms_fldng_dpth_map\./, "")}`,
);
}
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFloodDepthFid === String(row.fldng_dpth_map_sn ?? row.fid ?? "").trim() ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{year}년
</button>
))}
</div>
<button
type="button"
aria-label="다음 연도"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
floodDepthYearListRef.current?.scrollBy({
left: 120,
behavior: "smooth",
})
}
>
›
</button>
</div>
)}
{floodWtlvRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
위선
</span>
<button
type="button"
aria-label="이전 위선"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
floodWtlvListRef.current?.scrollBy({
left: -120,
behavior: "smooth",
})
}
>
‹
</button>
<div
ref={floodWtlvListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{floodWtlvRows.map((row, index) => {
const waterLevelFid = String(row.fid ?? "").trim();
const disasterName = String(
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
).trim();
return (
<button
key={`${waterLevelFid || "wtlv"}-${index}`}
type="button"
onClick={async () => {
if (!waterLevelFid) return;
setSelectedFloodWtlvFid(waterLevelFid);
setSelectedFloodDepthFid(null);
const core = GisApp.Module.layer as any;
await showFldngHstryFeature(
core,
"tb_ms_fldng_wtlv_map",
`tb_ms_fldng_wtlv_map.${waterLevelFid.replace(/^tb_ms_fldng_wtlv_map\./, "")}`,
);
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFloodWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{disasterName || "재해명 없음"}
</button>
);
})}
</div>
<button
type="button"
aria-label="다음 위선"
className="shrink-0 px-1 text-lg leading-none text-foreground"
onClick={() =>
floodWtlvListRef.current?.scrollBy({
left: 120,
behavior: "smooth",
})
}
>
›
</button>
</div>
)}
</div>
layerNm === "침수흔적위선" && hasFldngHstryLinkedFeatures ? (
fldngHstryFeatureClickHeadName
) : layerNm === "하수관로" && tableData?.elapsedYears ? (
<>
기본정보{" "}
+101 -97
View File
@@ -145,23 +145,25 @@ import {
type SinkholeYearRange,
} from "./components/sinkhole/sinkholeTypes";
import {
FLOOD_HISTORY_AREA_LAYER_ID,
FLOOD_HISTORY_DEPTH_LAYER_NAME,
FLOOD_HISTORY_DEPTH_LAYER_TYPE,
FLOOD_HISTORY_DEPTH_LAYER_ID,
FLOOD_HISTORY_PREVIEW_LAYER_TYPE,
FLOOD_HISTORY_LAYER_TYPE,
FLOOD_HISTORY_LAYER_NAME,
FLOOD_HISTORY_POPUP_KEY,
FLDNG_HSTRY_AREA_LAYER_ID,
FLDNG_HSTRY_DEPTH_LAYER_NAME,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_DEPTH_LAYER_ID,
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_LAYER_NAME,
FLDNG_HSTRY_POPUP_KEY,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
buildFloodHistoryDefaultYearRange,
buildFloodHistoryYearCql,
isFloodHistoryDepthNode,
isFloodHistoryWaterLevelNode,
isFldngHstryRootNode,
type FloodHistoryYearRange,
type FldngHstryYearRange,
type FldngHstryItem,
} from "./components/fldngHstry/fldngHstryTypes";
import {
buildFldngHstryDefaultYearRange,
buildFldngHstryYearCql,
isFldngHstryDepthNode,
isFldngHstryWtlvNode,
isFldngHstryRootNode,
} from "./components/fldngHstry/fldngHstryUtils";
import {
SBMRG_DNGR_MAP_POPUP_KEY,
isSbmrgDngrMapRootNode,
@@ -268,7 +270,7 @@ const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA";
const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE";
const WATER_RISK_MANHOLE_LAYER_ID = "SN_WATER_RISK_MANHOLE";
const SHORT_TERM_FORECAST_GRID_LAYER_NAME = "초단기예보격자망";
const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [
const FLDNG_HSTRY_SELECTION_PADDING: [number, number, number, number] = [
100, 380, 120, 120,
];
const RIGHT_PANEL_TAB_LIMIT = 5;
@@ -1090,7 +1092,7 @@ const getRightPanelFeatureTitle = (data: any) => {
case "tb_ms_swage_prcs_zone":
return "처리구역";
case "tb_ms_fldng_wtlv_map":
return FLOOD_HISTORY_LAYER_NAME;
return FLDNG_HSTRY_LAYER_NAME;
case "tb_ms_fldng_dpth_map":
return "침수흔적심선";
case "om_dt_untrt_swg_rain":
@@ -3035,28 +3037,27 @@ const Map = () => {
useState(true);
const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false);
// 침수흔적위선과 심선은 하나의 연도 범위를 공유하지만 체크 상태는 독립적으로 관리한다.
const [isFloodWtlvChecked, setIsFloodWtlvChecked] = useState(false);
const [isFloodDepthChecked, setIsFloodDepthChecked] = useState(false);
const [isFldngHstryWtlvChecked, setIsFldngHstryWtlvChecked] = useState(false);
const [isFldngHstryDepthChecked, setIsFldngHstryDepthChecked] =
useState(false);
// 심선은 6개 깊이 등급 중 사용자가 실제 체크한 lyrSn만 필터 레이어로 생성한다.
const [checkedFloodDepthLayerKeys, setCheckedFloodDepthLayerKeys] = useState<
string[]
>([]);
const [floodYearRange, setFloodYearRange] = useState<FloodHistoryYearRange>(
() => buildFloodHistoryDefaultYearRange(),
);
const [isFloodYearFilterEnabled, setIsFloodYearFilterEnabled] =
const [checkedFldngHstryDepthLayerKeys, setCheckedFldngHstryDepthLayerKeys] =
useState<string[]>([]);
const [fldngHstryYearRange, setFldngHstryYearRange] =
useState<FldngHstryYearRange>(() => buildFldngHstryDefaultYearRange());
const [isFldngHstryYearFilterEnabled, setIsFldngHstryYearFilterEnabled] =
useState(true);
const [isFloodYearSettingVisible, setIsFloodYearSettingVisible] =
const [isFldngHstryYearSettingVisible, setIsFldngHstryYearSettingVisible] =
useState(true);
// 닫힌 설정 카드는 레이어가 완전히 해제된 뒤 다시 체크될 때만 자동으로 연다.
const floodLayerCheckedRef = useRef(false);
const fldngHstryLayerCheckedRef = useRef(false);
const sinkholeLayerCheckedRef = useRef(false);
const agedPipeLayerCheckedRef = useRef(false);
const exmnPplLayerCheckedRef = useRef(false);
const exmnMnhlLayerCheckedRef = useRef(false);
const waterDepthLayerCheckedRef = useRef(false);
// 체크 등급 변경과 연도 필터 변경을 구분해 연도 변경 때만 기존 선택 상세를 정리한다.
const floodYearFilterSignatureRef = useRef("");
const fldngHstryYearFilterSignatureRef = useRef("");
const [isExmnPplChecked, setIsExmnPplChecked] = useState(false);
const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false);
const [exmnPplFilters, setExmnPplFilters] = useState<ExmnMapFilters>(() => ({
@@ -5344,7 +5345,7 @@ const Map = () => {
// Select 컬렉션에 다시 넣어도 강조가 렌더링되지 않는다. 탭으로 돌아올
// 때는 FID로 search-layer를 복원한 뒤 로드된 feature를 선택하게 한다.
if (
layerTypeName === FLOOD_HISTORY_LAYER_TYPE ||
layerTypeName === FLDNG_HSTRY_LAYER_TYPE ||
layerTypeName === "tb_ms_fldng_dpth_map"
) {
clearSinkholeFocusedPipeLayer();
@@ -5404,9 +5405,9 @@ const Map = () => {
source,
zIndex: 9993,
});
layer.set("id", FLOOD_HISTORY_AREA_LAYER_ID);
layer.set("lyrNm", FLOOD_HISTORY_LAYER_NAME);
layer.set("lyrTyNm", FLOOD_HISTORY_PREVIEW_LAYER_TYPE);
layer.set("id", FLDNG_HSTRY_AREA_LAYER_ID);
layer.set("lyrNm", FLDNG_HSTRY_LAYER_NAME);
layer.set("lyrTyNm", FLDNG_HSTRY_PREVIEW_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
fldngHstryAreaLayerRef.current = layer;
@@ -5424,9 +5425,9 @@ const Map = () => {
source,
zIndex: 9992,
});
layer.set("id", FLOOD_HISTORY_DEPTH_LAYER_ID);
layer.set("lyrNm", FLOOD_HISTORY_LAYER_NAME);
layer.set("lyrTyNm", FLOOD_HISTORY_PREVIEW_LAYER_TYPE);
layer.set("id", FLDNG_HSTRY_DEPTH_LAYER_ID);
layer.set("lyrNm", FLDNG_HSTRY_LAYER_NAME);
layer.set("lyrTyNm", FLDNG_HSTRY_PREVIEW_LAYER_TYPE);
layer.set("clickPsblYn", "N");
gisMaps.current.addLayer(layer);
fldngHstryDepthLayerRef.current = layer;
@@ -5480,7 +5481,7 @@ const Map = () => {
const geometry = new Polygon([detail.areaRing]);
gisMaps.current.getView?.().fit?.(geometry.getExtent(), {
duration: 350,
padding: FLOOD_HISTORY_SELECTION_PADDING,
padding: FLDNG_HSTRY_SELECTION_PADDING,
maxZoom: 15,
constrainResolution: true,
nearest: true,
@@ -5508,7 +5509,7 @@ const Map = () => {
);
const openFldngHstryPopupWindow = useCallback(() => {
const popup = openWindowPopup(FLOOD_HISTORY_POPUP_KEY as WindowPopupKey);
const popup = openWindowPopup(FLDNG_HSTRY_POPUP_KEY as WindowPopupKey);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
return null;
@@ -6053,7 +6054,7 @@ const Map = () => {
// 제거되지 않는다. 마지막 탭을 닫는 경우와 다른 일반 feature 탭으로
// 전환되는 경우 모두 잔상으로 남지 않도록 여기서 명시적으로 제거한다.
if (
closingLayerTypeName === FLOOD_HISTORY_LAYER_TYPE ||
closingLayerTypeName === FLDNG_HSTRY_LAYER_TYPE ||
closingLayerTypeName === "tb_ms_fldng_dpth_map"
) {
const layerModule = GisApp.Module.layer as any;
@@ -6478,33 +6479,33 @@ const Map = () => {
agedPipeLayerCheckedRef.current = agedPipeChecked;
waterDepthLayerCheckedRef.current = waterDepthChecked;
setIsAgedPipeChecked(agedPipeChecked);
const floodWtlvChecked = checkedNodesData.some((node) =>
isFloodHistoryWaterLevelNode(node),
const fldngHstryWtlvChecked = checkedNodesData.some((node) =>
isFldngHstryWtlvNode(node),
);
const checkedFloodDepthNodes = checkedNodesData.filter((node) =>
isFloodHistoryDepthNode(node),
const checkedFldngHstryDepthNodes = checkedNodesData.filter((node) =>
isFldngHstryDepthNode(node),
);
const checkedDepthLayerKeys = Array.from(
const checkedFldngHstryDepthLayerKeys = Array.from(
new Set(
checkedFloodDepthNodes
checkedFldngHstryDepthNodes
.map((node) => String(node?.data?.lyrSn ?? node?.id ?? "").trim())
.filter(Boolean),
),
).sort((left, right) => Number(left) - Number(right));
const floodDepthChecked = checkedDepthLayerKeys.length > 0;
const fldngHstryChecked = floodWtlvChecked || floodDepthChecked;
setIsFloodWtlvChecked(floodWtlvChecked);
setIsFloodDepthChecked(floodDepthChecked);
setCheckedFloodDepthLayerKeys((currentKeys) =>
currentKeys.join(",") === checkedDepthLayerKeys.join(",")
const fldngHstryDepthChecked = checkedFldngHstryDepthLayerKeys.length > 0;
const fldngHstryChecked = fldngHstryWtlvChecked || fldngHstryDepthChecked;
setIsFldngHstryWtlvChecked(fldngHstryWtlvChecked);
setIsFldngHstryDepthChecked(fldngHstryDepthChecked);
setCheckedFldngHstryDepthLayerKeys((currentKeys) =>
currentKeys.join(",") === checkedFldngHstryDepthLayerKeys.join(",")
? currentKeys
: checkedDepthLayerKeys,
: checkedFldngHstryDepthLayerKeys,
);
setIsFldngHstryChecked(fldngHstryChecked);
if (fldngHstryChecked && !floodLayerCheckedRef.current) {
setIsFloodYearSettingVisible(true);
if (fldngHstryChecked && !fldngHstryLayerCheckedRef.current) {
setIsFldngHstryYearSettingVisible(true);
}
floodLayerCheckedRef.current = fldngHstryChecked;
fldngHstryLayerCheckedRef.current = fldngHstryChecked;
if (!fldngHstryChecked) {
clearFldngHstrySelection();
}
@@ -6693,18 +6694,18 @@ const Map = () => {
});
}
if (isFldngHstryChecked && isFloodYearSettingVisible) {
if (isFldngHstryChecked && isFldngHstryYearSettingVisible) {
nextItems.push({
key: "fldngHstry",
label: "침수흔적도",
order: resolveSettingOrder("fldngHstry", 9997),
content: (
<FldngHstryLayerSettingPanel
yearRange={floodYearRange}
enabled={isFloodYearFilterEnabled}
onYearRangeChange={setFloodYearRange}
onEnabledChange={setIsFloodYearFilterEnabled}
onClose={() => setIsFloodYearSettingVisible(false)}
yearRange={fldngHstryYearRange}
enabled={isFldngHstryYearFilterEnabled}
onYearRangeChange={setFldngHstryYearRange}
onEnabledChange={setIsFldngHstryYearFilterEnabled}
onClose={() => setIsFldngHstryYearSettingVisible(false)}
/>
),
});
@@ -6798,7 +6799,7 @@ const Map = () => {
exmnMnhlFilters,
exmnPplFilters,
handleApplyAgedPipeFilters,
floodYearRange,
fldngHstryYearRange,
isAgedPipeChecked,
isAgedPipeSettingEnabled,
isAgedPipeSettingVisible,
@@ -6809,8 +6810,8 @@ const Map = () => {
isExmnPplSettingEnabled,
isExmnPplSettingVisible,
isFldngHstryChecked,
isFloodYearFilterEnabled,
isFloodYearSettingVisible,
isFldngHstryYearFilterEnabled,
isFldngHstryYearSettingVisible,
isSinkholeChecked,
isSinkholeSettingEnabled,
isSinkholeSettingVisible,
@@ -6934,60 +6935,60 @@ const Map = () => {
if (!mapReady || !gisMaps.current) return;
const core = GisApp.Module.core as any;
const yearCql = buildFloodHistoryYearCql(
floodYearRange,
isFloodYearFilterEnabled,
const yearCql = buildFldngHstryYearCql(
fldngHstryYearRange,
isFldngHstryYearFilterEnabled,
);
const yearFilterSignature = `${floodYearRange.startYear}:${floodYearRange.endYear}:${isFloodYearFilterEnabled}`;
const yearFilterSignature = `${fldngHstryYearRange.startYear}:${fldngHstryYearRange.endYear}:${isFldngHstryYearFilterEnabled}`;
if (
floodYearFilterSignatureRef.current &&
floodYearFilterSignatureRef.current !== yearFilterSignature
fldngHstryYearFilterSignatureRef.current &&
fldngHstryYearFilterSignatureRef.current !== yearFilterSignature
) {
clearFldngHstrySelection();
}
floodYearFilterSignatureRef.current = yearFilterSignature;
fldngHstryYearFilterSignatureRef.current = yearFilterSignature;
/** 기준 레이어 필터를 보존하면서 선택한 침수년도 조건을 결합한다. */
const resolveFloodFilter = (baseLayerCode: any) => {
const resolveFldngHstryFilter = (baseLayerCode: any) => {
const baseFilter = String(baseLayerCode?.flterSql ?? "").trim();
return [baseFilter && `(${baseFilter})`, yearCql]
.filter(Boolean)
.join(" AND ");
};
type FloodLayerSpec = {
type FldngHstryLayerSpec = {
id: string;
layerType: string;
layerName: string;
baseLayerCode: any;
filterSql: string;
};
const desiredLayers: FloodLayerSpec[] = [];
const desiredLayers: FldngHstryLayerSpec[] = [];
const layerCodes = Object.values(GisApp.layerCode ?? {}) as any[];
if (isFloodWtlvChecked) {
if (isFldngHstryWtlvChecked) {
const baseLayerCode = layerCodes.find(
(item) =>
item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y",
item?.lyrTyNm === FLDNG_HSTRY_LAYER_TYPE && item?.actlLyrYn === "Y",
);
if (baseLayerCode) {
desiredLayers.push({
id: FLDNG_HSTRY_WFS_LAYER_ID,
layerType: FLOOD_HISTORY_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_LAYER_NAME,
layerType: FLDNG_HSTRY_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLDNG_HSTRY_LAYER_NAME,
baseLayerCode,
filterSql: resolveFloodFilter(baseLayerCode),
filterSql: resolveFldngHstryFilter(baseLayerCode),
});
}
}
if (isFloodDepthChecked) {
const selectedDepthLayerKeys = new Set(checkedFloodDepthLayerKeys);
if (isFldngHstryDepthChecked) {
const selectedDepthLayerKeys = new Set(checkedFldngHstryDepthLayerKeys);
layerCodes
.filter((item) => {
const layerKey = String(item?.lyrSn ?? item?.id ?? "").trim();
return (
item?.lyrTyNm === FLOOD_HISTORY_DEPTH_LAYER_TYPE &&
item?.lyrTyNm === FLDNG_HSTRY_DEPTH_LAYER_TYPE &&
item?.actlLyrYn === "Y" &&
selectedDepthLayerKeys.has(layerKey)
);
@@ -6998,26 +6999,27 @@ const Map = () => {
).trim();
desiredLayers.push({
id: `${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_${layerKey}`,
layerType: FLOOD_HISTORY_DEPTH_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_DEPTH_LAYER_NAME,
layerType: FLDNG_HSTRY_DEPTH_LAYER_TYPE,
layerName: baseLayerCode?.lyrNm || FLDNG_HSTRY_DEPTH_LAYER_NAME,
baseLayerCode,
filterSql: resolveFloodFilter(baseLayerCode),
filterSql: resolveFldngHstryFilter(baseLayerCode),
});
});
}
const desiredLayerIds = new Set(desiredLayers.map((item) => item.id));
const mapLayers = [
...(gisMaps.current?.getLayers?.()?.getArray?.() ?? []),
];
const isManagedFloodLayerId = (id: string) =>
const mapLayers = [...(gisMaps.current?.getLayers?.()?.getArray?.() ?? [])];
const isManagedFldngHstryLayerId = (id: string) =>
id === FLDNG_HSTRY_WFS_LAYER_ID ||
id.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`);
// 체크 해제된 등급만 삭제하고, 계속 체크된 등급은 지도에 그대로 유지한다.
mapLayers.forEach((mapLayer: any) => {
const layerId = String(mapLayer?.get?.("id") ?? "");
if (isManagedFloodLayerId(layerId) && !desiredLayerIds.has(layerId)) {
if (
isManagedFldngHstryLayerId(layerId) &&
!desiredLayerIds.has(layerId)
) {
gisMaps.current?.removeLayer?.(mapLayer);
}
});
@@ -7032,7 +7034,9 @@ const Map = () => {
?.find(
(mapLayer: any) => String(mapLayer?.get?.("id") ?? "") === spec.id,
);
if (existingLayer?.get?.("floodFilterSignature") === filterSignature) {
if (
existingLayer?.get?.("fldngHstryFilterSignature") === filterSignature
) {
existingLayer?.setVisible?.(true);
return;
}
@@ -7056,7 +7060,7 @@ const Map = () => {
layer?.set?.("id", spec.id);
layer?.set?.("lyrNm", spec.layerName);
layer?.set?.("lyrTyNm", spec.layerType);
layer?.set?.("floodFilterSignature", filterSignature);
layer?.set?.("fldngHstryFilterSignature", filterSignature);
});
// 전용 필터 레이어가 담당하는 유형의 원본 레이어만 숨겨 중복 표출을 막는다.
@@ -7071,19 +7075,19 @@ const Map = () => {
const layerType = String(mapLayer?.get?.("lyrTyNm") ?? "");
if (
activeFilteredTypes.has(layerType) &&
!isManagedFloodLayerId(layerId)
!isManagedFldngHstryLayerId(layerId)
) {
mapLayer?.setVisible?.(false);
}
});
}, [
checkedFloodDepthLayerKeys,
checkedFldngHstryDepthLayerKeys,
clearFldngHstrySelection,
floodYearRange,
fldngHstryYearRange,
isFldngHstryChecked,
isFloodDepthChecked,
isFloodWtlvChecked,
isFloodYearFilterEnabled,
isFldngHstryDepthChecked,
isFldngHstryWtlvChecked,
isFldngHstryYearFilterEnabled,
mapReady,
]);