지반침하 레이어 설정 UI 및 포인트 표시 개선
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AgedPipeFilters } from "./agedPipeUtils";
|
||||
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
type Props = {
|
||||
filters: AgedPipeFilters;
|
||||
onFiltersChange: (filters: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => MaybePromise;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
@@ -23,27 +25,37 @@ export default function AgedPipeLayerSettingPanel({
|
||||
setElapsedYearsText(String(filters.elapsedYears ?? ""));
|
||||
}, [filters.elapsedYears]);
|
||||
|
||||
const commitElapsedYears = () => {
|
||||
const commitElapsedYears = async () => {
|
||||
const elapsedYears = elapsedYearsText === "" ? NaN : Number(elapsedYearsText);
|
||||
if (!validateElapsedYears(elapsedYears)) {
|
||||
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFilters = {
|
||||
...filters,
|
||||
elapsedYears: elapsedYearsText === "" ? ("" as unknown as number) : Number(elapsedYearsText),
|
||||
elapsedYears,
|
||||
};
|
||||
onFiltersChange(nextFilters);
|
||||
onApply(nextFilters);
|
||||
await onApply(nextFilters);
|
||||
alert(`노후관로 경과년도 ${elapsedYears}년으로 반영되었습니다.`);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-3 py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex min-h-[32px] items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[1.15rem] font-medium text-slate-700">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="whitespace-nowrap">노후관로 경과년도(년)</label>
|
||||
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
|
||||
const numberInputClass = inputClass + " w-[40px]";
|
||||
const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white";
|
||||
const indicator = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
|
||||
|
||||
const settingRow = (
|
||||
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-slate-700">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">노후관로 경과년도(년)</label>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
className={numberInputClass}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -55,19 +67,26 @@ export default function AgedPipeLayerSettingPanel({
|
||||
setElapsedYearsText(digits);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitElapsedYears();
|
||||
if (event.key === "Enter") void commitElapsedYears();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[20px] w-[20px] place-items-center rounded-full bg-[#31a83b] text-[1.15rem] font-bold leading-none text-white"
|
||||
onClick={commitElapsedYears}
|
||||
className={applyButtonClass}
|
||||
onClick={() => void commitElapsedYears()}
|
||||
title="적용"
|
||||
aria-label="경과년도 적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-[6px] py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
{settingRow}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,41 +100,14 @@ export default function AgedPipeLayerSettingPanel({
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
title={isCollapsed ? "펼치기" : "접기"}
|
||||
>
|
||||
{isCollapsed ? "›" : "⌄"}
|
||||
{isCollapsed ? "+" : "-"}
|
||||
</button>
|
||||
<span className="text-[1.2rem] font-semibold text-red-700">노후관로 설정</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
|
||||
<label htmlFor="aged-pipe-elapsed-years">노후관로 경과년도(년)</label>
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={elapsedYearsText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setElapsedYearsText(digits);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitElapsedYears();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
|
||||
onClick={commitElapsedYears}
|
||||
title="적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
{settingRow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SinkholeSyncSummary } from "./sinkholeTypes";
|
||||
import {
|
||||
normalizeSinkholeYearRange,
|
||||
validateSinkholeYearRange,
|
||||
type SinkholeSyncSummary,
|
||||
type SinkholeYearRange,
|
||||
} from "./sinkholeTypes";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
type Props = {
|
||||
radiusMeters: number;
|
||||
onApplyRadius: (radiusMeters: number) => void;
|
||||
onApplyRadius: (radiusMeters: number) => MaybePromise;
|
||||
yearRange: SinkholeYearRange;
|
||||
onApplyYearRange: (yearRange: SinkholeYearRange) => MaybePromise;
|
||||
lastSyncSummary?: SinkholeSyncSummary | null;
|
||||
isAdmin?: boolean;
|
||||
compact?: boolean;
|
||||
@@ -14,38 +23,80 @@ type Props = {
|
||||
export default function SinkholeLayerSettingPanel({
|
||||
radiusMeters,
|
||||
onApplyRadius,
|
||||
yearRange,
|
||||
onApplyYearRange,
|
||||
lastSyncSummary,
|
||||
isAdmin = false,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [radiusText, setRadiusText] = useState(String(radiusMeters));
|
||||
const [yearRangeDraft, setYearRangeDraft] = useState<SinkholeYearRange>(() =>
|
||||
normalizeSinkholeYearRange(yearRange),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRadiusText(String(radiusMeters));
|
||||
}, [radiusMeters]);
|
||||
|
||||
const commitRadius = () => {
|
||||
useEffect(() => {
|
||||
setYearRangeDraft(normalizeSinkholeYearRange(yearRange));
|
||||
}, [yearRange]);
|
||||
|
||||
const commitRadius = async () => {
|
||||
const nextValue = Number(radiusText.replace(/[^0-9]/g, ""));
|
||||
if (!Number.isFinite(nextValue) || nextValue <= 0) {
|
||||
alert("반경은 1m 이상 입력해야 합니다.");
|
||||
return;
|
||||
}
|
||||
onApplyRadius(nextValue);
|
||||
|
||||
try {
|
||||
await onApplyRadius(nextValue);
|
||||
alert(`지반침하반경은 ${nextValue}m로 반영되었습니다.`);
|
||||
} catch {
|
||||
// 적용 함수 내부에서 실패 사유를 안내한다.
|
||||
}
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-3 py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex min-h-[32px] items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[1.15rem] font-medium text-slate-700">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />
|
||||
<label htmlFor="sinkhole-radius" className="whitespace-nowrap">지반침하반경(m)</label>
|
||||
const commitYearRange = async () => {
|
||||
const normalized = normalizeSinkholeYearRange(yearRangeDraft, yearRange);
|
||||
const validationMessage = validateSinkholeYearRange(normalized);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onApplyYearRange(normalized);
|
||||
alert(`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`);
|
||||
} catch {
|
||||
alert("반영 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const updateYearRangeDraft = (key: keyof SinkholeYearRange, value: string) => {
|
||||
setYearRangeDraft((current) => ({
|
||||
...current,
|
||||
[key]: value.replace(/[^0-9]/g, "").slice(0, 4),
|
||||
}));
|
||||
};
|
||||
|
||||
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
|
||||
const numberInputClass = inputClass + " w-[40px]";
|
||||
const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white";
|
||||
const indicator = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
|
||||
|
||||
const settingRows = (
|
||||
<div className="space-y-1 text-[1.05rem] font-medium text-slate-700">
|
||||
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="sinkhole-radius" className="shrink-0">지반침하반경(m)</label>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
id="sinkhole-radius"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
className={numberInputClass}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -54,19 +105,76 @@ export default function SinkholeLayerSettingPanel({
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitRadius();
|
||||
if (event.key === "Enter") void commitRadius();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[20px] w-[20px] place-items-center rounded-full bg-[#31a83b] text-[1.15rem] font-bold leading-none text-white"
|
||||
onClick={commitRadius}
|
||||
className={applyButtonClass}
|
||||
onClick={() => void commitRadius()}
|
||||
title="적용"
|
||||
aria-label="반경 적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="sinkhole-start-year" className="shrink-0">시작년도</label>
|
||||
<input
|
||||
id="sinkhole-start-year"
|
||||
className={numberInputClass}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
autoComplete="off"
|
||||
value={yearRangeDraft.startYear}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => updateYearRangeDraft("startYear", event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void commitYearRange();
|
||||
}}
|
||||
/>
|
||||
<span className="px-[1px] text-[0.95rem] font-normal text-slate-500">~</span>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<label htmlFor="sinkhole-end-year" className="shrink-0">종료년도</label>
|
||||
<input
|
||||
id="sinkhole-end-year"
|
||||
className={numberInputClass}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
autoComplete="off"
|
||||
value={yearRangeDraft.endYear}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => updateYearRangeDraft("endYear", event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void commitYearRange();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={applyButtonClass}
|
||||
onClick={() => void commitYearRange()}
|
||||
title="적용"
|
||||
aria-label="연도범위 적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-[6px] py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
<div className="overflow-visible">{settingRows}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,38 +188,14 @@ export default function SinkholeLayerSettingPanel({
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
title={isCollapsed ? "펼치기" : "접기"}
|
||||
>
|
||||
{isCollapsed ? "›" : "⌄"}
|
||||
{isCollapsed ? "+" : "-"}
|
||||
</button>
|
||||
<span className="text-[1.2rem] font-semibold text-[#d97706]">지반침하정보</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
|
||||
<label htmlFor="sinkhole-radius">지반침하반경(m)</label>
|
||||
<input
|
||||
id="sinkhole-radius"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={radiusText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitRadius();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
|
||||
onClick={commitRadius}
|
||||
title="적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-visible">{settingRows}</div>
|
||||
|
||||
{isAdmin && lastSyncSummary ? (
|
||||
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-white px-2 py-2 text-[1rem] text-[#7c5b2f]">
|
||||
|
||||
@@ -6,8 +6,6 @@ import type { IUserInfo } from "@/shared/store";
|
||||
export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE";
|
||||
export const SINKHOLE_LAYER_TYPE = "tb_sn_sinkhole_info";
|
||||
export const SINKHOLE_LAYER_NAME = "지반침하정보";
|
||||
export const SINKHOLE_ALL_YEAR_NODE_ID = "SN_SINKHOLE_YEAR_ALL";
|
||||
export const SINKHOLE_YEAR_NODE_PREFIX = "SN_SINKHOLE_YEAR_";
|
||||
export const SINKHOLE_POPUP_KEY = "sinkholePopup";
|
||||
export const SINKHOLE_ROW_SELECT_MESSAGE = "SN_SINKHOLE_ROW_SELECT";
|
||||
export const SINKHOLE_FILTER_SYNC_MESSAGE = "SN_SINKHOLE_FILTER_SYNC";
|
||||
@@ -16,8 +14,7 @@ export const SINKHOLE_DEFAULT_RADIUS = 100;
|
||||
export const SINKHOLE_LAYER_ID = "SN_SINKHOLE_LAYER";
|
||||
export const SINKHOLE_SELECTED_LAYER_ID = "SN_SINKHOLE_SELECTED_LAYER";
|
||||
export const SINKHOLE_MIN_SEARCH_YMD = "20160101";
|
||||
export const SINKHOLE_YEAR_ICON_ORANGE = "SN_SINKHOLE_SPOT_ORANGE.png";
|
||||
export const SINKHOLE_YEAR_ICON_BLUE = "SN_SINKHOLE_SPOT_BLUE.png";
|
||||
export const SINKHOLE_MIN_SEARCH_YEAR = Number(SINKHOLE_MIN_SEARCH_YMD.slice(0, 4));
|
||||
|
||||
export type SinkholeFilters = {
|
||||
startYmd: string;
|
||||
@@ -88,9 +85,68 @@ export type SinkholeScope = {
|
||||
sggLocked: boolean;
|
||||
};
|
||||
|
||||
export type SinkholeYearRange = {
|
||||
startYear: string;
|
||||
endYear: string;
|
||||
};
|
||||
|
||||
const toYmd = (date: Date) =>
|
||||
`${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
|
||||
|
||||
export const buildSinkholeDefaultYearRange = (baseDate = new Date()): SinkholeYearRange => {
|
||||
const currentYear = baseDate.getFullYear();
|
||||
return {
|
||||
startYear: String(currentYear - 1),
|
||||
endYear: String(currentYear),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeSinkholeYearRange = (
|
||||
range?: Partial<SinkholeYearRange> | null,
|
||||
fallback: SinkholeYearRange = buildSinkholeDefaultYearRange(),
|
||||
): SinkholeYearRange => ({
|
||||
startYear: String(range?.startYear ?? fallback.startYear).replace(/[^0-9]/g, "").slice(0, 4),
|
||||
endYear: String(range?.endYear ?? fallback.endYear).replace(/[^0-9]/g, "").slice(0, 4),
|
||||
});
|
||||
|
||||
export const validateSinkholeYearRange = (
|
||||
range: SinkholeYearRange,
|
||||
currentYear = new Date().getFullYear(),
|
||||
) => {
|
||||
const startYear = Number(range.startYear);
|
||||
const endYear = Number(range.endYear);
|
||||
|
||||
if (!/^\d{4}$/.test(range.startYear) || !/^\d{4}$/.test(range.endYear)) {
|
||||
return "시작년도와 종료년도는 4자리 숫자로 입력해야 합니다.";
|
||||
}
|
||||
if (startYear < SINKHOLE_MIN_SEARCH_YEAR || endYear < SINKHOLE_MIN_SEARCH_YEAR) {
|
||||
return `${SINKHOLE_MIN_SEARCH_YEAR}년 이후 범위로 입력해야 합니다.`;
|
||||
}
|
||||
if (startYear > endYear) {
|
||||
return "시작년도는 종료년도보다 클 수 없습니다.";
|
||||
}
|
||||
if (endYear > currentYear) {
|
||||
return `종료년도는 현재년도(${currentYear})를 초과할 수 없습니다.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildSinkholeFiltersFromYearRange = (
|
||||
range: SinkholeYearRange,
|
||||
scope?: SinkholeScope,
|
||||
): SinkholeFilters => {
|
||||
const normalized = normalizeSinkholeYearRange(range);
|
||||
return {
|
||||
startYmd: `${normalized.startYear}0101`,
|
||||
endYmd: `${normalized.endYear}1231`,
|
||||
ctpvCd: scope?.ctpvCd || "0",
|
||||
sggCd: scope?.sggCd || "0",
|
||||
restoreStatus: "0",
|
||||
coordStatus: "0",
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilters => {
|
||||
const today = new Date();
|
||||
const oneYearAgo = new Date(today);
|
||||
@@ -106,15 +162,6 @@ export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilt
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSinkholeTreeBaseFilters = (scope?: SinkholeScope): SinkholeFilters => ({
|
||||
startYmd: "",
|
||||
endYmd: "",
|
||||
ctpvCd: scope?.ctpvCd || "0",
|
||||
sggCd: scope?.sggCd || "0",
|
||||
restoreStatus: "0",
|
||||
coordStatus: "0",
|
||||
});
|
||||
|
||||
export const buildSinkholeScope = (userInfo: IUserInfo | null): SinkholeScope => {
|
||||
const admin = userInfo?.adminRoleYn === "Y";
|
||||
if (admin) {
|
||||
@@ -239,8 +286,6 @@ export const isSinkholeLayerNode = (node: any) => {
|
||||
|
||||
return (
|
||||
nodeId === SINKHOLE_CONTROL_NODE_ID ||
|
||||
nodeId === SINKHOLE_ALL_YEAR_NODE_ID ||
|
||||
nodeId.startsWith(SINKHOLE_YEAR_NODE_PREFIX) ||
|
||||
layerName === SINKHOLE_LAYER_NAME ||
|
||||
layerTypeName === SINKHOLE_LAYER_TYPE
|
||||
);
|
||||
@@ -253,192 +298,6 @@ export const isSinkholeRootNode = (node: any) => {
|
||||
return nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME;
|
||||
};
|
||||
|
||||
export const isSinkholeYearNode = (node: any) => {
|
||||
const data = node?.data ?? node;
|
||||
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
|
||||
const nodeText = String(node?.text ?? data?.lyrNm ?? data?.name ?? "").trim();
|
||||
return (
|
||||
nodeId === SINKHOLE_ALL_YEAR_NODE_ID ||
|
||||
nodeId.startsWith(SINKHOLE_YEAR_NODE_PREFIX) ||
|
||||
nodeText === "전체" ||
|
||||
/^\d{4}년$/.test(nodeText)
|
||||
);
|
||||
};
|
||||
|
||||
const resolveSinkholeYearToken = (node: any) => {
|
||||
const data = node?.data ?? node;
|
||||
const explicitYear = String(data?.sinkholeYear ?? "").trim();
|
||||
if (explicitYear) {
|
||||
return explicitYear === "ALL" || /^\d{4}$/.test(explicitYear) ? explicitYear : "";
|
||||
}
|
||||
|
||||
const nodeText = String(node?.text ?? data?.lyrNm ?? data?.name ?? "").trim();
|
||||
if (nodeText === "전체") {
|
||||
return "ALL";
|
||||
}
|
||||
|
||||
const yearMatch = nodeText.match(/^(\d{4})년$/);
|
||||
return yearMatch?.[1] ?? "";
|
||||
};
|
||||
|
||||
export const resolveSinkholeLegendIconName = (node: any) => {
|
||||
const sinkholeYear = resolveSinkholeYearToken(node);
|
||||
if (sinkholeYear === "ALL") {
|
||||
return SINKHOLE_YEAR_ICON_BLUE;
|
||||
}
|
||||
if (/^\d{4}$/.test(sinkholeYear)) {
|
||||
return SINKHOLE_YEAR_ICON_ORANGE;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildSinkholeYearNodes = (currentYear = new Date().getFullYear()) => {
|
||||
return [
|
||||
{
|
||||
lyrSn: `${SINKHOLE_YEAR_NODE_PREFIX}${currentYear}`,
|
||||
id: `${SINKHOLE_YEAR_NODE_PREFIX}${currentYear}`,
|
||||
lyrNm: `${currentYear}년`,
|
||||
upperLyrSn: SINKHOLE_CONTROL_NODE_ID,
|
||||
upId: SINKHOLE_CONTROL_NODE_ID,
|
||||
sortOrdr: "1",
|
||||
drawOrdr: null,
|
||||
flterSql: null,
|
||||
gobjTy: "",
|
||||
lyrTyNm: SINKHOLE_LAYER_TYPE,
|
||||
sinkholeYear: String(currentYear),
|
||||
iconNm: SINKHOLE_YEAR_ICON_ORANGE,
|
||||
useYn: "Y",
|
||||
minViewSize: null,
|
||||
iconSize: null,
|
||||
lblAtrbNm: null,
|
||||
fontNm: null,
|
||||
fontIntnColor: null,
|
||||
fontExtnColor: null,
|
||||
fontExtnWdth: null,
|
||||
lnWdth: null,
|
||||
lnColor: null,
|
||||
polgnColor: null,
|
||||
initEprssYn: "Y",
|
||||
clickPsblYn: "N",
|
||||
exprsYn: "Y",
|
||||
cmncNm: "all",
|
||||
srvcNm: "api",
|
||||
actlLyrYn: "N",
|
||||
clckZoom: null,
|
||||
children: null,
|
||||
},
|
||||
{
|
||||
lyrSn: SINKHOLE_ALL_YEAR_NODE_ID,
|
||||
id: SINKHOLE_ALL_YEAR_NODE_ID,
|
||||
lyrNm: "전체",
|
||||
upperLyrSn: SINKHOLE_CONTROL_NODE_ID,
|
||||
upId: SINKHOLE_CONTROL_NODE_ID,
|
||||
sortOrdr: "99",
|
||||
drawOrdr: null,
|
||||
flterSql: null,
|
||||
gobjTy: "",
|
||||
lyrTyNm: SINKHOLE_LAYER_TYPE,
|
||||
sinkholeYear: "ALL",
|
||||
iconNm: SINKHOLE_YEAR_ICON_BLUE,
|
||||
useYn: "Y",
|
||||
minViewSize: null,
|
||||
iconSize: null,
|
||||
lblAtrbNm: null,
|
||||
fontNm: null,
|
||||
fontIntnColor: null,
|
||||
fontExtnColor: null,
|
||||
fontExtnWdth: null,
|
||||
lnWdth: null,
|
||||
lnColor: null,
|
||||
polgnColor: null,
|
||||
initEprssYn: "Y",
|
||||
clickPsblYn: "N",
|
||||
exprsYn: "Y",
|
||||
cmncNm: "all",
|
||||
srvcNm: "api",
|
||||
actlLyrYn: "N",
|
||||
clckZoom: null,
|
||||
children: null,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const applySinkholeNodeFilters = (filters: SinkholeFilters, node: any) => {
|
||||
const data = node?.data ?? node;
|
||||
const sinkholeYear = resolveSinkholeYearToken(node);
|
||||
const filterSql = String(data?.flterSql ?? data?.filterSql ?? "").trim();
|
||||
|
||||
if (sinkholeYear === "ALL") {
|
||||
return {
|
||||
...filters,
|
||||
startYmd: "",
|
||||
endYmd: "",
|
||||
};
|
||||
}
|
||||
|
||||
const rangeMatch = filterSql.match(
|
||||
/ocrn_ymd\s*>=\s*'(\d{8})'.*ocrn_ymd\s*<=\s*'(\d{8})'/i,
|
||||
);
|
||||
if (rangeMatch) {
|
||||
return {
|
||||
...filters,
|
||||
startYmd: rangeMatch[1] ?? "",
|
||||
endYmd: rangeMatch[2] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
if (/^\d{4}$/.test(sinkholeYear)) {
|
||||
return {
|
||||
...filters,
|
||||
startYmd: `${sinkholeYear}0101`,
|
||||
endYmd: `${sinkholeYear}1231`,
|
||||
};
|
||||
}
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
export const applySinkholeYearFilters = (filters: SinkholeFilters, checkedNodesData: any[]) => {
|
||||
const selectedYears = checkedNodesData
|
||||
.map((node) => resolveSinkholeYearToken(node))
|
||||
.filter(Boolean);
|
||||
|
||||
if (selectedYears.includes("ALL")) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
if (selectedYears.length === 0) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
const numericYears = selectedYears
|
||||
.map((year) => Number(year))
|
||||
.filter((year) => Number.isFinite(year))
|
||||
.sort((left, right) => left - right);
|
||||
|
||||
if (numericYears.length === 0) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
const yearStart = `${numericYears[0]}0101`;
|
||||
const yearEnd = `${numericYears[numericYears.length - 1]}1231`;
|
||||
const nextStart = filters.startYmd ? (filters.startYmd > yearStart ? filters.startYmd : yearStart) : yearStart;
|
||||
const nextEnd = filters.endYmd ? (filters.endYmd < yearEnd ? filters.endYmd : yearEnd) : yearEnd;
|
||||
|
||||
if (nextStart > nextEnd) {
|
||||
return {
|
||||
...filters,
|
||||
startYmd: "99991231",
|
||||
endYmd: "99991231",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...filters,
|
||||
startYmd: nextStart,
|
||||
endYmd: nextEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export const SINKHOLE_RESTORE_STATUS_OPTIONS = [
|
||||
{ value: "0", label: "전체" },
|
||||
|
||||
@@ -82,18 +82,14 @@ import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
|
||||
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
|
||||
import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel";
|
||||
import {
|
||||
applySinkholeNodeFilters,
|
||||
applySinkholeYearFilters,
|
||||
buildSinkholeYearNodes,
|
||||
buildSinkholeDefaultFilters,
|
||||
buildSinkholeTreeBaseFilters,
|
||||
resolveSinkholeLegendIconName,
|
||||
buildSinkholeDefaultYearRange,
|
||||
buildSinkholeFiltersFromYearRange,
|
||||
buildSinkholeListQueryString,
|
||||
buildSinkholeScope,
|
||||
isSinkholeLayerNode,
|
||||
isSinkholeRootNode,
|
||||
isSinkholeYearNode,
|
||||
normalizeSinkholeFilters,
|
||||
normalizeSinkholeYearRange,
|
||||
SINKHOLE_CONTROL_NODE_ID,
|
||||
SINKHOLE_DEFAULT_RADIUS,
|
||||
SINKHOLE_FILTER_SYNC_MESSAGE,
|
||||
@@ -109,6 +105,7 @@ import {
|
||||
type SinkholeListItem,
|
||||
type SinkholeNearbyPipe,
|
||||
type SinkholeSyncSummary,
|
||||
type SinkholeYearRange,
|
||||
} from "./components/sinkhole/sinkholeTypes";
|
||||
import {
|
||||
FLOOD_HISTORY_AREA_LAYER_ID,
|
||||
@@ -305,42 +302,7 @@ const SINKHOLE_POINT_PALETTE = {
|
||||
|
||||
type SinkholePointVariant = keyof typeof SINKHOLE_POINT_PALETTE;
|
||||
|
||||
const getSinkholeYearValue = (ocrnYmd?: string | null) => String(ocrnYmd ?? "").trim().slice(0, 4);
|
||||
|
||||
const resolveSinkholeActiveYearTokens = (checkedNodesData: any[]) =>
|
||||
checkedNodesData
|
||||
.map((node) => node?.data ?? node)
|
||||
.map((data) => {
|
||||
const explicit = String(data?.sinkholeYear ?? "").trim();
|
||||
if (/^\d{4}$/.test(explicit)) return explicit;
|
||||
const nodeText = String(data?.lyrNm ?? data?.name ?? "").trim();
|
||||
const match = nodeText.match(/^(\d{4})년$/);
|
||||
return match?.[1] ?? "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const resolveSinkholePointVariant = (
|
||||
ocrnYmd?: string | null,
|
||||
checkedNodesData: any[] = [],
|
||||
): SinkholePointVariant => {
|
||||
const year = getSinkholeYearValue(ocrnYmd);
|
||||
const selectedTokens = checkedNodesData
|
||||
.map((node) => node?.data ?? node)
|
||||
.map((data) => String(data?.sinkholeYear ?? data?.lyrNm ?? data?.name ?? "").trim());
|
||||
|
||||
if (selectedTokens.includes("ALL") || selectedTokens.includes("전체")) {
|
||||
const activeYears = resolveSinkholeActiveYearTokens(checkedNodesData);
|
||||
const highlightYear = activeYears[0] ?? String(new Date().getFullYear());
|
||||
return year === highlightYear ? "orange" : "blue";
|
||||
}
|
||||
|
||||
const activeYears = resolveSinkholeActiveYearTokens(checkedNodesData);
|
||||
if (activeYears.length > 0) {
|
||||
return activeYears.includes(year) ? "orange" : "blue";
|
||||
}
|
||||
|
||||
return year === String(new Date().getFullYear()) ? "orange" : "blue";
|
||||
};
|
||||
const resolveSinkholePointVariant = (_ocrnYmd?: string | null): SinkholePointVariant => "blue";
|
||||
|
||||
// 현재 지도 화면 BBOX 주변을 함께 조회해 이동 직후 가장자리 유하방향이 비어 보이는 현상을 줄인다.
|
||||
const expandFlowDirectionExtent = (extent: number[]) => {
|
||||
@@ -855,27 +817,51 @@ const findInvalidPipeRequiredChange = (changes: Array<{ changeId: string; editTy
|
||||
return !mngInstCd || !fcltMngNo || /^NEW-/i.test(fcltMngNo) || !instMngNo || /^NEW-/i.test(instMngNo) || !usgCd;
|
||||
});
|
||||
|
||||
const getSinkholeSelectionValue = (data: any, key: string) =>
|
||||
data?.[key] ?? data?.values_?.[key] ?? data?.get?.(key) ?? data?.properties?.[key];
|
||||
|
||||
const getSinkholeAccidentId = (data: any) =>
|
||||
String(
|
||||
data?.acdntNo ??
|
||||
data?.values_?.acdntNo ??
|
||||
data?.get?.("acdntNo") ??
|
||||
data?.properties?.acdntNo ??
|
||||
"",
|
||||
).trim();
|
||||
String(getSinkholeSelectionValue(data, "acdntNo") ?? "").trim();
|
||||
|
||||
const getSinkholeFallbackDetailFromSelection = (data: any): Partial<SinkholeDetail> | null => {
|
||||
const acdntNo = getSinkholeAccidentId(data);
|
||||
if (!acdntNo) return null;
|
||||
|
||||
const point = getSinkholePointFromSelection(data);
|
||||
return {
|
||||
acdntNo,
|
||||
ctpvNm: String(getSinkholeSelectionValue(data, "ctpvNm") ?? ""),
|
||||
sggNm: String(getSinkholeSelectionValue(data, "sggNm") ?? ""),
|
||||
emdNm: String(getSinkholeSelectionValue(data, "emdNm") ?? ""),
|
||||
daddr: String(getSinkholeSelectionValue(data, "daddr") ?? ""),
|
||||
fullAddress: String(getSinkholeSelectionValue(data, "fullAddress") ?? getSinkholeSelectionValue(data, "daddr") ?? ""),
|
||||
ocrnYmd: String(getSinkholeSelectionValue(data, "ocrnYmd") ?? ""),
|
||||
restoreStatus: String(getSinkholeSelectionValue(data, "restoreStatus") ?? ""),
|
||||
restoreCompletedYmd: String(getSinkholeSelectionValue(data, "restoreCompletedYmd") ?? ""),
|
||||
coordStatus: String(getSinkholeSelectionValue(data, "coordStatus") ?? getSinkholeSelectionValue(data, "geocodeStatus") ?? ""),
|
||||
geocodeStatus: String(getSinkholeSelectionValue(data, "geocodeStatus") ?? getSinkholeSelectionValue(data, "coordStatus") ?? ""),
|
||||
geocodeSource: String(getSinkholeSelectionValue(data, "geocodeSource") ?? ""),
|
||||
geocodeErrorMessage: String(getSinkholeSelectionValue(data, "geocodeErrorMessage") ?? ""),
|
||||
latitude: getSinkholeSelectionValue(data, "latitude") ?? null,
|
||||
longitude: getSinkholeSelectionValue(data, "longitude") ?? null,
|
||||
x: point?.x ?? getSinkholeSelectionValue(data, "x") ?? null,
|
||||
y: point?.y ?? getSinkholeSelectionValue(data, "y") ?? null,
|
||||
sizeWidth: getSinkholeSelectionValue(data, "sizeWidth") ?? null,
|
||||
sizeLength: getSinkholeSelectionValue(data, "sizeLength") ?? null,
|
||||
sizeDepth: getSinkholeSelectionValue(data, "sizeDepth") ?? null,
|
||||
detailCause: getSinkholeSelectionValue(data, "detailCause") ?? null,
|
||||
deathCount: getSinkholeSelectionValue(data, "deathCount") ?? null,
|
||||
injuryCount: getSinkholeSelectionValue(data, "injuryCount") ?? null,
|
||||
vehicleDamageCount: getSinkholeSelectionValue(data, "vehicleDamageCount") ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const getSinkholePointFromSelection = (data: any): { x: number; y: number } | null => {
|
||||
const directX = Number(
|
||||
data?.x ??
|
||||
data?.values_?.x ??
|
||||
data?.get?.("x") ??
|
||||
data?.properties?.x,
|
||||
getSinkholeSelectionValue(data, "x"),
|
||||
);
|
||||
const directY = Number(
|
||||
data?.y ??
|
||||
data?.values_?.y ??
|
||||
data?.get?.("y") ??
|
||||
data?.properties?.y,
|
||||
getSinkholeSelectionValue(data, "y"),
|
||||
);
|
||||
if (Number.isFinite(directX) && Number.isFinite(directY)) {
|
||||
return { x: directX, y: directY };
|
||||
@@ -895,7 +881,7 @@ const getSinkholePointFromSelection = (data: any): { x: number; y: number } | nu
|
||||
};
|
||||
|
||||
const createSinkholePointStyle = (
|
||||
variant: SinkholePointVariant = "orange",
|
||||
variant: SinkholePointVariant = "blue",
|
||||
selected = false,
|
||||
) =>
|
||||
new Style({
|
||||
@@ -916,7 +902,7 @@ const createSinkholePointStyle = (
|
||||
zIndex: selected ? 9997 : 9994,
|
||||
});
|
||||
|
||||
const createSinkholeRadiusStyle = (variant: SinkholePointVariant = "orange") =>
|
||||
const createSinkholeRadiusStyle = (variant: SinkholePointVariant = "blue") =>
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: SINKHOLE_POINT_PALETTE[variant].radiusStroke,
|
||||
@@ -2173,44 +2159,14 @@ const appendSinkholeControlNode = (data: any[]) => {
|
||||
});
|
||||
|
||||
const existingRootId = existingRoot?.lyrSn ?? existingRoot?.id ?? SINKHOLE_CONTROL_NODE_ID;
|
||||
const existingChildren = data.filter((item) => {
|
||||
const upperId = item?.upperLyrSn ?? item?.upId ?? null;
|
||||
return upperId === existingRootId;
|
||||
});
|
||||
|
||||
if (existingRoot && existingChildren.length > 0) {
|
||||
return data.map((item) => {
|
||||
const nodeId = String(item?.lyrSn ?? item?.id ?? "");
|
||||
if (nodeId === existingRootId) {
|
||||
return {
|
||||
...item,
|
||||
gobjTy: "",
|
||||
iconNm: null,
|
||||
clickPsblYn: "N",
|
||||
actlLyrYn: "N",
|
||||
};
|
||||
}
|
||||
const upperId = item?.upperLyrSn ?? item?.upId ?? null;
|
||||
if (upperId === existingRootId && isSinkholeYearNode(item)) {
|
||||
return {
|
||||
...item,
|
||||
gobjTy: "",
|
||||
iconNm: resolveSinkholeLegendIconName(item) ?? item?.iconNm ?? null,
|
||||
initEprssYn: "Y",
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
const filtered = data.filter((item) => {
|
||||
const nodeId = String(item?.lyrSn ?? item?.id ?? "");
|
||||
const upperId = String(item?.upperLyrSn ?? item?.upId ?? "");
|
||||
const layerName = String(item?.lyrNm ?? item?.name ?? "").trim();
|
||||
const layerTypeName = String(item?.lyrTyNm ?? item?.lyrTypeNm ?? item?.lyr_type_nm ?? "").trim();
|
||||
|
||||
if (String(nodeId) === String(existingRootId)) return false;
|
||||
if (String(upperId) === String(existingRootId)) return false;
|
||||
if (nodeId === String(existingRootId)) return false;
|
||||
if (upperId === String(existingRootId)) return false;
|
||||
if (!existingRoot && nodeId === SINKHOLE_CONTROL_NODE_ID) return false;
|
||||
if (!existingRoot && upperId === SINKHOLE_CONTROL_NODE_ID) return false;
|
||||
if (!existingRoot && layerName === SINKHOLE_LAYER_NAME) return false;
|
||||
@@ -2227,6 +2183,7 @@ const appendSinkholeControlNode = (data: any[]) => {
|
||||
iconNm: null,
|
||||
clickPsblYn: "N",
|
||||
actlLyrYn: "N",
|
||||
children: null,
|
||||
}
|
||||
: {
|
||||
lyrSn: SINKHOLE_CONTROL_NODE_ID,
|
||||
@@ -2261,17 +2218,7 @@ const appendSinkholeControlNode = (data: any[]) => {
|
||||
children: null,
|
||||
};
|
||||
|
||||
const yearNodes = buildSinkholeYearNodes().map((item) => ({
|
||||
...item,
|
||||
upperLyrSn: existingRootId,
|
||||
upId: existingRootId,
|
||||
}));
|
||||
|
||||
return [
|
||||
...filtered,
|
||||
rootNode,
|
||||
...yearNodes,
|
||||
];
|
||||
return [...filtered, rootNode];
|
||||
};
|
||||
|
||||
const Map = () => {
|
||||
@@ -2293,12 +2240,16 @@ const Map = () => {
|
||||
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
|
||||
const [isSinkholeChecked, setIsSinkholeChecked] = useState(false);
|
||||
const [isFloodHistoryChecked, setIsFloodHistoryChecked] = useState(false);
|
||||
const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState<string[]>([]);
|
||||
const [draftAgedPipeFilters, setDraftAgedPipeFilters] =
|
||||
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
||||
const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] =
|
||||
useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
|
||||
const [sinkholeYearRange, setSinkholeYearRange] = useState<SinkholeYearRange>(() =>
|
||||
buildSinkholeDefaultYearRange(),
|
||||
);
|
||||
const [appliedSinkholeFilters, setAppliedSinkholeFilters] = useState<SinkholeFilters>(() =>
|
||||
buildSinkholeDefaultFilters(sinkholeScope),
|
||||
normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(buildSinkholeDefaultYearRange(), sinkholeScope), sinkholeScope),
|
||||
);
|
||||
const [isSinkholePopupFilterActive, setIsSinkholePopupFilterActive] = useState(false);
|
||||
const [sinkholeRadiusMeters, setSinkholeRadiusMeters] = useState(SINKHOLE_DEFAULT_RADIUS);
|
||||
@@ -2637,9 +2588,13 @@ const Map = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scopedFilters = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope);
|
||||
setAppliedSinkholeFilters(scopedFilters);
|
||||
}, [sinkholeScope]);
|
||||
setAppliedSinkholeFilters((currentFilters) => {
|
||||
if (isSinkholePopupFilterActive) {
|
||||
return normalizeSinkholeFilters(currentFilters, sinkholeScope);
|
||||
}
|
||||
return normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope), sinkholeScope);
|
||||
});
|
||||
}, [isSinkholePopupFilterActive, sinkholeScope, sinkholeYearRange]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -3445,7 +3400,7 @@ const Map = () => {
|
||||
restoreSinkholeDetailOnCloseRef.current = false;
|
||||
const acdntNo = getSinkholeAccidentId(data);
|
||||
if (acdntNo) {
|
||||
void loadSinkholeDetail(acdntNo, false, getSinkholePointFromSelection(data));
|
||||
void loadSinkholeDetail(acdntNo, false, getSinkholePointFromSelection(data), getSinkholeFallbackDetailFromSelection(data));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3887,7 +3842,7 @@ const Map = () => {
|
||||
const pointFeature = new Feature({
|
||||
geometry: new Point([detail.x, detail.y]),
|
||||
});
|
||||
const pointVariant = resolveSinkholePointVariant(detail.ocrnYmd, checkedNodesDataRef.current);
|
||||
const pointVariant = resolveSinkholePointVariant(detail.ocrnYmd);
|
||||
pointFeature.setStyle(createSinkholePointStyle(pointVariant, true));
|
||||
|
||||
const radiusFeature = new Feature({
|
||||
@@ -4029,8 +3984,8 @@ const Map = () => {
|
||||
}, []);
|
||||
|
||||
const buildSinkholeQueryString = useCallback(
|
||||
(filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT, checkedNodesData = checkedNodesDataRef.current) =>
|
||||
buildSinkholeListQueryString(applySinkholeYearFilters(filters, checkedNodesData), ctpv, sgg, page, size),
|
||||
(filters: SinkholeFilters, page = 0, size = SINKHOLE_DEFAULT_POINT_LIMIT) =>
|
||||
buildSinkholeListQueryString(filters, ctpv, sgg, page, size),
|
||||
[ctpv, sgg],
|
||||
);
|
||||
|
||||
@@ -4041,11 +3996,7 @@ const Map = () => {
|
||||
const source = ensureSinkholeLayer();
|
||||
if (!source) return;
|
||||
|
||||
const popupFilters = normalizeSinkholeFilters(filters, sinkholeScope);
|
||||
const treeBaseFilters = normalizeSinkholeFilters(buildSinkholeTreeBaseFilters(sinkholeScope), sinkholeScope);
|
||||
const effectiveFilters = isSinkholePopupFilterActive
|
||||
? applySinkholeYearFilters(popupFilters, checkedNodesDataRef.current)
|
||||
: applySinkholeYearFilters(treeBaseFilters, checkedNodesDataRef.current);
|
||||
const effectiveFilters = normalizeSinkholeFilters(filters, sinkholeScope);
|
||||
try {
|
||||
const params = new URLSearchParams(buildSinkholeQueryString(effectiveFilters, 0, SINKHOLE_DEFAULT_POINT_LIMIT));
|
||||
const rawResponse = await getRawJson(
|
||||
@@ -4072,7 +4023,6 @@ const Map = () => {
|
||||
features.forEach((feature: any) => {
|
||||
const variant = resolveSinkholePointVariant(
|
||||
String(feature?.get?.("ocrnYmd") ?? ""),
|
||||
checkedNodesDataRef.current,
|
||||
);
|
||||
feature.setStyle(createSinkholePointStyle(variant, false));
|
||||
});
|
||||
@@ -4082,7 +4032,7 @@ const Map = () => {
|
||||
source.clear();
|
||||
}
|
||||
},
|
||||
[appliedSinkholeFilters, buildSinkholeQueryString, ensureSinkholeLayer, isSinkholeChecked, isSinkholePopupFilterActive, mapReady, sinkholeScope],
|
||||
[appliedSinkholeFilters, buildSinkholeQueryString, ensureSinkholeLayer, isSinkholeChecked, mapReady, sinkholeScope],
|
||||
);
|
||||
|
||||
const closeSinkholeDetail = useCallback(() => {
|
||||
@@ -4258,12 +4208,20 @@ const Map = () => {
|
||||
async (radiusMeters: number) => {
|
||||
setSinkholeRadiusMeters(radiusMeters);
|
||||
if (!selectedSinkholeDetail?.acdntNo) {
|
||||
alert("반경을 적용할 지반침하 위치가 선택되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPoint =
|
||||
selectedSinkholeDetail.x != null && selectedSinkholeDetail.y != null
|
||||
? { x: Number(selectedSinkholeDetail.x), y: Number(selectedSinkholeDetail.y) }
|
||||
: null;
|
||||
|
||||
try {
|
||||
const nearbyRawResponse = await getRawJson(
|
||||
const nearbyRawResponse = selectedPoint && Number.isFinite(selectedPoint.x) && Number.isFinite(selectedPoint.y)
|
||||
? await getRawJson(
|
||||
`/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${selectedPoint.x}&y=${selectedPoint.y}&radiusMeters=${radiusMeters}`,
|
||||
)
|
||||
: await getRawJson(
|
||||
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(selectedSinkholeDetail.acdntNo)}/nearbyPipes?radiusMeters=${radiusMeters}`,
|
||||
);
|
||||
const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse;
|
||||
@@ -4272,10 +4230,10 @@ const Map = () => {
|
||||
);
|
||||
renderSelectedSinkhole(selectedSinkholeDetail, radiusMeters);
|
||||
focusSinkholeOnMap(selectedSinkholeDetail, radiusMeters);
|
||||
alert("분석 반경이 적용되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("지반침하 반경 적용 중 오류:", error);
|
||||
alert("반경 내 하수관로 정보를 다시 조회하지 못했습니다.");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[focusSinkholeOnMap, renderSelectedSinkhole, selectedSinkholeDetail],
|
||||
@@ -4326,8 +4284,7 @@ const Map = () => {
|
||||
}, [ensureSinkholeFocusedPipeLayer, setIsSearchModalOpen]);
|
||||
|
||||
const openSinkholePopupWindow = useCallback(() => {
|
||||
const normalized = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope);
|
||||
const effectiveFilters = applySinkholeYearFilters(normalized, checkedNodesDataRef.current);
|
||||
const effectiveFilters = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope);
|
||||
const popup = openWindowPopup(SINKHOLE_POPUP_KEY as WindowPopupKey, effectiveFilters as Record<string, string>);
|
||||
if (!popup) {
|
||||
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
|
||||
@@ -4351,12 +4308,11 @@ const Map = () => {
|
||||
}
|
||||
sinkholePopupWindowRef.current = null;
|
||||
setIsSinkholePopupFilterActive(false);
|
||||
const defaultFilters = normalizeSinkholeFilters(buildSinkholeDefaultFilters(sinkholeScope), sinkholeScope);
|
||||
setAppliedSinkholeFilters(defaultFilters);
|
||||
setAppliedSinkholeFilters(normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope), sinkholeScope));
|
||||
setSinkholeSelectionVersion((version) => version + 1);
|
||||
}, 300);
|
||||
return popup;
|
||||
}, [appliedSinkholeFilters, sinkholeScope]);
|
||||
}, [appliedSinkholeFilters, sinkholeScope, sinkholeYearRange]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (sinkholePopupCloseWatcherRef.current) {
|
||||
@@ -4561,7 +4517,11 @@ const Map = () => {
|
||||
}
|
||||
|
||||
handleWaterDepthVisibility(checkedIds, checkedNodesData);
|
||||
setIsAgedPipeChecked(checkedNodesData.some((node) => isAgedPipeLayerNode(node)));
|
||||
const agedPipeChecked = checkedNodesData.some((node) => isAgedPipeLayerNode(node));
|
||||
const waterDepthChecked = checkedNodesData.some((node) =>
|
||||
WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")),
|
||||
);
|
||||
setIsAgedPipeChecked(agedPipeChecked);
|
||||
const floodHistoryChecked = checkedNodesData.some((node) => isFloodHistoryNode(node));
|
||||
setIsFloodHistoryChecked(floodHistoryChecked);
|
||||
if (!floodHistoryChecked) {
|
||||
@@ -4569,6 +4529,18 @@ const Map = () => {
|
||||
}
|
||||
const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node));
|
||||
setIsSinkholeChecked(sinkholeChecked);
|
||||
setActiveLayerSettingOrder((currentOrder) => {
|
||||
const activeKeys = [
|
||||
waterDepthChecked ? "waterDepthLegend" : null,
|
||||
sinkholeChecked ? "sinkhole" : null,
|
||||
agedPipeChecked ? "agedPipe" : null,
|
||||
].filter((key): key is string => Boolean(key));
|
||||
|
||||
return [
|
||||
...currentOrder.filter((key) => activeKeys.includes(key)),
|
||||
...activeKeys.filter((key) => !currentOrder.includes(key)),
|
||||
];
|
||||
});
|
||||
if (sinkholeChecked) {
|
||||
setSinkholeSelectionVersion((version) => version + 1);
|
||||
}
|
||||
@@ -4583,6 +4555,20 @@ const Map = () => {
|
||||
[checkedLayerLegendVersion],
|
||||
);
|
||||
|
||||
|
||||
const applySinkholeYearRange = useCallback(
|
||||
(nextRange: SinkholeYearRange) => {
|
||||
const normalizedRange = normalizeSinkholeYearRange(nextRange, sinkholeYearRange);
|
||||
setSinkholeYearRange(normalizedRange);
|
||||
setIsSinkholePopupFilterActive(false);
|
||||
setAppliedSinkholeFilters(
|
||||
normalizeSinkholeFilters(buildSinkholeFiltersFromYearRange(normalizedRange, sinkholeScope), sinkholeScope),
|
||||
);
|
||||
setSinkholeSelectionVersion((version) => version + 1);
|
||||
},
|
||||
[sinkholeScope, sinkholeYearRange],
|
||||
);
|
||||
|
||||
const handleOpenLayerPopup = useCallback(
|
||||
(_layerNm: string, node?: any) => {
|
||||
if (isAgedPipeLayerNode(node)) {
|
||||
@@ -4597,9 +4583,6 @@ const Map = () => {
|
||||
openFloodHistoryPopupWindow();
|
||||
return false;
|
||||
}
|
||||
if (isSinkholeYearNode(node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
@@ -4609,9 +4592,8 @@ const Map = () => {
|
||||
// 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다.
|
||||
const activeLayerSettingItems = useMemo<{ key: string; label: string; content: ReactNode }[]>(() => {
|
||||
const nextItems: { key: string; label: string; content: ReactNode; order: number }[] = [];
|
||||
const checkedSettingNodes = checkedNodesDataRef.current ?? [];
|
||||
const resolveSettingOrder = (predicate: (node: any) => boolean, fallback: number) => {
|
||||
const index = checkedSettingNodes.findIndex((node) => predicate(node));
|
||||
const resolveSettingOrder = (key: string, fallback: number) => {
|
||||
const index = activeLayerSettingOrder.indexOf(key);
|
||||
return index >= 0 ? index : fallback;
|
||||
};
|
||||
|
||||
@@ -4619,11 +4601,13 @@ const Map = () => {
|
||||
nextItems.push({
|
||||
key: "sinkhole",
|
||||
label: "지반침하정보",
|
||||
order: resolveSettingOrder((node) => isSinkholeLayerNode(node), 9998),
|
||||
order: resolveSettingOrder("sinkhole", 9998),
|
||||
content: (
|
||||
<SinkholeLayerSettingPanel
|
||||
radiusMeters={sinkholeRadiusMeters}
|
||||
onApplyRadius={applySinkholeRadius}
|
||||
yearRange={sinkholeYearRange}
|
||||
onApplyYearRange={applySinkholeYearRange}
|
||||
lastSyncSummary={lastSinkholeSyncSummary}
|
||||
isAdmin={userInfo?.adminRoleYn === "Y"}
|
||||
compact
|
||||
@@ -4636,7 +4620,7 @@ const Map = () => {
|
||||
nextItems.push({
|
||||
key: "agedPipe",
|
||||
label: "노후관로",
|
||||
order: resolveSettingOrder((node) => isAgedPipeLayerNode(node), 9999),
|
||||
order: resolveSettingOrder("agedPipe", 9999),
|
||||
content: (
|
||||
<AgedPipeLayerSettingPanel
|
||||
filters={draftAgedPipeFilters}
|
||||
@@ -4652,13 +4636,10 @@ const Map = () => {
|
||||
nextItems.push({
|
||||
key: "waterDepthLegend",
|
||||
label: "침수심 범례",
|
||||
order: resolveSettingOrder(
|
||||
(node) => WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")),
|
||||
9997,
|
||||
),
|
||||
order: resolveSettingOrder("waterDepthLegend", 9997),
|
||||
content: (
|
||||
<div className="overflow-hidden rounded-sm border border-sky-100 bg-sky-50/40">
|
||||
<div className="px-3 py-3">
|
||||
<div className="px-3 py-2">
|
||||
<div className="rounded-sm border border-slate-200 bg-white px-3 py-3">
|
||||
<div className="mb-2 bg-blue-900 py-1 text-center text-[1.1rem] font-semibold text-white">
|
||||
침수심 범례
|
||||
@@ -4688,7 +4669,9 @@ const Map = () => {
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(({ key, label, content }) => ({ key, label, content }));
|
||||
}, [
|
||||
activeLayerSettingOrder,
|
||||
applySinkholeRadius,
|
||||
applySinkholeYearRange,
|
||||
draftAgedPipeFilters,
|
||||
handleApplyAgedPipeFilters,
|
||||
isAgedPipeChecked,
|
||||
@@ -4696,6 +4679,7 @@ const Map = () => {
|
||||
isWaterDepthLayerActive,
|
||||
lastSinkholeSyncSummary,
|
||||
sinkholeRadiusMeters,
|
||||
sinkholeYearRange,
|
||||
userInfo?.adminRoleYn,
|
||||
waterDepthLegendItems,
|
||||
]);
|
||||
@@ -6401,8 +6385,7 @@ const Map = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope);
|
||||
const effectiveFilters = applySinkholeYearFilters(normalized, checkedNodesDataRef.current);
|
||||
const effectiveFilters = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope);
|
||||
popup.postMessage(
|
||||
{
|
||||
type: SINKHOLE_FILTER_SYNC_MESSAGE,
|
||||
|
||||
@@ -29,6 +29,13 @@ const withSiteIdHeaders = (headers: HeadersInit = {}) => ({
|
||||
const resolveApiUrl = (url: string) =>
|
||||
url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`;
|
||||
|
||||
const buildInvalidJsonResponseError = (url: string, text: string, contentType?: string | null) => {
|
||||
const preview = text.trim().slice(0, 200);
|
||||
const responseType = contentType ? ` (${contentType})` : "";
|
||||
return new Error(
|
||||
`JSON 응답이 아닌 데이터를 받았습니다${responseType}: ${url}${preview ? ` - ${preview}` : ""}`,
|
||||
);
|
||||
};
|
||||
const buildRequestError = (
|
||||
fallbackMessage: string,
|
||||
status?: number,
|
||||
@@ -274,11 +281,15 @@ export const getRawJson = async (url: string, init: RequestInit = {}): Promise<a
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("<")) {
|
||||
throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type"));
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
console.error('JSON parsing error:', error);
|
||||
throw new Error(`Invalid JSON response from server: ${url}`);
|
||||
} catch {
|
||||
throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,23 @@
|
||||
- 좌표 없는 사고건 상세 표시
|
||||
- 관리자 동기화 성공/실패 검증
|
||||
|
||||
### 3.3 2026-06-26 레이어 연도범위 설정 전환 검증 계획
|
||||
|
||||
- 레이어 트리 검증
|
||||
- `지반침하정보` 루트만 표시되고 `2026년`, `전체` 자식 노드가 표시되지 않는지 확인한다.
|
||||
- 루트 체크/해제만으로 지도 포인트 레이어가 on/off 되는지 확인한다.
|
||||
- 선택 레이어 설정 검증
|
||||
- `지반침하정보` 체크 시 하단 `선택 레이어 설정`에 반경 입력과 함께 `시작년도`, `종료년도`가 표시되는지 확인한다.
|
||||
- 현재 연도 2026 기준 최초값이 `2025`, `2026`인지 확인한다.
|
||||
- 적용 시 `/api/v1/sinkholes` 요청에 `startYmd=20250101`, `endYmd=20261231`이 포함되는지 확인한다.
|
||||
- 시작년도가 종료년도보다 큰 경우, 2016년 이전 입력, 4자리 미만 입력을 차단하는지 확인한다.
|
||||
- 기존 기능 회귀 검증
|
||||
- 반경 입력/적용, 우측 상세 패널, 반경 내 관로 조회가 기존처럼 동작하는지 확인한다.
|
||||
- 목록 팝업 오픈 시 설정패널 연도 범위가 초기 조회조건으로 전달되는지 확인한다.
|
||||
- 팝업 조회 후 지도 동기화와 팝업 닫힘 후 복귀 기준이 문서화된 정책과 일치하는지 확인한다.
|
||||
- DB 기준정보 검증
|
||||
- `tb_sn_lyr_stng`에서 `지반침하정보` 루트는 유지되고 `2026년`, `전체` 자식 행이 삭제 또는 비활성화됐는지 확인한다.
|
||||
|
||||
## 4. 브라우저 검증 선행조건
|
||||
|
||||
- `/map` 진입 가능한 로그인 상태
|
||||
@@ -70,3 +87,11 @@
|
||||
|
||||
- 이번 턴은 설계 단계이므로 실제 브라우저/API 실행 검증은 수행하지 않는다.
|
||||
- 구현 승인 전에는 이 문서의 시나리오와 설계 문서의 계약 일치 여부만 점검 대상으로 둔다.
|
||||
|
||||
### 3.4 2026-06-26 레이어 연도범위 설정 전환 구현 검증 계획
|
||||
|
||||
- 지반침하정보 레이어 트리에 `2026년`, `전체` 자식 노드가 표시되지 않고 루트 레이어만 on/off 되는지 확인한다.
|
||||
- 선택 레이어 설정 패널에 시작년도/종료년도 입력이 표시되고 기본값이 현재년도 기준 최근 1년인지 확인한다.
|
||||
- 연도 범위 적용 후 지도 조회 요청의 `startYmd/endYmd`가 시작년도 `0101`, 종료년도 `1231`로 변환되는지 확인한다.
|
||||
- 목록 팝업을 열었을 때 현재 지도 연도 범위가 초기 조회조건으로 전달되는지 확인한다.
|
||||
- 잘못된 연도 입력(4자리 미만, 시작년도 > 종료년도, 2016년 이전, 현재년도 초과)이 차단되는지 확인한다.
|
||||
|
||||
@@ -123,6 +123,34 @@
|
||||
- FE 컬럼 추가와 타입 추가, BE 응답 필드 추가만 남긴 상태에서 `git diff --check`를 다시 수행한다.
|
||||
- 실제 브라우저에서 사고번호 `20260026`처럼 기존에 24건이 나오던 케이스가 그대로 유지되는지 재확인이 필요하다.
|
||||
|
||||
### 2.16 2026-06-26 레이어 연도범위 설정 전환 사전 분석 결과
|
||||
|
||||
- 이번 턴은 구현 전 분석 단계이므로 코드 수정, 브라우저 실행, API 호출 검증은 수행하지 않았다.
|
||||
- 기존 문서와 코드 경로를 대조한 결과, 현재 지반침하 레이어 조회는 `2026년`/`전체` 자식 노드와 `applySinkholeYearFilters`에 의존하는 구조가 남아 있음을 확인했다.
|
||||
- `SinkholeLayerSettingPanel`에는 현재 반경 입력만 있고 시작/종료년도 입력이 없어, 다음 구현에서 패널 props와 상태를 확장해야 한다.
|
||||
- BE `SinkholeListSearchDto`와 `SinkholeQueryService`는 `startYmd`, `endYmd`, 최소일 검증을 이미 제공하므로 신규 BE 계약은 필요하지 않은 것으로 판정했다.
|
||||
- `COMMON_DOCS_ROOT/DB작업.sql`에는 `2026년`, `전체` 자식 레이어 생성/아이콘 보정 DML이 남아 있어, 다음 구현에서 기준 데이터 삭제/비활성화 DML을 함께 정리해야 한다.
|
||||
- 다음 검증은 구현 후 `지반침하정보` 루트 on/off, 설정패널 연도 범위 API 파라미터, 팝업 초기조건 동기화, 기존 반경/상세 회귀 순서로 수행한다.
|
||||
|
||||
|
||||
### 2.17 2026-06-26 레이어 연도범위 설정 전환 구현 검증 결과
|
||||
|
||||
- 코드 검색 결과 `2026년`/`전체` 합성 연도 노드 의존 심볼(`buildSinkholeYearNodes`, `isSinkholeYearNode`, `applySinkholeYearFilters`)은 지도 화면 코드에서 제거됐다.
|
||||
- 변경 TS/TSX 3개 파일(`page.tsx`, `SinkholeLayerSettingPanel.tsx`, `sinkholeTypes.ts`)은 로컬 TypeScript 파서 기준 diagnostics 0건을 확인했다.
|
||||
- 기존 dev 서버에서 `http://localhost:3002/map` 요청이 200으로 응답해 페이지 컴파일 진입이 가능함을 확인했다.
|
||||
- `git diff --check`는 FE/공통 변경 파일 기준 공백 오류 없음으로 통과했다. Git 줄바꿈 정책 경고는 일부 파일에서만 출력됐다.
|
||||
- `next lint`와 `tsc --noEmit`는 현재 프로젝트 도구 설정 문제로 완료하지 못했다. 실제 로그인 화면에서의 레이어 트리/Network query string 검증은 후속 수동 검증으로 남긴다.
|
||||
|
||||
|
||||
|
||||
### 2.18 2026-06-26 상세 fallback 및 설정 UI 보정 검증 결과
|
||||
|
||||
- `page.tsx`와 `SinkholeLayerSettingPanel.tsx` 변경분을 TypeScript 파서로 확인했고 diagnostics 0건이었다.
|
||||
- 기존 dev 서버에서 `http://localhost:3002/map` 요청이 200으로 응답했다.
|
||||
- `git diff --check`는 변경 FE 파일 기준 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다.
|
||||
- 상세 API 500 상황에서 fallback이 실제 화면에서 패널을 유지하는지, 반경 적용 후 Network 요청이 좌표 기반으로 나가는지는 로그인 세션이 있는 브라우저에서 후속 수동 확인이 필요하다.
|
||||
|
||||
|
||||
## 3. 미실행 항목
|
||||
|
||||
- FE 브라우저 검증
|
||||
|
||||
@@ -258,6 +258,53 @@
|
||||
- 기존 반경 내 관로 조회 기능은 정상 동작하던 기능이므로, `map/page.tsx`에 추가했던 좌표/사고번호 재조회 fallback 변경은 제거했다.
|
||||
- FE 변경 범위는 `SinkholeDetailPanel.tsx`의 표 컬럼 추가와 `sinkholeTypes.ts`의 `elapsedYears` 타입 추가로 제한한다.
|
||||
|
||||
### 4.33 2026-06-26 레이어 연도범위 설정 전환 사전 분석
|
||||
|
||||
- 이번 요청은 구현 전 분석 단계로 한정하며, 실제 FE/BE 코드는 수정하지 않았다.
|
||||
- 요구사항은 기존 `2026년`, `전체` 자식 레이어를 화면에서 제거하고, `지반침하정보` 루트 레이어만 on/off 하도록 단순화하는 것이다.
|
||||
- 현재 하단 `선택 레이어 설정`의 `SinkholeLayerSettingPanel`에는 `지반침하반경(m)`만 있으므로, 다음 구현에서는 같은 패널에 `시작년도`, `종료년도` 입력과 적용 버튼을 추가한다.
|
||||
- 기본 연도 범위는 현재 연도 기준 최근 1년으로 해석한다. 현재 연도가 2026년이면 화면 입력값은 `2025 ~ 2026`이고, API 호출값은 `startYmd=20250101`, `endYmd=20261231`로 변환한다.
|
||||
- 현재 코드에서 정리해야 할 지점은 아래와 같다.
|
||||
- `sinkholeTypes.ts`: `buildSinkholeYearNodes`, `isSinkholeYearNode`, `applySinkholeYearFilters`, 연도 노드 아이콘 상수의 사용 범위 축소 또는 제거
|
||||
- `page.tsx`: `appendSinkholeControlNode`의 연도 fallback 추가 제거, `applySinkholeYearFilters` 호출 제거, 팝업 close 시 기본 필터 복귀 기준 재정의
|
||||
- `SinkholeLayerSettingPanel.tsx`: 연도 범위 입력 상태와 검증, 적용 callback 추가
|
||||
- `DB작업.sql`: `2026년`, `전체` 자식 레이어 추가/보정 DML 제거 또는 기존 행 삭제 DML로 전환
|
||||
- BE는 이미 `startYmd/endYmd` 목록 조회 파라미터와 최소일 검증을 제공하므로, 이번 요구는 현재 분석 기준으로 FE/DB 기준정보 변경만으로 처리 가능하다.
|
||||
- 팝업 조회조건과 설정패널 연도 범위의 관계는 다음 구현에서 명확히 고정해야 한다. 우선안은 설정패널 연도 범위를 지도 레이어 기본 조건으로 두고, 팝업은 그 조건을 초기값으로 받아 목록 조회를 시작하는 방식이다.
|
||||
|
||||
|
||||
### 4.34 2026-06-26 레이어 연도범위 설정 전환 구현
|
||||
|
||||
- `sinkholeTypes.ts`에서 `2026년`/`전체` 합성 노드와 연도 노드 기반 필터 결합 로직을 제거하고, `SinkholeYearRange` 및 시작/종료년도 기반 필터 변환 유틸을 추가했다.
|
||||
- `page.tsx`는 지반침하정보 트리 노출을 루트 on/off로 단순화하고, 지도 조회/팝업 동기화는 선택 레이어 설정의 연도 범위에서 만든 `startYmd/endYmd`를 사용하도록 변경했다.
|
||||
- `SinkholeLayerSettingPanel.tsx`에 시작년도/종료년도 입력을 추가하고 적용 시 연도 유효성 검증 후 지도 레이어를 다시 조회하도록 연결했다.
|
||||
- 기본 연도 범위는 현재년도 기준 최근 1년이다. 2026년 기준 화면 기본값은 `2025 ~ 2026`이며 조회 파라미터는 `20250101 ~ 20261231`로 변환된다.
|
||||
- 기존 반경 설정, 상세 패널 반경 내 관로 조회, 목록 팝업 row 선택 흐름은 유지했다.
|
||||
|
||||
|
||||
|
||||
### 4.35 2026-06-26 상세 500 fallback 및 설정 패널 UI 보정
|
||||
|
||||
- 지도 포인트 선택 시 `loadSinkholeDetail`에 feature/list 응답 기반 fallback 상세정보를 함께 전달하도록 변경했다. 상세 API가 500을 반환해도 목록 응답에 있는 주소, 발생일자, 좌표, 복구상태 등으로 상세 패널을 계속 열 수 있다.
|
||||
- 지반침하반경 설정은 선택 지점이 없어도 전역 값으로 저장되며, 선택 지점이 있을 때만 반경 내 관로를 즉시 재조회한다.
|
||||
- 선택 지점 좌표가 있는 경우 반경 내 관로 재조회는 `nearbyPipesByPoint`를 우선 사용해 사고번호 기반 주변 관로 API 실패 영향을 줄였다.
|
||||
- 선택 레이어 설정 패널의 시작/종료년도 줄은 기존 반경 입력 줄과 같은 버튼 크기와 입력 높이, 간격으로 정렬했다.
|
||||
|
||||
|
||||
|
||||
### 4.36 2026-06-26 지반침하 포인트 색상 단일화
|
||||
|
||||
- 지반침하 포인트가 발생연도 기준으로 현재년도는 주황색, 그 외 연도는 파란색으로 표시되던 분기 로직을 제거했다.
|
||||
- `resolveSinkholePointVariant`는 발생일자를 받아도 항상 `blue`를 반환하도록 변경해, 2026년 데이터와 그 외 데이터가 모두 동일한 파란색 원형 포인트로 표시된다.
|
||||
- 선택 지점 포인트와 반경 표시 기본 스타일도 파란색 기준으로 맞췄다.
|
||||
|
||||
### 4.37 2026-06-26 선택 레이어 설정 안내문구 및 범례 높이 미세조정
|
||||
|
||||
- 지반침하 반경 적용 alert는 적용된 반경 값을 포함해 `지반침하반경은 {값}m로 반영되었습니다.`로 표시한다.
|
||||
- 지반침하 시작/종료년도 적용 alert는 적용된 연도 범위를 포함해 `시작년도 {시작년도} ~ 종료년도 {종료년도} 반영되었습니다.`로 표시한다.
|
||||
- 노후관로 경과년도 적용 alert는 적용된 경과년 값을 포함해 `노후관로 경과년도 {값}년으로 반영되었습니다.`로 표시한다.
|
||||
- 침수심 범례는 내부 디자인은 유지하고 외곽 세로 여백만 줄여 선택 레이어 설정 영역의 높이를 소폭 낮췄다.
|
||||
|
||||
## 5. 승인 전 보류 사항
|
||||
|
||||
- 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요
|
||||
|
||||
@@ -570,9 +570,10 @@ export default React.memo(function GisTree({
|
||||
return (
|
||||
<div style={{ display: isVisible ? "block" : "none" }}>
|
||||
<div
|
||||
className="gis-layer-row flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
|
||||
className="gis-layer-row flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
|
||||
style={{
|
||||
marginInlineStart: depth * 16,
|
||||
width: `calc(100% - ${depth * 16}px)`,
|
||||
}}
|
||||
>
|
||||
{hasVisibleChildren ? (
|
||||
@@ -583,7 +584,7 @@ export default React.memo(function GisTree({
|
||||
title={isOpen ? "닫기" : "열기"}
|
||||
/>
|
||||
) : (
|
||||
(isSinkholeRootTreeNode ? null : <span style={{ display: "inline-block", width: 16 }} />)
|
||||
<span className="shrink-0" style={{ display: "inline-block", width: 16 }} />
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -601,7 +602,7 @@ export default React.memo(function GisTree({
|
||||
{showLayerActionButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="gis-layer-action-button ml-1 shrink-0 rounded-sm border border-[#9cc6f0] bg-white px-2 py-[1px] text-[1.1rem] font-medium leading-5 text-[#2c6fb7] transition-colors hover:bg-[#eef6ff]"
|
||||
className="gis-layer-action-button ml-auto shrink-0 rounded-sm border border-[#9cc6f0] bg-white px-2 py-[1px] text-[1.1rem] font-medium leading-5 text-[#2c6fb7] transition-colors hover:bg-[#eef6ff]"
|
||||
onClick={() =>
|
||||
handleLayerImgClick(
|
||||
node.data.lyrTyNm,
|
||||
|
||||
Reference in New Issue
Block a user