sn: 선택 레이어 설정 - 침수흔적도
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
@@ -6147,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);
|
||||
@@ -6343,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();
|
||||
}
|
||||
@@ -6365,6 +6472,7 @@ const Map = () => {
|
||||
setActiveLayerSettingOrder((currentOrder) => {
|
||||
const activeKeys = [
|
||||
waterDepthChecked ? "waterDepthLegend" : null,
|
||||
fldngHstryChecked ? "fldngHstry" : null,
|
||||
sinkholeChecked ? "sinkhole" : null,
|
||||
agedPipeChecked ? "agedPipe" : null,
|
||||
exmnPplChecked ? "exmnPpl" : null,
|
||||
@@ -6522,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",
|
||||
@@ -6603,9 +6728,13 @@ const Map = () => {
|
||||
exmnMnhlFilters,
|
||||
exmnPplFilters,
|
||||
handleApplyAgedPipeFilters,
|
||||
floodYearRange,
|
||||
isAgedPipeChecked,
|
||||
isExmnMnhlChecked,
|
||||
isExmnPplChecked,
|
||||
isFldngHstryChecked,
|
||||
isFloodYearFilterEnabled,
|
||||
isFloodYearSettingVisible,
|
||||
isSinkholeChecked,
|
||||
isWaterDepthLayerActive,
|
||||
lastSinkholeSyncSummary,
|
||||
@@ -6732,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) => {
|
||||
@@ -8936,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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user