Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic

This commit is contained in:
kiminchan
2026-09-01 16:01:37 +09:00
4 changed files with 535 additions and 63 deletions
@@ -0,0 +1,116 @@
"use client";
import { SelectBox } from "@common/business_components/ui";
import {
FLOOD_HISTORY_MIN_YEAR,
normalizeFloodHistoryYearRange,
type FloodHistoryYearRange,
} from "./fldngHstryTypes";
type Props = {
yearRange: FloodHistoryYearRange;
enabled: boolean;
onYearRangeChange: (yearRange: FloodHistoryYearRange) => void;
onEnabledChange: (enabled: boolean) => void;
onClose: () => void;
};
/** 침수흔적위선·심선에 공통 적용되는 즉시 반영 침수년도 설정 카드다. */
export default function FldngHstryLayerSettingPanel({
yearRange,
enabled,
onYearRangeChange,
onEnabledChange,
onClose,
}: Props) {
const normalizedRange = normalizeFloodHistoryYearRange(yearRange);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from(
{ length: currentYear - FLOOD_HISTORY_MIN_YEAR + 1 },
(_, index) => String(currentYear - index),
);
const startYearOptions = yearOptions
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
.map((year) => ({ val: year, text: year }));
const endYearOptions = yearOptions
.filter((year) => Number(year) >= Number(normalizedRange.startYear))
.map((year) => ({ val: year, text: year }));
const yearSelectClass =
"h-[22px] w-[62px] rounded-none border border-input bg-background px-[4px] text-center text-[1rem] leading-none text-foreground";
const changeYear = (key: keyof FloodHistoryYearRange, value: string) => {
onYearRangeChange(
normalizeFloodHistoryYearRange({
...normalizedRange,
[key]: value,
}),
);
};
return (
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
<div className="flex min-h-[32px] items-center justify-between border-b border-border px-2">
<span className="text-[1.15rem] font-semibold text-label">
침수흔적도
</span>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="침수연도 필터 적용 여부"
title={enabled ? "침수연도 필터 적용 중" : "침수연도 필터 미적용"}
className={`relative h-[18px] w-[34px] rounded-full transition-colors ${enabled ? "bg-primary" : "bg-muted-foreground/40"}`}
onClick={() => onEnabledChange(!enabled)}
>
<span
className={`absolute top-[2px] h-[14px] w-[14px] rounded-full bg-white shadow-sm transition-[left] ${enabled ? "left-[18px]" : "left-[2px]"}`}
/>
</button>
<button
type="button"
aria-label="침수연도 설정 닫기"
title="닫기"
className="grid h-[20px] w-[20px] place-items-center bg-transparent text-[1.3rem] leading-none text-muted-foreground hover:text-foreground"
onClick={onClose}
>
×
</button>
</div>
</div>
<div className="flex min-h-[38px] items-center gap-1.5 px-2 py-2 text-[1.05rem] font-medium text-label">
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
<span className="shrink-0">침수년도</span>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<div className="w-[62px]">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
label=""
selectArray={startYearOptions}
value={normalizedRange.startYear}
onSelectValue={(value) => changeYear("startYear", String(value))}
selectContentClass="z-[101]"
labelClass="hidden"
/>
</div>
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
~
</span>
<div className="w-[62px]">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
label=""
selectArray={endYearOptions}
value={normalizedRange.endYear}
onSelectValue={(value) => changeYear("endYear", String(value))}
selectContentClass="z-[101]"
labelClass="hidden"
/>
</div>
</div>
</div>
</div>
);
}
@@ -5,13 +5,57 @@ import type { UiHeadProps } from "@common/business_components/ui";
export const FLOOD_HISTORY_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_PAGE_SIZE = 10;
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
export const FLOOD_HISTORY_MIN_YEAR = 2002;
/** 지도 침수흔적위선·심선이 공통으로 사용하는 침수년도 범위다. */
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 FldngHstryFilters = {
ctpvCd: string;
@@ -382,8 +426,10 @@ export const buildFldngHstryListQueryString = (
if (normalized.floodYear) params.set("fldngYr", normalized.floodYear);
if (normalized.disasterName)
params.set("fldngDstrNm", normalized.disasterName);
if (normalized.rainStartDate) params.set("rainBgngTm", normalized.rainStartDate);
if (normalized.districtName) params.set("fldngDstrctNm", normalized.districtName);
if (normalized.rainStartDate)
params.set("rainBgngTm", normalized.rainStartDate);
if (normalized.districtName)
params.set("fldngDstrctNm", normalized.districtName);
return params.toString();
};
@@ -494,11 +540,32 @@ export const isFldngHstryNode = (node: any) => {
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;
+333 -58
View File
@@ -102,6 +102,7 @@ import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap";
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel";
import FldngHstryLayerSettingPanel from "./components/fldngHstry/FldngHstryLayerSettingPanel";
import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel";
import { getExmnMapLayer } from "./components/exmn/exmnMapApi";
import {
@@ -143,14 +144,20 @@ import {
} 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_ROW_SELECT_MESSAGE,
isFldngHstryNode,
buildFloodHistoryDefaultYearRange,
buildFloodHistoryYearCql,
isFloodHistoryDepthNode,
isFloodHistoryWaterLevelNode,
isFldngHstryRootNode,
type FloodHistoryYearRange,
type FldngHstryItem,
} from "./components/fldngHstry/fldngHstryTypes";
import {
@@ -186,6 +193,8 @@ const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
// 노후관로 지도 강조선과 시설물 트리의 선형 범례가 동일하게 사용하는 빨간색이다.
const AGED_PIPE_HIGHLIGHT_COLOR = "rgba(235, 40, 40, 0.82)";
const FLDNG_HSTRY_WFS_LAYER_ID = "SN_FLDNG_HSTRY_WFS";
// 심선은 동일 레이어 유형에 6개 깊이 등급 설정이 있어 prefix 뒤에 lyrSn을 붙여 각각 생성한다.
const FLDNG_DEPTH_WFS_LAYER_ID_PREFIX = "SN_FLDNG_DEPTH_WFS";
const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
@@ -3005,6 +3014,24 @@ const Map = () => {
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
const [isSinkholeChecked, setIsSinkholeChecked] = useState(false);
const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false);
// 침수흔적위선과 심선은 하나의 연도 범위를 공유하지만 체크 상태는 독립적으로 관리한다.
const [isFloodWtlvChecked, setIsFloodWtlvChecked] = useState(false);
const [isFloodDepthChecked, setIsFloodDepthChecked] = useState(false);
// 심선은 6개 깊이 등급 중 사용자가 실제 체크한 lyrSn만 필터 레이어로 생성한다.
const [checkedFloodDepthLayerKeys, setCheckedFloodDepthLayerKeys] = useState<
string[]
>([]);
const [floodYearRange, setFloodYearRange] = useState<FloodHistoryYearRange>(
() => buildFloodHistoryDefaultYearRange(),
);
const [isFloodYearFilterEnabled, setIsFloodYearFilterEnabled] =
useState(true);
const [isFloodYearSettingVisible, setIsFloodYearSettingVisible] =
useState(true);
// 닫힌 설정 카드는 레이어가 완전히 해제된 뒤 다시 체크될 때만 자동으로 연다.
const floodLayerCheckedRef = useRef(false);
// 체크 등급 변경과 연도 필터 변경을 구분해 연도 변경 때만 기존 선택 상세를 정리한다.
const floodYearFilterSignatureRef = useRef("");
const [isExmnPplChecked, setIsExmnPplChecked] = useState(false);
const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false);
const [exmnPplFilters, setExmnPplFilters] = useState<ExmnMapFilters>(() => ({
@@ -3463,7 +3490,10 @@ const Map = () => {
selectedWaterRiskMapView?.riskStatus ??
selectedWaterRiskResultRow?.riskStatus ??
"NORMAL",
[selectedWaterRiskMapView?.riskStatus, selectedWaterRiskResultRow?.riskStatus],
[
selectedWaterRiskMapView?.riskStatus,
selectedWaterRiskResultRow?.riskStatus,
],
);
/** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */
@@ -3521,7 +3551,8 @@ const Map = () => {
const loadFloodPredictionGridLayer = useCallback(
async (sggCd: string, analysisDateTime: string, extent: number[]) => {
const map = gisMaps.current;
if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime)) return;
if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime))
return;
removeFloodPredictionGridLayer();
try {
const response = await getRawJson(
@@ -3529,29 +3560,74 @@ const Map = () => {
);
const payload = response?.data ?? response;
const row = payload && !Array.isArray(payload) ? payload : null;
if (!row || extent.length !== 4 || extent.some((value) => !Number.isFinite(value))) return;
if (
!row ||
extent.length !== 4 ||
extent.some((value) => !Number.isFinite(value))
)
return;
const center = [
(extent[0]! + extent[2]!) / 2,
(extent[1]! + extent[3]!) / 2,
];
const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(1);
const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(1);
const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(
1,
);
const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(
1,
);
const container = document.createElement("div");
container.style.cssText = "position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; min-width:230px;";
container.style.cssText =
"position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; min-width:230px;";
const close = document.createElement("button");
close.type = "button";
close.textContent = "×";
close.title = "침수예측 오버레이 닫기";
close.style.cssText = "position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;";
close.style.cssText =
"position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;";
close.addEventListener("click", () => removeFloodPredictionGridLayer());
const table = document.createElement("table");
table.style.cssText = "border-collapse:collapse; width:100%;";
const title = table.createTHead().insertRow();
const titleCell = title.insertCell(); titleCell.colSpan = 2; titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`; titleCell.style.cssText = "font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;";
const titleCell = title.insertCell();
titleCell.colSpan = 2;
titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`;
titleCell.style.cssText =
"font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;";
const body = table.createTBody();
[["분석시각", formatFloodPredictionDateTime(row.analysisDateTime ?? analysisDateTime)], ["강우시작시각", formatFloodPredictionDateTime(row.rainBeginningDateTime)], ["관측 누적강수량(mm)", observed], ["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)], ["예측 누적강수량(mm)", predicted], ["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`], ["침수위험도", row.floodRiskGrade ?? "-"]].forEach(([label, value]) => { const tr = body.insertRow(); const key = tr.insertCell(); key.textContent = label; key.style.cssText = "font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; const val = tr.insertCell(); val.textContent = value; val.style.cssText = "font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; });
[
[
"분석시각",
formatFloodPredictionDateTime(
row.analysisDateTime ?? analysisDateTime,
),
],
[
"강우시작시각",
formatFloodPredictionDateTime(row.rainBeginningDateTime),
],
["관측 누적강수량(mm)", observed],
["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)],
["예측 누적강수량(mm)", predicted],
["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`],
["침수위험도", row.floodRiskGrade ?? "-"],
].forEach(([label, value]) => {
const tr = body.insertRow();
const key = tr.insertCell();
key.textContent = label;
key.style.cssText =
"font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
const val = tr.insertCell();
val.textContent = value;
val.style.cssText =
"font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
});
container.append(close, table);
const overlay = new Overlay({ element: container, positioning: "center-center", stopEvent: true });
const overlay = new Overlay({
element: container,
positioning: "center-center",
stopEvent: true,
});
overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID);
overlay.setPosition(center);
map.addOverlay(overlay);
@@ -3682,7 +3758,9 @@ const Map = () => {
: [];
const sggCd = String(data.payload?.sggCd ?? "").trim();
const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim();
const analysisDateTime = String(data.payload?.analysisDateTime ?? "").trim();
const analysisDateTime = String(
data.payload?.analysisDateTime ?? "",
).trim();
const map = gisMaps.current;
const select = GisApp.Module.select as any;
if (
@@ -5924,6 +6002,22 @@ const Map = () => {
if (!tabId) return;
const closingTab = rightPanelTabs.find((tab) => tab.id === tabId);
const remainingTabs = removeRightPanelTabGroup(rightPanelTabs, tabId);
const closingLayerTypeName = getSelectedFeatureLayerTypeName(
closingTab?.featureData,
);
// 침수흔적 연계 조회로 만든 공용 search-layer는 선택 해제만으로
// 제거되지 않는다. 마지막 탭을 닫는 경우와 다른 일반 feature 탭으로
// 전환되는 경우 모두 잔상으로 남지 않도록 여기서 명시적으로 제거한다.
if (
closingLayerTypeName === FLOOD_HISTORY_LAYER_TYPE ||
closingLayerTypeName === "tb_ms_fldng_dpth_map"
) {
const layerModule = GisApp.Module.layer as any;
if (layerModule?.searchLayer) {
layerModule.clearSearchLayer?.();
}
}
if (closingTab?.kind === "sinkhole") {
setSelectedSinkholeDetail(null);
@@ -6131,18 +6225,24 @@ const Map = () => {
try {
setIsAgedPipeListLoading(true);
setAgedPipeDownloadProgressText("Excel 파일을 준비하고 있습니다.");
await downloadAgedPipeExcel(appliedAgedPipeFilters, (job) => {
if (job.status === "QUEUED") {
setAgedPipeDownloadProgressText("Excel 다운로드 작업이 대기 중입니다.");
return;
}
await downloadAgedPipeExcel(
appliedAgedPipeFilters,
(job) => {
if (job.status === "QUEUED") {
setAgedPipeDownloadProgressText(
"Excel 다운로드 작업이 대기 중입니다.",
);
return;
}
if (job.status === "RUNNING") {
setAgedPipeDownloadProgressText(
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
}, agedPipeTotalCount);
if (job.status === "RUNNING") {
setAgedPipeDownloadProgressText(
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
},
agedPipeTotalCount,
);
setAgedPipeDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
@@ -6327,10 +6427,33 @@ const Map = () => {
WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")),
);
setIsAgedPipeChecked(agedPipeChecked);
const fldngHstryChecked = checkedNodesData.some((node) =>
isFldngHstryNode(node),
const floodWtlvChecked = checkedNodesData.some((node) =>
isFloodHistoryWaterLevelNode(node),
);
const checkedFloodDepthNodes = checkedNodesData.filter((node) =>
isFloodHistoryDepthNode(node),
);
const checkedDepthLayerKeys = Array.from(
new Set(
checkedFloodDepthNodes
.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(",")
? currentKeys
: checkedDepthLayerKeys,
);
setIsFldngHstryChecked(fldngHstryChecked);
if (fldngHstryChecked && !floodLayerCheckedRef.current) {
setIsFloodYearSettingVisible(true);
}
floodLayerCheckedRef.current = fldngHstryChecked;
if (!fldngHstryChecked) {
clearFldngHstrySelection();
}
@@ -6349,6 +6472,7 @@ const Map = () => {
setActiveLayerSettingOrder((currentOrder) => {
const activeKeys = [
waterDepthChecked ? "waterDepthLegend" : null,
fldngHstryChecked ? "fldngHstry" : null,
sinkholeChecked ? "sinkhole" : null,
agedPipeChecked ? "agedPipe" : null,
exmnPplChecked ? "exmnPpl" : null,
@@ -6506,6 +6630,23 @@ const Map = () => {
});
}
if (isFldngHstryChecked && isFloodYearSettingVisible) {
nextItems.push({
key: "fldngHstry",
label: "침수흔적도",
order: resolveSettingOrder("fldngHstry", 9997),
content: (
<FldngHstryLayerSettingPanel
yearRange={floodYearRange}
enabled={isFloodYearFilterEnabled}
onYearRangeChange={setFloodYearRange}
onEnabledChange={setIsFloodYearFilterEnabled}
onClose={() => setIsFloodYearSettingVisible(false)}
/>
),
});
}
if (isSinkholeChecked) {
nextItems.push({
key: "sinkhole",
@@ -6587,9 +6728,13 @@ const Map = () => {
exmnMnhlFilters,
exmnPplFilters,
handleApplyAgedPipeFilters,
floodYearRange,
isAgedPipeChecked,
isExmnMnhlChecked,
isExmnPplChecked,
isFldngHstryChecked,
isFloodYearFilterEnabled,
isFloodYearSettingVisible,
isSinkholeChecked,
isWaterDepthLayerActive,
lastSinkholeSyncSummary,
@@ -6716,39 +6861,173 @@ const Map = () => {
if (!mapReady || !gisMaps.current) return;
const core = GisApp.Module.core as any;
core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
const yearCql = buildFloodHistoryYearCql(
floodYearRange,
isFloodYearFilterEnabled,
);
const yearFilterSignature = `${floodYearRange.startYear}:${floodYearRange.endYear}:${isFloodYearFilterEnabled}`;
if (
floodYearFilterSignatureRef.current &&
floodYearFilterSignatureRef.current !== yearFilterSignature
) {
clearFldngHstrySelection();
}
floodYearFilterSignatureRef.current = yearFilterSignature;
if (!isFldngHstryChecked) return;
const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
(item: any) =>
item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y",
) as any;
if (!baseLayerCode) return;
const layerCode = {
...baseLayerCode,
id: FLDNG_HSTRY_WFS_LAYER_ID,
lyrSn: FLDNG_HSTRY_WFS_LAYER_ID,
lyrNm: FLOOD_HISTORY_LAYER_NAME,
lyrTyNm: FLOOD_HISTORY_LAYER_TYPE,
actlLyrYn: "Y",
exprsYn: "Y",
clickPsblYn: "Y",
cmncNm: "bbox",
srvcNm: "wfs",
/** 기준 레이어 필터를 보존하면서 선택한 침수년도 조건을 결합한다. */
const resolveFloodFilter = (baseLayerCode: any) => {
const baseFilter = String(baseLayerCode?.flterSql ?? "").trim();
return [baseFilter && `(${baseFilter})`, yearCql]
.filter(Boolean)
.join(" AND ");
};
const layer = core?.addWfsLayer?.(layerCode);
layer?.set?.("id", FLDNG_HSTRY_WFS_LAYER_ID);
layer?.set?.("lyrNm", FLOOD_HISTORY_LAYER_NAME);
layer?.set?.("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE);
type FloodLayerSpec = {
id: string;
layerType: string;
layerName: string;
baseLayerCode: any;
filterSql: string;
};
const desiredLayers: FloodLayerSpec[] = [];
const layerCodes = Object.values(GisApp.layerCode ?? {}) as any[];
if (isFloodWtlvChecked) {
const baseLayerCode = layerCodes.find(
(item) =>
item?.lyrTyNm === FLOOD_HISTORY_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,
baseLayerCode,
filterSql: resolveFloodFilter(baseLayerCode),
});
}
}
if (isFloodDepthChecked) {
const selectedDepthLayerKeys = new Set(checkedFloodDepthLayerKeys);
layerCodes
.filter((item) => {
const layerKey = String(item?.lyrSn ?? item?.id ?? "").trim();
return (
item?.lyrTyNm === FLOOD_HISTORY_DEPTH_LAYER_TYPE &&
item?.actlLyrYn === "Y" &&
selectedDepthLayerKeys.has(layerKey)
);
})
.forEach((baseLayerCode) => {
const layerKey = String(
baseLayerCode?.lyrSn ?? baseLayerCode?.id,
).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,
baseLayerCode,
filterSql: resolveFloodFilter(baseLayerCode),
});
});
}
const desiredLayerIds = new Set(desiredLayers.map((item) => item.id));
const mapLayers = gisMaps.current?.getLayers?.()?.getArray?.() ?? [];
const isManagedFloodLayerId = (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)) {
core?.deleteLayer?.(layerId);
}
});
desiredLayers.forEach((spec) => {
const filterSignature = `${spec.layerType}|${spec.filterSql}`;
// 공통 getLayerById는 ID를 Number로 비교하므로 문자열 ID인 필터 레이어를 찾지 못한다.
// 지도 컬렉션에서 문자열 ID를 직접 비교해 기존 레이어와 WFS source를 재사용한다.
const existingLayer = gisMaps.current
?.getLayers?.()
?.getArray?.()
?.find(
(mapLayer: any) => String(mapLayer?.get?.("id") ?? "") === spec.id,
);
if (existingLayer?.get?.("floodFilterSignature") === filterSignature) {
existingLayer?.setVisible?.(true);
return;
}
// 연도나 적용 여부가 달라진 레이어만 교체하고, 동일 필터 레이어는 재사용한다.
core?.deleteLayer?.(spec.id);
const layerCode = {
...spec.baseLayerCode,
id: spec.id,
lyrSn: spec.id,
lyrNm: spec.layerName,
lyrTyNm: spec.layerType,
actlLyrYn: "Y",
exprsYn: "Y",
clickPsblYn: "Y",
cmncNm: "bbox",
srvcNm: "wfs",
flterSql: spec.filterSql,
};
const layer = core?.addWfsLayer?.(layerCode);
layer?.set?.("id", spec.id);
layer?.set?.("lyrNm", spec.layerName);
layer?.set?.("lyrTyNm", spec.layerType);
layer?.set?.("floodFilterSignature", filterSignature);
});
// 전용 필터 레이어가 담당하는 유형의 원본 레이어만 숨겨 중복 표출을 막는다.
const activeFilteredTypes = new Set(
desiredLayers.map((item) => item.layerType),
);
gisMaps.current
?.getLayers?.()
?.getArray?.()
?.forEach((mapLayer: any) => {
const layerId = String(mapLayer?.get?.("id") ?? "");
const layerType = String(mapLayer?.get?.("lyrTyNm") ?? "");
if (
activeFilteredTypes.has(layerType) &&
!isManagedFloodLayerId(layerId)
) {
mapLayer?.setVisible?.(false);
}
});
}, [
checkedFloodDepthLayerKeys,
clearFldngHstrySelection,
floodYearRange,
isFldngHstryChecked,
isFloodDepthChecked,
isFloodWtlvChecked,
isFloodYearFilterEnabled,
mapReady,
]);
// 페이지가 닫힐 때만 관리 대상 필터 레이어 전체를 정리한다.
useEffect(() => {
return () => {
core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
const map = gisMaps.current;
const layers = [...(map?.getLayers?.()?.getArray?.() ?? [])];
layers.forEach((layer: any) => {
const layerId = String(layer?.get?.("id") ?? "");
if (
layerId === FLDNG_HSTRY_WFS_LAYER_ID ||
layerId.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`)
) {
map?.removeLayer?.(layer);
}
});
};
}, [isFldngHstryChecked, mapReady]);
}, []);
const openAnalysisPopup = useCallback(
(popupKey: WindowPopupKey) => {
@@ -8920,15 +9199,11 @@ const Map = () => {
: "bg-[#e8f6f4] text-[#0f766e]"
}`}
>
{buildWaterRiskStatusLabel(
selectedWaterRiskDisplayStatus,
)}
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
</span>
</div>
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
<div>
GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면
</div>
<div>GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면</div>
<div>
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
<span className="font-bold text-[#1677ff]">정상</span>
+17 -3
View File
@@ -111,6 +111,22 @@ const loadSelectOptions = async (url: string) => {
.filter((item): item is SelectDataType => Boolean(item));
};
/** ONSEMIRO 환경청 응답을 코드/환경청명 기준으로 SelectBox 옵션으로 변환한다. */
const loadGovAreaOptions = async () => {
const response = await getRawJson(
"/sewerage-service/api/v1/onsemiro/addr/govArea",
);
return toResponseList(response)
.map((item) => {
if (!item || typeof item !== "object") return null;
const { lclgvrnmntCd, lclgvrnmntNm } = item as Record<string, unknown>;
const code = String(lclgvrnmntCd ?? "").trim();
const name = String(lclgvrnmntNm ?? "").trim();
return code && name ? { text: name, val: code } : null;
})
.filter((item): item is SelectDataType => Boolean(item));
};
/** ONSEMIRO 환경청·시도·시군구 선택목록을 공유하는 상태 저장소다. */
const useOnsemiroAddrStore = create<OnsemiroAddrStoreState>((set) => ({
govArea: [],
@@ -118,9 +134,7 @@ const useOnsemiroAddrStore = create<OnsemiroAddrStoreState>((set) => ({
sgg: [],
/** 환경청 선택목록을 조회해 한글명 순으로 저장한다. */
fetchGovArea: async () => {
const govArea = await loadSelectOptions(
"/sewerage-service/api/v1/onsemiro/addr/govArea",
);
const govArea = await loadGovAreaOptions();
set({
govArea: govArea.sort((left, right) =>
left.text.localeCompare(right.text, "ko"),