지반침하 레이어 설정 UI 및 포인트 표시 개선

This commit is contained in:
kiminchan
2026-06-26 15:40:21 +09:00
parent 0fe1aa983c
commit e170790e4c
9 changed files with 510 additions and 480 deletions
@@ -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: "전체" },
+125 -142
View File
@@ -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,
+14 -3
View File
@@ -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,