지반침하 팝업 조회조건, 다운로드, 지도표출 방식 정리
This commit is contained in:
@@ -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: "복구완료일자",
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user