지반침하 팝업 조회조건, 다운로드, 지도표출 방식 정리

This commit is contained in:
kiminchan
2026-06-09 15:26:09 +09:00
parent 8737d1eb0a
commit a274b5a261
10 changed files with 567 additions and 78 deletions
@@ -14,8 +14,10 @@ import {
formatDateInputToYmd,
formatYmdToDateInput,
normalizeSinkholeFilters,
SINKHOLE_EXCEL_HEADERS,
SINKHOLE_COORD_STATUS_OPTIONS,
SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
SINKHOLE_FILTER_SYNC_MESSAGE,
SINKHOLE_MIN_SEARCH_YMD,
SINKHOLE_RESTORE_STATUS_OPTIONS,
SINKHOLE_ROW_SELECT_MESSAGE,
type SinkholeFilters,
@@ -23,6 +25,7 @@ import {
} from "./sinkholeTypes";
const PAGE_SIZE = 20;
const DOWNLOAD_BATCH_SIZE = 2000;
type SinkholeTableRow = SinkholeListItem & {
rowNo: number;
@@ -60,10 +63,10 @@ const toCoordStatusDisplay = (value: unknown) => {
return normalized || "-";
};
const toTableRows = (items: SinkholeListItem[], page: number) =>
const toTableRows = (items: SinkholeListItem[], page: number, pageSize = PAGE_SIZE) =>
items.map((item, index) => ({
...item,
rowNo: page * PAGE_SIZE + index + 1,
rowNo: page * pageSize + index + 1,
sizeWidthDisplay: toDisplay(item.sizeWidth),
sizeLengthDisplay: toDisplay(item.sizeLength),
sizeDepthDisplay: toDisplay(item.sizeDepth),
@@ -82,7 +85,6 @@ const buildDownloadRows = (rows: SinkholeTableRow[]) =>
sizeDepth: row.sizeDepthDisplay,
detailCause: row.detailCauseDisplay,
restoreStatus: row.restoreStatus || "-",
coordStatus: row.coordStatusDisplay,
restoreCompletedYmd: row.restoreCompletedYmdDisplay,
}));
@@ -100,6 +102,25 @@ export default function SinkholePopupPage() {
ctpvCd: searchParams.get("ctpvCd") ?? "0",
sggCd: searchParams.get("sggCd") ?? "0",
restoreStatus: searchParams.get("restoreStatus") ?? "0",
coordStatus: searchParams.get("coordStatus") ?? "0",
},
undefined,
);
if (!seeded.startYmd || !seeded.endYmd) {
return normalizeSinkholeFilters(buildSinkholeDefaultFilters(), undefined);
}
return seeded;
});
const [appliedFilters, setAppliedFilters] = useState<SinkholeFilters>(() => {
const seeded = normalizeSinkholeFilters(
{
startYmd: searchParams.get("startYmd") ?? "",
endYmd: searchParams.get("endYmd") ?? "",
ctpvCd: searchParams.get("ctpvCd") ?? "0",
sggCd: searchParams.get("sggCd") ?? "0",
restoreStatus: searchParams.get("restoreStatus") ?? "0",
coordStatus: searchParams.get("coordStatus") ?? "0",
},
undefined,
);
@@ -128,13 +149,28 @@ export default function SinkholePopupPage() {
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (page: number, nextFilters: SinkholeFilters) => {
const queryString = buildSinkholeListQueryString(nextFilters, ctpv as SelectDataType[], sgg as SelectDataType[], page - 1, PAGE_SIZE);
async (
page: number,
nextFilters: SinkholeFilters,
pageSize = PAGE_SIZE,
options?: {
includeCoordStatus?: boolean;
},
) => {
const queryString = buildSinkholeListQueryString(
nextFilters,
ctpv as SelectDataType[],
sgg as SelectDataType[],
page - 1,
pageSize,
options,
);
const response = await getRawJson(`/sewerage-service/api/v1/sinkholes?${queryString}`);
const payload = unwrapPayload<{ items?: SinkholeListItem[]; total?: number }>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: toTableRows(items, page - 1),
rows: toTableRows(items, page - 1, pageSize),
items,
total: Number(payload?.total ?? 0),
};
},
@@ -150,6 +186,7 @@ export default function SinkholePopupPage() {
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(normalized);
window.opener?.postMessage(
{
type: SINKHOLE_FILTER_SYNC_MESSAGE,
@@ -197,18 +234,43 @@ export default function SinkholePopupPage() {
};
const handleDownload = async () => {
if (rows.length === 0) {
if (total === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(rows) as unknown as JSON[],
fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["지반침하정보"],
headerRange: "A1:J2",
headerMapping: SINKHOLE_EXCEL_HEADERS,
});
setIsLoading(true);
try {
const normalized = normalizeSinkholeFilters(appliedFilters);
const downloadItems: SinkholeListItem[] = [];
const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE));
for (let page = 1; page <= totalPages; page += 1) {
const response = await fetchRows(page, normalized, DOWNLOAD_BATCH_SIZE, {
includeCoordStatus: false,
});
downloadItems.push(...response.items);
}
if (downloadItems.length === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
const downloadRows = toTableRows(downloadItems, 0, 1);
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(downloadRows) as unknown as JSON[],
fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["지반침하정보"],
headerRange: "A1:I1",
headerMapping: SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
});
} catch (error) {
console.error("지반침하 다운로드 중 오류:", error);
alert(error instanceof Error ? error.message : "지반침하 다운로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
return (
@@ -220,12 +282,13 @@ export default function SinkholePopupPage() {
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[170px_170px_220px_220px_180px_auto] md:items-end md:justify-between">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[170px_170px_220px_220px_180px_180px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(시작)</div>
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
onChange={(event) => setFilters({ ...filters, startYmd: formatDateInputToYmd(event.target.value) })}
/>
@@ -236,6 +299,7 @@ export default function SinkholePopupPage() {
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
onChange={(event) => setFilters({ ...filters, endYmd: formatDateInputToYmd(event.target.value) })}
/>
@@ -294,6 +358,21 @@ export default function SinkholePopupPage() {
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">좌표보정상태</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.coordStatus}
onChange={(event) => setFilters({ ...filters, coordStatus: event.target.value })}
>
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
@@ -15,6 +15,9 @@ export const SINKHOLE_SYNC_COMPLETED_MESSAGE = "SN_SINKHOLE_SYNC_COMPLETED";
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.svg";
export const SINKHOLE_YEAR_ICON_BLUE = "SN_SINKHOLE_SPOT_BLUE.svg";
export type SinkholeFilters = {
startYmd: string;
@@ -22,6 +25,7 @@ export type SinkholeFilters = {
ctpvCd: string;
sggCd: string;
restoreStatus: string;
coordStatus: string;
};
export type SinkholeListItem = {
@@ -88,15 +92,16 @@ const toYmd = (date: Date) =>
export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilters => {
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(today.getMonth() - 3);
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
return {
startYmd: toYmd(threeMonthsAgo),
startYmd: toYmd(oneYearAgo),
endYmd: toYmd(today),
ctpvCd: scope?.ctpvCd || "0",
sggCd: scope?.sggCd || "0",
restoreStatus: "0",
coordStatus: "0",
};
};
@@ -147,6 +152,7 @@ export const normalizeSinkholeFilters = (
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
restoreStatus: String(filters.restoreStatus ?? "0") || "0",
coordStatus: String(filters.coordStatus ?? "0") || "0",
};
if (scope?.ctpvCd && scope.ctpvCd !== "0") {
@@ -186,6 +192,9 @@ export const buildSinkholeListQueryString = (
sggOptions: SelectDataType[],
page = 0,
size = 20,
options?: {
includeCoordStatus?: boolean;
},
) => {
const params = new URLSearchParams({
page: String(page),
@@ -202,6 +211,12 @@ export const buildSinkholeListQueryString = (
if (filters.restoreStatus && filters.restoreStatus !== "0") {
params.set("restoreStatus", filters.restoreStatus);
}
if (filters.coordStatus && filters.coordStatus !== "0") {
params.set("coordStatus", filters.coordStatus);
}
if (options?.includeCoordStatus === false) {
params.set("includeCoordStatus", "false");
}
return params.toString();
};
@@ -240,6 +255,33 @@ export const isSinkholeYearNode = (node: any) => {
);
};
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 [
{
@@ -254,9 +296,9 @@ export const buildSinkholeYearNodes = (currentYear = new Date().getFullYear()) =
gobjTy: "",
lyrTyNm: SINKHOLE_LAYER_TYPE,
sinkholeYear: String(currentYear),
iconNm: SINKHOLE_YEAR_ICON_ORANGE,
useYn: "Y",
minViewSize: null,
iconNm: null,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
@@ -287,9 +329,9 @@ export const buildSinkholeYearNodes = (currentYear = new Date().getFullYear()) =
gobjTy: "",
lyrTyNm: SINKHOLE_LAYER_TYPE,
sinkholeYear: "ALL",
iconNm: SINKHOLE_YEAR_ICON_BLUE,
useYn: "Y",
minViewSize: null,
iconNm: null,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
@@ -313,7 +355,7 @@ export const buildSinkholeYearNodes = (currentYear = new Date().getFullYear()) =
export const applySinkholeNodeFilters = (filters: SinkholeFilters, node: any) => {
const data = node?.data ?? node;
const sinkholeYear = String(data?.sinkholeYear ?? "").trim();
const sinkholeYear = resolveSinkholeYearToken(node);
const filterSql = String(data?.flterSql ?? data?.filterSql ?? "").trim();
if (sinkholeYear === "ALL") {
@@ -348,8 +390,7 @@ export const applySinkholeNodeFilters = (filters: SinkholeFilters, node: any) =>
export const applySinkholeYearFilters = (filters: SinkholeFilters, checkedNodesData: any[]) => {
const selectedYears = checkedNodesData
.map((node) => node?.data ?? node)
.map((data) => String(data?.sinkholeYear ?? ""))
.map((node) => resolveSinkholeYearToken(node))
.filter(Boolean);
if (selectedYears.includes("ALL")) {
@@ -387,6 +428,12 @@ export const SINKHOLE_RESTORE_STATUS_OPTIONS = [
{ value: "미복구", label: "미복구" },
] as const;
export const SINKHOLE_COORD_STATUS_OPTIONS = [
{ value: "0", label: "전체" },
{ value: "SUCCESS", label: "성공" },
{ value: "FAILED", label: "실패" },
] as const;
export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
rowNo: "NO",
ocrnYmd: "발생일자",
@@ -399,3 +446,15 @@ export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
coordStatus: "좌표보정상태",
restoreCompletedYmd: "복구완료일자",
};
export const SINKHOLE_DOWNLOAD_EXCEL_HEADERS: Record<string, string> = {
rowNo: "NO",
ocrnYmd: "발생일자",
fullAddress: "주소",
sizeWidth: "발생규모폭",
sizeLength: "발생규모연장",
sizeDepth: "발생규모깊이",
detailCause: "상세발생원인",
restoreStatus: "복구상태",
restoreCompletedYmd: "복구완료일자",
};
+251 -44
View File
@@ -82,9 +82,9 @@ import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
import {
applySinkholeNodeFilters,
applySinkholeYearFilters,
buildSinkholeYearNodes,
buildSinkholeDefaultFilters,
resolveSinkholeLegendIconName,
buildSinkholeListQueryString,
buildSinkholeScope,
isSinkholeLayerNode,
@@ -271,6 +271,64 @@ const buildSinkholeFeatureCollectionFromItems = (items: SinkholeListItem[]) => (
.filter(Boolean),
});
const SINKHOLE_POINT_PALETTE = {
orange: {
fill: "rgba(214, 102, 31, 0.88)",
fillSelected: "rgba(255, 138, 0, 0.92)",
stroke: "rgba(255, 255, 255, 0.95)",
strokeSelected: "rgba(120, 53, 15, 0.98)",
radiusStroke: "rgba(214, 102, 31, 0.75)",
radiusFill: "rgba(251, 146, 60, 0.12)",
},
blue: {
fill: "rgba(37, 99, 235, 0.82)",
fillSelected: "rgba(37, 99, 235, 0.9)",
stroke: "rgba(255, 255, 255, 0.95)",
strokeSelected: "rgba(30, 64, 175, 0.98)",
radiusStroke: "rgba(37, 99, 235, 0.7)",
radiusFill: "rgba(96, 165, 250, 0.12)",
},
} as const;
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";
};
// 현재 지도 화면 BBOX 주변을 함께 조회해 이동 직후 가장자리 유하방향이 비어 보이는 현상을 줄인다.
const expandFlowDirectionExtent = (extent: number[]) => {
const [minX = 0, minY = 0, maxX = 0, maxY = 0] = extent;
@@ -785,30 +843,37 @@ const getSinkholePointFromSelection = (data: any): { x: number; y: number } | nu
return null;
};
const createSinkholePointStyle = (selected = false) =>
const createSinkholePointStyle = (
variant: SinkholePointVariant = "orange",
selected = false,
) =>
new Style({
image: new CircleStyle({
radius: selected ? 9 : 7,
fill: new Fill({
color: selected ? "rgba(255, 138, 0, 0.92)" : "rgba(214, 102, 31, 0.88)",
color: selected
? SINKHOLE_POINT_PALETTE[variant].fillSelected
: SINKHOLE_POINT_PALETTE[variant].fill,
}),
stroke: new Stroke({
color: selected ? "rgba(120, 53, 15, 0.98)" : "rgba(255, 255, 255, 0.95)",
color: selected
? SINKHOLE_POINT_PALETTE[variant].strokeSelected
: SINKHOLE_POINT_PALETTE[variant].stroke,
width: selected ? 2.6 : 2,
}),
}),
zIndex: selected ? 9997 : 9994,
});
const createSinkholeRadiusStyle = () =>
const createSinkholeRadiusStyle = (variant: SinkholePointVariant = "orange") =>
new Style({
stroke: new Stroke({
color: "rgba(214, 102, 31, 0.75)",
color: SINKHOLE_POINT_PALETTE[variant].radiusStroke,
width: 2.2,
lineDash: [8, 6],
}),
fill: new Fill({
color: "rgba(251, 146, 60, 0.12)",
color: SINKHOLE_POINT_PALETTE[variant].radiusFill,
}),
zIndex: 9993,
});
@@ -2065,14 +2130,24 @@ const appendSinkholeControlNode = (data: any[]) => {
if (existingRoot && existingChildren.length > 0) {
return data.map((item) => {
const nodeId = String(item?.lyrSn ?? item?.id ?? "");
if (nodeId !== existingRootId) return item;
return {
...item,
gobjTy: "",
iconNm: null,
clickPsblYn: "N",
actlLyrYn: "N",
};
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,
};
}
return item;
});
}
@@ -2190,6 +2265,7 @@ const Map = () => {
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef<Window | null>(null);
const sinkholePopupWindowRef = useRef<Window | null>(null);
const sinkholePopupCloseWatcherRef = useRef<number | null>(null);
const pipeAnalysisPopupWindowRef = useRef<Window | null>(null);
const gisEditSessionPopupWindowRef = useRef<Window | null>(null);
const gisEditSessionPopupCloseWatcherRef = useRef<number | null>(null);
@@ -2637,7 +2713,13 @@ const Map = () => {
const fetchData = async () => {
try {
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(appendAgedPipeControlNode(normalizeFlowDirectionLayerInfos(data)));
setTreeData(
appendAgedPipeControlNode(
appendSinkholeControlNode(
normalizeTreeRowSpacing(normalizeFlowDirectionLayerInfos(data)),
),
),
);
setLayerLoadError(null);
} catch (error) {
const message =
@@ -3745,12 +3827,13 @@ const Map = () => {
const pointFeature = new Feature({
geometry: new Point([detail.x, detail.y]),
});
pointFeature.setStyle(createSinkholePointStyle(true));
const pointVariant = resolveSinkholePointVariant(detail.ocrnYmd, checkedNodesDataRef.current);
pointFeature.setStyle(createSinkholePointStyle(pointVariant, true));
const radiusFeature = new Feature({
geometry: new CircleGeom([detail.x, detail.y], radiusMeters),
});
radiusFeature.setStyle(createSinkholeRadiusStyle());
radiusFeature.setStyle(createSinkholeRadiusStyle(pointVariant));
source.addFeatures([radiusFeature, pointFeature]);
},
@@ -3771,8 +3854,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],
);
@@ -3784,9 +3867,8 @@ const Map = () => {
if (!source) return;
const normalized = normalizeSinkholeFilters(filters, sinkholeScope);
const effectiveFilters = applySinkholeYearFilters(normalized, checkedNodesDataRef.current);
try {
const params = new URLSearchParams(buildSinkholeQueryString(effectiveFilters, 0, SINKHOLE_DEFAULT_POINT_LIMIT));
const params = new URLSearchParams(buildSinkholeQueryString(normalized, 0, SINKHOLE_DEFAULT_POINT_LIMIT));
const rawResponse = await getRawJson(
`/sewerage-service/api/v1/sinkholes?${params.toString()}`,
);
@@ -3809,7 +3891,11 @@ const Map = () => {
});
source.clear();
features.forEach((feature: any) => {
feature.setStyle(createSinkholePointStyle(false));
const variant = resolveSinkholePointVariant(
String(feature?.get?.("ocrnYmd") ?? ""),
checkedNodesDataRef.current,
);
feature.setStyle(createSinkholePointStyle(variant, false));
});
source.addFeatures(features);
} catch (error) {
@@ -3833,14 +3919,109 @@ const Map = () => {
}, [clearSinkholeFocusedPipeLayer, clearSinkholeSelectionLayer, featureData]);
const loadSinkholeDetail = useCallback(
async (acdntNo: string, focusMap = true, fallbackPoint?: { x: number; y: number } | null) => {
async (
acdntNo: string,
focusMap = true,
fallbackPoint?: { x: number; y: number } | null,
fallbackDetail?: Partial<SinkholeDetail> | null,
) => {
if (!acdntNo) return;
try {
const detailResponse = await getRawJson(
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}`,
);
const detail = ((detailResponse?.data ?? detailResponse) as SinkholeDetail);
const hasFallbackCoordinate =
(fallbackDetail?.x != null && fallbackDetail?.y != null) ||
(fallbackPoint != null &&
Number.isFinite(fallbackPoint.x) &&
Number.isFinite(fallbackPoint.y));
const fallbackCoordStatus = String(
fallbackDetail?.coordStatus ?? fallbackDetail?.geocodeStatus ?? "",
)
.trim()
.toUpperCase();
if (
fallbackDetail &&
!hasFallbackCoordinate &&
fallbackCoordStatus &&
fallbackCoordStatus !== "SUCCESS"
) {
const noCoordDetail: SinkholeDetail = {
acdntNo,
ctpvNm: String(fallbackDetail.ctpvNm ?? ""),
sggNm: String(fallbackDetail.sggNm ?? ""),
emdNm: String(fallbackDetail.emdNm ?? ""),
daddr: String(fallbackDetail.daddr ?? ""),
fullAddress: String(fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? ""),
ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""),
restoreStatus: String(fallbackDetail.restoreStatus ?? ""),
restoreCompletedYmd: String(fallbackDetail.restoreCompletedYmd ?? ""),
coordStatus: String(fallbackDetail.coordStatus ?? fallbackDetail.geocodeStatus ?? ""),
geocodeStatus: String(fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? ""),
geocodeSource: String(fallbackDetail.geocodeSource ?? ""),
geocodeErrorMessage: String(fallbackDetail.geocodeErrorMessage ?? ""),
latitude: fallbackDetail.latitude ?? null,
longitude: fallbackDetail.longitude ?? null,
x: fallbackDetail.x ?? null,
y: fallbackDetail.y ?? null,
sizeWidth: fallbackDetail.sizeWidth ?? null,
sizeLength: fallbackDetail.sizeLength ?? null,
sizeDepth: fallbackDetail.sizeDepth ?? null,
detailCause: fallbackDetail.detailCause ?? null,
deathCount: fallbackDetail.deathCount ?? null,
injuryCount: fallbackDetail.injuryCount ?? null,
vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null,
};
setSelectedSinkholeDetail(noCoordDetail);
setSelectedSinkholeNearbyPipes([]);
renderSelectedSinkhole(null, sinkholeRadiusMeters);
setFeatureData({
lyrTypeNm: SINKHOLE_LAYER_TYPE,
acdntNo: noCoordDetail.acdntNo,
});
setIsOpen(true);
setIsSearchModalOpen(false);
return;
}
let detail: SinkholeDetail;
try {
const detailResponse = await getRawJson(
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}`,
);
detail = ((detailResponse?.data ?? detailResponse) as SinkholeDetail);
} catch (detailError) {
if (!fallbackDetail) {
throw detailError;
}
console.warn("지반침하 상세 API 실패, 목록 데이터로 대체합니다.", detailError);
detail = {
acdntNo,
ctpvNm: String(fallbackDetail.ctpvNm ?? ""),
sggNm: String(fallbackDetail.sggNm ?? ""),
emdNm: String(fallbackDetail.emdNm ?? ""),
daddr: String(fallbackDetail.daddr ?? ""),
fullAddress: String(fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? ""),
ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""),
restoreStatus: String(fallbackDetail.restoreStatus ?? ""),
restoreCompletedYmd: String(fallbackDetail.restoreCompletedYmd ?? ""),
coordStatus: String(fallbackDetail.coordStatus ?? ""),
geocodeStatus: String(fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? ""),
geocodeSource: String(fallbackDetail.geocodeSource ?? ""),
geocodeErrorMessage: String(fallbackDetail.geocodeErrorMessage ?? ""),
latitude: fallbackDetail.latitude ?? null,
longitude: fallbackDetail.longitude ?? null,
x: fallbackDetail.x ?? null,
y: fallbackDetail.y ?? null,
sizeWidth: fallbackDetail.sizeWidth ?? null,
sizeLength: fallbackDetail.sizeLength ?? null,
sizeDepth: fallbackDetail.sizeDepth ?? null,
detailCause: fallbackDetail.detailCause ?? null,
deathCount: fallbackDetail.deathCount ?? null,
injuryCount: fallbackDetail.injuryCount ?? null,
vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null,
};
}
const resolvedPoint =
detail.x != null && detail.y != null
? { x: detail.x, y: detail.y }
@@ -3848,17 +4029,22 @@ const Map = () => {
? fallbackPoint
: null;
const nearbyRawResponse = resolvedPoint
? await getRawJson(
`/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${resolvedPoint.x}&y=${resolvedPoint.y}&radiusMeters=${sinkholeRadiusMeters}`,
)
: await getRawJson(
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}/nearbyPipes?radiusMeters=${sinkholeRadiusMeters}`,
);
const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse;
const nearbyPipes = Array.isArray(nearbyResponse?.items)
? (nearbyResponse.items as SinkholeNearbyPipe[])
: [];
let nearbyPipes: SinkholeNearbyPipe[] = [];
try {
const nearbyRawResponse = resolvedPoint
? await getRawJson(
`/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${resolvedPoint.x}&y=${resolvedPoint.y}&radiusMeters=${sinkholeRadiusMeters}`,
)
: await getRawJson(
`/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}/nearbyPipes?radiusMeters=${sinkholeRadiusMeters}`,
);
const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse;
nearbyPipes = Array.isArray(nearbyResponse?.items)
? (nearbyResponse.items as SinkholeNearbyPipe[])
: [];
} catch (nearbyError) {
console.warn("지반침하 반경 내 관로 조회 실패, 상세는 계속 표시합니다.", nearbyError);
}
const normalizedDetail = resolvedPoint
? {
...detail,
@@ -3962,16 +4148,38 @@ const Map = () => {
const openSinkholePopupWindow = useCallback(() => {
const normalized = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope);
const effectiveFilters = applySinkholeYearFilters(normalized, checkedNodesDataRef.current);
const popup = openWindowPopup(SINKHOLE_POPUP_KEY as WindowPopupKey, effectiveFilters as Record<string, string>);
const popup = openWindowPopup(SINKHOLE_POPUP_KEY as WindowPopupKey, normalized as Record<string, string>);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
return null;
}
if (sinkholePopupCloseWatcherRef.current) {
window.clearInterval(sinkholePopupCloseWatcherRef.current);
sinkholePopupCloseWatcherRef.current = null;
}
sinkholePopupWindowRef.current = popup;
sinkholePopupCloseWatcherRef.current = window.setInterval(() => {
if (!popup.closed) return;
if (sinkholePopupCloseWatcherRef.current) {
window.clearInterval(sinkholePopupCloseWatcherRef.current);
sinkholePopupCloseWatcherRef.current = null;
}
sinkholePopupWindowRef.current = null;
const defaultFilters = normalizeSinkholeFilters(buildSinkholeDefaultFilters(sinkholeScope), sinkholeScope);
setAppliedSinkholeFilters(defaultFilters);
setSinkholeSelectionVersion((version) => version + 1);
}, 300);
return popup;
}, [appliedSinkholeFilters, sinkholeScope]);
useEffect(() => () => {
if (sinkholePopupCloseWatcherRef.current) {
window.clearInterval(sinkholePopupCloseWatcherRef.current);
sinkholePopupCloseWatcherRef.current = null;
}
}, []);
const handleAgedPipeRowDoubleClick = useCallback(
async (row: AgedPipeRow) => {
try {
@@ -4092,7 +4300,7 @@ const Map = () => {
row.x != null && row.y != null
? { x: Number(row.x), y: Number(row.y) }
: null;
void loadSinkholeDetail(String(row.acdntNo ?? ""), true, fallbackPoint);
void loadSinkholeDetail(String(row.acdntNo ?? ""), true, fallbackPoint, row);
return;
}
@@ -5887,11 +6095,10 @@ const Map = () => {
}
const normalized = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope);
const effectiveFilters = applySinkholeYearFilters(normalized, checkedNodesDataRef.current);
popup.postMessage(
{
type: SINKHOLE_FILTER_SYNC_MESSAGE,
filters: effectiveFilters,
filters: normalized,
},
window.location.origin,
);
@@ -12,7 +12,7 @@
- 목록 아이콘 클릭 시 지반침하 목록 팝업이 열려야 한다.
- 목록 조회조건 검증이 동작해야 한다.
- 날짜 누락 차단
- 최대 1년 초과 차단
- `2016-01-01` 이전 날짜 차단
- 목록 행 클릭 또는 지도 포인트 클릭 시 우측 상세 패널이 열려야 한다.
- 반경 적용 시 지도 원과 반경 내 관로 목록이 함께 갱신돼야 한다.
- 좌표 없음/좌표 변환 실패/반경 내 관로 없음 케이스가 서로 다른 안내로 분기돼야 한다.
@@ -31,6 +31,49 @@
- 따라서 이번 런타임 장애의 1차 원인은 프론트 fetch 자체가 아니라 `reuse-service` 지오코딩 응답 필드 확장을 백엔드가 무시하지 못한 점으로 판정했다.
- `./gradlew compileJava --no-daemon`는 시도했으나 로컬 점유 중인 `spring-configuration-metadata.json` 때문에 `Failed to clean up stale outputs`로 중단돼, 재기동 후 최종 확인이 필요하다.
### 2.4 2026-06-09 연도/전체 노드 코드 경로 확인
- 현재 프론트 지반침하 연도 필터 로직을 다시 확인한 결과, `tb_sn_lyr_stng`에서 내려오는 `2026년`, `전체` 자식 노드는 `sinkholeYear` 값이 없어 기존 코드가 연도 토큰을 읽지 못하고 있었다.
- 그래서 화면에 연도 노드가 보여도 초기 표출과 팝업 필터 동기화에서는 최근 3개월 기본 조건이 남아 있을 수 있는 상태로 판정했다.
- 이번 수정으로 `sinkholeYear`가 없더라도 노드 텍스트 `2026년`, `전체`를 각각 `2026`, `ALL`로 해석하도록 보강했다.
- 레이어 설정 초기 로딩도 지반침하 트리 보정 유틸을 실제로 거치도록 연결해, DB 자식 노드 유무와 상관없이 같은 정리 경로를 타게 했다.
### 2.5 2026-06-09 발생기간 조회 시작일 2016년 확장
- 현재 팝업은 빈 조회조건일 때 최근 3개월을 기본값으로 쓰고 있어, 사용자가 장기 이력을 보려 해도 시작일을 다시 넓혀야 하는 상태였다.
- 이번 수정으로 프론트 기본 조회조건을 `2016-01-01 ~ 오늘`로 바꾸고, 날짜 입력 `min`도 `2016-01-01`로 고정했다.
- 백엔드도 같은 날 `최대 1년` 검증을 제거하고 `2016-01-01` 이전 날짜만 차단하도록 바꿨다.
### 2.6 2026-06-09 다운로드 전체 결과 기준 코드 경로 확인
- 현재 지반침하 다운로드는 화면에 보이는 현재 페이지 `rows`만 엑셀로 내려가고 있어, 페이지네이션 이후 데이터는 누락되는 상태였다.
- 이번 수정으로 다운로드 시 현재 조회조건을 유지한 채 `/api/v1/sinkholes`를 최대 2000건 단위로 재호출해 전체 결과를 합친 뒤 엑셀을 생성하도록 변경했다.
- 아직 브라우저에서 2페이지 이상 결과를 실제로 내려받아 행 수를 대조하는 수동 검증은 수행하지 못했다.
### 2.7 2026-06-09 다운로드 병목 재검토
- 백엔드 목록 API는 각 행마다 `SinkholeCoordinateResolver.resolve()`를 호출하고, 원천 TM 좌표가 없으면 외부 지오코딩을 타므로 대량 다운로드에서 이 경로가 지연의 핵심 병목이 될 수 있다고 판단했다.
- 그래서 동일 주소의 실패 지오코딩도 30분 단기 캐시에 저장하고, 프론트 다운로드 배치도 2000건으로 올려 1600건 규모에서는 목록 API 1회 호출에 가깝게 줄였다.
- 아직 실제 운영/개발 데이터 1600건 조건에서 전후 다운로드 시간을 계측하지는 못했다.
### 2.8 2026-06-09 좌표 실패 건 상세 fallback 코드 경로 확인
- 좌표 실패 건 상세 클릭 시 `GET /api/v1/sinkholes/{acdntNo}`가 generic 500으로 실패하는 사례가 확인됐다.
- 이번 수정으로 목록 행 클릭 경로에서는 상세 API 실패 시 목록 row 데이터를 fallback 상세로 사용하고, 인접관로 조회 실패도 별도 warning으로만 남기도록 바꿨다.
- 추가로 row 자체가 이미 좌표 실패 상태이고 x/y도 없으면, 상세 API를 아예 생략하고 즉시 fallback 상세 패널을 열도록 바꿨다.
- 아직 실제 브라우저에서 실패 좌표 row 클릭 후 우측 패널이 fallback 데이터로 열리는지 재검증은 수행하지 못했다.
### 2.9 2026-06-09 목록/다운로드 조회조건 변경 코드 경로 확인
- 기본 발생기간은 `2016-01-01` 고정이 아니라 다시 `현재 기준 최근 1년`으로 되돌렸다.
- 목록 조회영역에 `좌표보정상태` 조건을 추가했고, 목록 API query string도 해당 값을 함께 보내도록 바꿨다.
- 다운로드는 `includeCoordStatus=false`를 붙여 `좌표보정상태` 열 없이 현재 조회조건 전체 결과를 다시 받아 엑셀을 만들도록 변경했다.
### 2.10 2026-06-09 조회옵션/헤더범위 마감 확인
- `좌표보정상태` 옵션에서 `주소없음`은 제거되고 `전체 / 성공 / 실패`만 남는 코드 경로를 확인했다.
- 지반침하 다운로드는 `headerRange=A1:I1`로 수정돼 첫 데이터 행이 헤더 스타일을 먹지 않도록 정리된 것을 확인했다.
## 3. 미실행 항목
- FE 브라우저 검증
@@ -38,11 +81,14 @@
- 반경 내 관로 거리 계산 검증
- 관리자 권한 분기 검증
- 다운로드 파일 검증
- FE 빌드 검증
- BE compile/test 최종 검증
미실행 사유:
- 작성자 승인 전 구현 금지 지시
- 이번 턴 범위가 설계 및 문서 초안 작성까지로 제한됨
- 이번 유지보수 라운드에서는 코드 경로 점검과 최소 수정까지만 수행했고, 개발 서버 실행 여부를 확인하지 못해 FE 빌드를 별도로 돌리지 않았다.
## 4. 현재 상태 판정
@@ -70,6 +70,7 @@
- 레이어 트리에서 `지반침하정보` 체크
- 목록 아이콘 클릭
- 목록 팝업에서 조건 입력 후 조회
- 좌표보정상태 조건 선택
- 목록 행 클릭 또는 지도 포인트 클릭
- 좌측 하단 반경 입력 후 적용
- 관리자일 경우 동기화 클릭
@@ -80,7 +81,7 @@
- 목록 다운로드 가능
- 오류 흐름
- 날짜 미입력
- 기간 1년 초과
- `2016-01-01` 이전 날짜 입력
- 좌표 없음
- 상세조회 실패
- 반경 내 관로 없음
@@ -91,11 +92,11 @@
| 기능 | Method | URL | 요청 | 응답 | 확인 상태 |
| --- | --- | --- | --- | --- | --- |
| 지도 포인트 조회 | `GET` | `/api/v1/sinkholes/geometry` | 기간/행정구역/limit | GeoJSON `FeatureCollection` | 현재 BE 선반영 |
| 목록 조회 | `GET` | `/api/v1/sinkholes` | 기간, 시도, 시군구, 복구상태, page, size | `items[]`, `total` | BE 신규 필요 |
| 목록 조회 | `GET` | `/api/v1/sinkholes` | 기간, 시도, 시군구, 복구상태, 좌표보정상태, page, size | `items[]`, `total` | BE 신규 필요 |
| 상세 조회 | `GET` | `/api/v1/sinkholes/{acdntNo}` | 사고번호 | 상세 DTO | BE 신규 필요 |
| 반경 내 관로 조회 | `GET` | `/api/v1/sinkholes/{acdntNo}/nearbyPipes` | 사고번호, 반경 | 관로 목록 | BE 신규 필요 |
| 동기화 | `POST` | `/api/v1/sinkholes/sync` | 관리자 요청 body | 처리 결과 | BE 신규 필요 |
| 다운로드 | `GET` | `/api/v1/sinkholes/download` | 현재 조회조건 | xlsx | BE 신규 필요 |
| 다운로드 | `GET` | `/api/v1/sinkholes` | 현재 조회조건 + `includeCoordStatus=false` | FE xlsx 생성 | 목록 재조회 기반 |
- FE 메모
- 좌표 보정과 반경 내 관로 거리 계산은 프론트에서 하지 않는다.
@@ -85,6 +85,96 @@
- 그래서 프론트는 실제 루트 row를 부모 토글로 사용하고, DB 자식 노드가 없을 때만 `현재년도`, `전체` 2개를 fallback으로 붙이도록 정리했다.
- 연도 노드 클릭 시에는 노드명 하드코딩 대신 `filter_sql`을 우선 읽어 목록 조회 조건을 만들고, `filter_sql`이 없을 때만 `sinkholeYear` 값을 fallback으로 사용하도록 수정했다.
### 4.7 2026-06-09 연도/전체 노드 해석 유지보수
- `tb_sn_lyr_stng`에 `2026년`, `전체` 자식 노드를 실제로 넣은 뒤에는 프론트 fallback 노드가 아니라 DB 노드를 주로 타게 됐는데, 기존 `applySinkholeYearFilters`는 `sinkholeYear` 커스텀 필드만 읽고 있어 DB 자식 노드 선택을 해석하지 못하고 있었다.
- 그 결과 화면에는 `2026년`, `전체`가 보여도 초기 레이어 표출과 팝업 동기화에서는 기본 최근 3개월 조건이 남아 있을 수 있는 드리프트가 생겼다.
- 이번 유지보수에서 프론트는 `sinkholeYear`가 없는 경우에도 노드 텍스트 `2026년`, `전체`를 각각 `2026`, `ALL` 토큰으로 해석하도록 보강했다.
- 또한 `page.tsx`의 레이어 설정 초기 로딩에 지반침하 루트/자식 보정 유틸을 다시 연결해, DB 자식 노드가 있을 때와 없을 때 모두 같은 정리 경로를 거치도록 맞췄다.
### 4.8 2026-06-09 발생기간 조회 시작일 2016년 확장
- 지반침하발생현황 팝업은 빈 조회조건으로 열릴 때 최근 3개월을 기본값으로 넣고 있었는데, 운영 확인용 장기 이력 조회에서는 시작일을 2016년까지 바로 내릴 수 있어야 한다는 요청이 생겼다.
- 프론트 기본 조회조건은 `2016-01-01 ~ 오늘`로 바꾸고, 날짜 입력 `min`도 `2016-01-01`로 고정했다.
- 이 변경만으로는 서버가 `최대 1년` 검증에서 막히므로, 백엔드 검증 규칙도 같은 기준으로 함께 맞춰야 한다고 정리했다.
### 4.9 2026-06-09 다운로드 전체 결과 기준 재조회 유지보수
- 기존 다운로드 버튼은 현재 테이블에 렌더된 `rows`만 엑셀로 내보내고 있어, 사용자가 2페이지 이후 결과를 조회하지 않아도 “전체 조회 결과 다운로드”를 기대하는 UX와 어긋나고 있었다.
- 이번 수정으로 다운로드는 현재 조회조건을 그대로 유지한 채 `/api/v1/sinkholes`를 200건 단위로 다시 호출해 전체 결과를 합친 뒤 엑셀을 생성하도록 바꿨다.
- 엑셀 `NO` 컬럼도 현재 페이지 기준이 아니라 전체 결과 기준 1번부터 연속 번호가 매겨지도록 정리했다.
### 4.10 2026-06-09 다운로드 체감속도 재검토
- 지반침하 다운로드는 기능적으로 전체 결과 기준으로 맞췄지만, 1600건 수준에서도 체감이 길다면 단순 페이지 수보다 백엔드 좌표보정 경로가 병목일 가능성이 높다고 판단했다.
- 프론트 재조회 배치 크기는 2000으로 올려 1600건 정도는 1회 목록 호출로 내려받게 조정했다.
- 이와 별개로 동일 주소의 지오코딩 실패도 30분 동안은 캐시되도록 백엔드 정책을 바꿔, 같은 다운로드를 반복할 때 외부 지오코딩을 매번 다시 치지 않게 정리했다.
### 4.11 2026-06-09 좌표 실패 건 상세 fallback 표시
- 좌표변환 실패 건을 목록에서 클릭했을 때 상세 API가 generic 500으로 실패하면, 사용자는 사고 상세 자체를 전혀 열 수 없게 되는 문제가 있었다.
- 하지만 목록 행에는 사고번호, 발생일자, 주소, 복구상태, 좌표보정상태, 지오코딩 실패 사유가 이미 있으므로, 이 경우에는 목록 데이터를 우선 상세 패널 fallback 값으로 사용해도 UX가 성립한다고 판단했다.
- 그래서 목록 행 클릭 경로에서는 상세 API 실패 시 row 데이터를 `SinkholeDetail` 형태로 보강해 우측 패널을 열고, 반경 내 관로 조회가 실패하더라도 상세 자체는 계속 보여주도록 방어 로직을 추가했다.
- 이후 추가 정리로, 목록 row에 좌표가 없고 `coordStatus/geocodeStatus`가 이미 실패 상태인 경우에는 상세 API를 아예 호출하지 않고 곧바로 fallback 상세 패널을 여는 쪽으로 단순화했다.
### 4.12 2026-06-09 연도 노드 지도범례 아이콘 표시
- 지반침하 `2026년`, `전체` 노드는 체크박스만 보이고 지도 범례 느낌이 약해, 하수처리시설처럼 노드 앞에 작은 아이콘을 두는 쪽이 더 직관적이라고 판단했다.
- `2026년`은 주황 원형, `전체`는 파랑 원형 SVG 아이콘을 `public/images/map/layericon/` 아래에 추가했고, DB 자식 노드와 FE fallback 노드 모두 같은 규칙으로 아이콘을 붙이도록 맞췄다.
- 공용 트리 렌더러는 기존에 `SN_SINKHOLE*` 전체를 아이콘 숨김 대상으로 보고 있었기 때문에, 지반침하 루트만 예외로 두고 연도 노드는 아이콘이 보이도록 분기 범위를 조정했다.
- 추가 확인 결과 공용 트리의 `makeLegendSrc()`가 `gobjTy`를 먼저 검사해 `iconNm`만 있는 노드를 바로 탈락시키고 있었고, 이 때문에 `icon_nm`을 넣어도 화면에 변화가 없었다.
- 그래서 공용 트리 렌더 순서를 `iconNm 우선 -> 없으면 gobjTy 기반 범례 생성`으로 바꿔, DB `icon_nm`과 FE 주입 `iconNm` 모두 실제로 보이게 맞췄다.
### 4.13 2026-06-09 연도 노드 색과 지도 포인트 색상 일치
- 연도 노드 아이콘이 보여도 아이콘 중앙의 흰 점 때문에 지도 포인트와 시각 톤이 달라 보여, SVG를 단색 원형 + 진한 외곽선 형태로 다시 정리했다.
- 지도 포인트도 같은 색 규칙을 쓰도록 `createSinkholePointStyle`을 연도 기반 variant 구조로 바꿨다.
- `전체` 선택 시에는 `2026년` 건만 주황, 나머지 연도는 파랑으로 보이고, 선택 상세 포인트와 반경 원도 같은 색 계열을 따르도록 맞췄다.
### 4.14 2026-06-09 포인트/아이콘 시각 농도 완화
- 실제 화면 확인 피드백 기준으로 지반침하 포인트가 너무 진해서 바탕지도를 가리고 있었고, 연도 노드 아이콘도 가운데가 빈 점처럼 보여 지도 포인트와 다르게 느껴졌다.
- 그래서 연도 아이콘 SVG는 외곽선과 중심 흰 점을 모두 제거한 꽉 찬 단색 점으로 단순화했다.
- 지도 포인트는 주황/파랑 모두 fill alpha와 stroke alpha를 낮추고 반경 원 투명도도 줄여, 기존보다 바탕지도가 더 잘 보이도록 완화했다.
### 4.15 2026-06-09 포인트 농도 재보정
- 완화 이후 실제 체감이 지나치게 옅어져 포인트 존재감이 약해졌기 때문에, 일반 포인트 fill/stroke alpha와 반지름을 한 단계 다시 올렸다.
- 목표는 “예전 지반침하정보처럼 자연스럽게 보이되 지도 바탕을 과도하게 가리지 않는 중간 농도”로 두었다.
### 4.17 2026-06-09 포인트 투명도 미세 상향
- 추가 피드백 기준으로는 아직도 포인트가 너무 연하게 느껴졌기 때문에, 일반 포인트 fill/stroke와 반경 원 alpha를 현재 값에서 절반 정도 더 보이는 수준으로 한 번 더 올렸다.
- 선택 포인트는 이미 충분히 강조되고 있어 선택 색은 유지하고, 일반 점과 반경 원의 가시성만 미세 조정했다.
### 4.18 2026-06-09 목록/다운로드 조회조건 정리
- 추가 요구사항으로 지반침하 목록에 `좌표보정상태` 조회조건을 넣고, 다운로드는 `좌표보정상태` 열 자체를 제외한 채 현재 조회조건 전체 결과를 받아야 한다는 기준이 확정됐다.
- 프론트는 기본 발생기간을 다시 `현재 기준 최근 1년`으로 되돌렸고, 팝업 조회영역에 `좌표보정상태` select를 추가했다.
- 다운로드는 현재 조회조건을 유지하되 `includeCoordStatus=false`로 목록 API를 재조회해, 좌표보정상태 컬럼과 좌표해석 비용을 함께 줄이는 방향으로 정리했다.
### 4.19 2026-06-09 팝업 닫힘 시 기본 최근 1년 복귀
- 기존에는 지반침하 팝업에서 마지막으로 조회한 조건이 `appliedSinkholeFilters`에 남아, 팝업을 닫아도 지도 레이어가 그 조건을 계속 유지하고 있었다.
- 추가 협의 기준으로는 팝업을 닫으면 다시 기본 최근 1년 조건으로 복귀하는 쪽이 더 자연스럽다고 보고, 팝업 close watcher를 붙였다.
- 이제 팝업이 닫히면 지도 지반침하 레이어 조회조건도 `buildSinkholeDefaultFilters()` 기준 최근 1년으로 즉시 되돌아간다.
### 4.20 2026-06-09 초기 지도 표출 기준을 팝업 조건으로 단일화
- 기존에는 `appliedSinkholeFilters`와 별도로 `2026년`/`전체` 연도 자식 노드 체크 상태가 `applySinkholeYearFilters()`로 다시 날짜조건을 덮어쓰고 있어, 초기 로드 시 최근 1년 기본조건과 충돌했다.
- 그래서 지도 레이어 조회와 팝업 초기 전달에는 연도 노드 덮어쓰기를 제거하고, 팝업 조회조건 자체를 유일한 필터 기준으로 정리했다.
- 결과적으로 초기 로드, 팝업 조회 후 지도 반영, 팝업 닫힘 후 기본 최근 1년 복귀가 같은 필터 흐름으로 맞춰졌다.
### 4.16 2026-06-09 연도 노드 아이콘 외곽선 재조정
- 연도 노드 아이콘은 단색 점만 두었을 때 경계가 약해 보여, 사용자가 보여준 예시처럼 `색 원 + 흰 외곽선` 형태가 더 읽기 쉽다고 판단했다.
- 그래서 `2026년` 주황 아이콘과 `전체` 파랑 아이콘 모두 흰 외곽선을 다시 추가해 트리 안에서 더 또렷하게 보이도록 맞췄다.
### 4.21 2026-06-09 조회옵션/다운로드 마감 정리
- `좌표보정상태` 조회조건은 최종적으로 `전체 / 성공 / 실패`만 남기고 `주소없음` 옵션은 제거했다.
- 다운로드 엑셀은 `좌표보정상태` 열을 제외한 9개 컬럼만 내리도록 유지했고, 헤더 범위도 `A1:I1`로 맞춰 첫 데이터 행이 헤더색을 먹지 않게 수정했다.
## 5. 승인 전 보류 사항
- 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="4.5" fill="#2563eb" stroke="#ffffff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="4.5" fill="#f59e0b" stroke="#ffffff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -442,10 +442,11 @@ export default React.memo(function GisTree({
* 레이어 정보로부터 범례 이미지 경로 또는 동적 생성 이미지를 반환합니다.
*/
const makeLegendSrc = (layerinfo) => {
if (!layerinfo.gobjTy) return null;
if (layerinfo.iconNm) {
return GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm;
} else {
}
if (!layerinfo.gobjTy) return null;
else {
return makeLegendImage(layerinfo).src;
}
};
@@ -542,8 +543,8 @@ export default React.memo(function GisTree({
(child) =>
child.parent === node.id && child.data.exprsYn === "Y"
);
const isSinkholeTreeNode =
String(node.id).includes("SN_SINKHOLE") ||
const isSinkholeRootTreeNode =
String(node.id) === "SN_SINKHOLE" ||
String(node?.data?.lyrNm ?? "").trim() === "지반침하정보";
const showLayerActionButton = layerIconVisible?.(node);
const showLayerActionIcon = depth > 0 && imageVisible;
@@ -566,7 +567,7 @@ export default React.memo(function GisTree({
title={isOpen ? "닫기" : "열기"}
/>
) : (
(isSinkholeTreeNode ? null : <span style={{ display: "inline-block", width: 16 }} />)
(isSinkholeRootTreeNode ? null : <span style={{ display: "inline-block", width: 16 }} />)
)}
<input
type="checkbox"
@@ -574,7 +575,7 @@ export default React.memo(function GisTree({
onChange={() => handleCheck(node.id)}
className="cursor-pointer"
/>
{!isSinkholeTreeNode && node.icon && <span>{node.icon}</span>}
{!isSinkholeRootTreeNode && node.icon && <span>{node.icon}</span>}
<span className="text-[1.3rem] font-medium text-label">
{node.text}
</span>