Merge branch 'iipbs/sn/dev_kic' into iipbs/sn/dev

This commit is contained in:
kiminchan
2026-05-13 15:52:59 +09:00
14 changed files with 653 additions and 80 deletions
@@ -3,13 +3,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { UiTable } from "@common/business_components/ui";
import { getData } from "@/components/lib/api";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
AGED_PIPE_DEFAULT_FILTERS,
AGED_PIPE_EXCEL_HEADER,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_LIST_HEADLIST,
AGED_PIPE_PAGE_SIZE,
@@ -20,6 +19,7 @@ import {
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
import { downloadAgedPipeCsv } from "./agedPipeExport";
const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
@@ -40,6 +40,7 @@ export default function AgedPipePopupPage() {
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const [downloadProgressText, setDownloadProgressText] = useState("");
const sggOptions = useMemo(
() =>
@@ -122,7 +123,7 @@ export default function AgedPipePopupPage() {
return () => window.removeEventListener("message", handleFilterSyncMessage);
}, [loadRows]);
const handleSearch = (page = 1) => {
const handleSearch = async (page = 1) => {
if (!validateElapsedYears(Number(filters.elapsedYears))) {
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
return;
@@ -130,7 +131,7 @@ export default function AgedPipePopupPage() {
const normalized = normalizeAgedPipeFilters(filters);
setFilters(normalized);
loadRows(page, normalized);
await loadRows(page, normalized);
};
const handleRowClick = (index?: number) => {
@@ -156,17 +157,28 @@ export default function AgedPipePopupPage() {
try {
setIsLoading(true);
const response = await fetchRows(1, filters, 10000);
await downloadExcelWithKoreanHeaders({
data: response.rows as JSON[],
fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`,
sheetNameArray: ["노후관로 현황"],
headerRange: "A1:J1",
headerMapping: AGED_PIPE_EXCEL_HEADER,
setDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
await downloadAgedPipeCsv(filters, (job) => {
if (job.status === "QUEUED") {
setDownloadProgressText("CSV export 작업이 대기 중입니다.");
return;
}
if (job.status === "RUNNING") {
setDownloadProgressText(
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
});
setDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
setDownloadProgressText("");
alert(
error instanceof Error
? error.message
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
@@ -202,6 +214,12 @@ export default function AgedPipePopupPage() {
</div>
</header>
{downloadProgressText ? (
<div className="border-b border-slate-200 bg-emerald-50 px-5 py-2 text-[1rem] text-emerald-700">
{downloadProgressText}
</div>
) : null}
<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-[160px_320px_320px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[160px]">
@@ -221,7 +239,7 @@ export default function AgedPipePopupPage() {
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch(1);
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
@@ -270,7 +288,7 @@ export default function AgedPipePopupPage() {
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => handleSearch(1)}
onClick={() => void handleSearch(1)}
>
조회
</Button>
@@ -293,7 +311,7 @@ export default function AgedPipePopupPage() {
pageSize={10}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => handleSearch(page)}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
@@ -0,0 +1,42 @@
"use client";
import { postBlobData } from "@/components/lib/api";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import {
AGED_PIPE_EXCEL_FIELDS,
normalizeAgedPipeFilters,
type AgedPipeFilters,
} from "./agedPipeUtils";
type AgedPipeExportProgress = {
fileName?: string;
progressPercent?: number;
status: "QUEUED" | "RUNNING" | "COMPLETED";
};
/** 노후관로 CSV 다운로드를 요청한다. */
export const downloadAgedPipeCsv = async (
filters: AgedPipeFilters,
onProgress?: (progress: AgedPipeExportProgress) => void,
) => {
const normalizedFilters = normalizeAgedPipeFilters(filters);
onProgress?.({ status: "RUNNING", progressPercent: 0 });
const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", {
elapsedYears: normalizedFilters.elapsedYears,
ctpvCd: normalizedFilters.ctpvCd,
sggCd: normalizedFilters.sggCd,
format: "csv",
columns: [...AGED_PIPE_EXCEL_FIELDS],
});
const resolvedFileName = fileName || "노후관로_현황.csv";
fileDownload(blob, resolvedFileName);
onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 });
return {
status: "COMPLETED" as const,
fileName: resolvedFileName,
};
};
@@ -10,8 +10,9 @@ import {
export const AGED_PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
export const AGED_PIPE_DEFAULT_ELAPSED_YEARS = 30;
export const AGED_PIPE_PAGE_SIZE = 10;
export const AGED_PIPE_MIN_ZOOM = 14;
export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101", "19990101"];
export const AGED_PIPE_MIN_ZOOM = 13;
export const AGED_PIPE_GUIDE_MIN_ZOOM = 13.1;
export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101"];
export type AgedPipeFilters = {
elapsedYears: number;
@@ -46,6 +47,12 @@ export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = {
sggCd: "0",
};
const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => {
const digits = String(value ?? "").replace(/[^0-9]/g, "");
if (digits === "" || digits === "0") return "0";
return digits.length > prefixLength ? digits.slice(0, prefixLength) : digits;
};
export const AGED_PIPE_LIST_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 6 },
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 },
@@ -57,8 +64,61 @@ export const AGED_PIPE_LIST_HEADLIST = [
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
];
export const AGED_PIPE_EXCEL_FIELDS = [
"ftreSmbolCd",
"fcltMngNo",
"pbadmsEmdCd",
"mapshtNo",
"mngInstCd",
"instlYmd",
"usgCd",
"mqltCd",
"fcltyShpCd",
"pdmtVl",
"wdthLen",
"vrtcLen",
"prlgVl",
"tmptDpth",
"scendDpth",
"avgGrdn",
"trflnCnt",
"exclnMltvArea",
"drtwMltvArea",
"rnyssnFvlcVl",
"clarFvlcVl",
"tmptMhlMngNo",
"scendMhlMngNo",
"instMngNo",
"fid",
"lyrTypeNm",
"swtrmtFcltCd",
"elapsedYears",
"ctpvNm",
"sggNm",
] as const;
export const AGED_PIPE_EXCEL_HEADER = {
rowNumber: "NO",
ftreSmbolCd: "지형지물부호코드",
pbadmsEmdCd: "행정읍면동코드",
mapshtNo: "도엽번호",
mngInstCd: "관리기관코드",
wdthLen: "가로길이",
vrtcLen: "세로길이",
tmptDpth: "시점깊이",
scendDpth: "종점깊이",
avgGrdn: "평균구배",
trflnCnt: "차선수",
exclnMltvArea: "우수배수면적",
drtwMltvArea: "오수배수면적",
rnyssnFvlcVl: "우기유속값",
clarFvlcVl: "맑음유속값",
tmptMhlMngNo: "시점맨홀관리번호",
scendMhlMngNo: "종점맨홀관리번호",
instMngNo: "기관관리번호",
fid: "FID",
lyrTypeNm: "레이어타입명",
swtrmtFcltCd: "시설코드",
fcltyShpCd: "시설물형태코드",
fcltMngNo: "시설관리번호",
instlYmd: "설치일자",
pdmtVl: "관경(mm)",
@@ -70,6 +130,21 @@ export const AGED_PIPE_EXCEL_HEADER = {
sggNm: "시군구",
};
const getExcelColumnLetter = (columnIndex: number) => {
let current = columnIndex;
let result = "";
while (current > 0) {
const remainder = (current - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
current = Math.floor((current - 1) / 26);
}
return result || "A";
};
export const AGED_PIPE_EXCEL_HEADER_RANGE = `A1:${getExcelColumnLetter(AGED_PIPE_EXCEL_FIELDS.length)}1`;
const pad2 = (value: number) => String(value).padStart(2, "0");
const normalizeYmd = (value: unknown) =>
@@ -82,6 +157,8 @@ export const normalizeAgedPipeFilters = (filters: AgedPipeFilters): AgedPipeFilt
return {
...filters,
ctpvCd: normalizeRegionFilterCode(filters.ctpvCd, 2),
sggCd: normalizeRegionFilterCode(filters.sggCd, 5),
elapsedYears:
Number.isFinite(elapsedYears) && elapsedYears >= 1 && elapsedYears <= 100
? elapsedYears
@@ -224,6 +301,16 @@ export const toDisplayPipeRow = (
};
};
export const toAgedPipeExcelRow = (row: AgedPipeRow) => {
const exportRow: Record<string, unknown> = {};
AGED_PIPE_EXCEL_FIELDS.forEach((field) => {
exportRow[field] = row[field] ?? "-";
});
return exportRow;
};
export const isAgedPipeLayerNode = (node: any) => {
const data = node?.data ?? node;
const layerName = String(data?.lyrNm ?? node?.text ?? "");
@@ -1,50 +1,101 @@
"use client";
import { useState } from "react";
import {
ChartColumnBig,
ChevronLeft,
ChevronRight,
History,
PanelsTopLeft,
PencilRuler,
Search,
Settings2,
TriangleAlert,
Waves,
} from "lucide-react";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => void;
onOpenInfoSearch: () => void;
};
const ANALYSIS_POPUP_ITEMS: Array<{ key: WindowPopupKey; label: string }> = [
{ key: "pipeAnalysisPopup", label: "관로분석" },
{ key: "floodPredictionMapPopup", label: "침수예측지도" },
{ key: "floodHistoryPopup", label: "침수발생이력" },
{ key: "gisEditPopup", label: "GIS 편집" },
{ key: "gisManagementPopup", label: "GIS 관리" },
{ key: "flowRiskPopup", label: "통수 위험" },
const ANALYSIS_POPUP_ITEMS: Array<{
key: WindowPopupKey | "infoSearch";
label: string;
icon: typeof ChartColumnBig;
}> = [
{ key: "infoSearch", label: "정보검색", icon: Search },
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
{ key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves },
{ key: "floodHistoryPopup", label: "침수발생이력", icon: History },
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
];
export default function AnalysisPopupMenu({ onOpenPopup }: Props) {
export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) {
const [isOpen, setIsOpen] = useState(true);
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(null);
return (
<div className="absolute right-[0.35rem] top-[53%] z-[70]">
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
{isOpen ? (
<div className="absolute right-[21px] top-1/2 flex w-[86px] -translate-y-1/2 flex-col gap-[3px]">
{ANALYSIS_POPUP_ITEMS.map((item) => (
<button
key={item.key}
type="button"
className="min-h-[40px] rounded-md border border-primary bg-primary px-2 py-1 text-center text-[1rem] font-semibold leading-[1.2] text-white shadow transition hover:brightness-95"
onClick={() => onOpenPopup(item.key)}
>
<span className="break-keep whitespace-normal">{item.label}</span>
</button>
))}
<div className="absolute right-[28px] w-[132px] overflow-hidden rounded-[6px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] items-center gap-2 border-b border-[#e5eaf1] bg-[#f8fafc] px-[10px] text-[13px] font-bold text-[#1f2937]">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-[#1e88e5]" />
<span>분석도구</span>
</div>
</div>
<div className="flex flex-col gap-1 p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeKey === item.key;
return (
<button
key={item.key}
type="button"
className={`relative flex h-[34px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
isActive
? "border-[#b9dcff] bg-[#eaf4ff] font-bold text-[#0f5fa8] before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-[#1e88e5]"
: "border-transparent bg-white text-[#374151] hover:border-[#cfe5ff] hover:bg-[#eef6ff] hover:text-[#1e3a5f]"
}`}
onClick={() => {
setActiveKey(item.key);
if (item.key === "infoSearch") {
onOpenInfoSearch();
return;
}
onOpenPopup(item.key);
}}
>
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-[#1e88e5]" : "text-[#64748b]"
}`}
/>
<span className="break-keep whitespace-normal">{item.label}</span>
</button>
);
})}
</div>
</div>
) : null}
<button
type="button"
className="flex h-[36px] w-[18px] items-center justify-center rounded-sm border border-[#2f5c9b] bg-white text-[0.9rem] font-bold text-[#2f5c9b] shadow transition hover:bg-slate-50"
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] text-[#64748b] shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-[#eaf4ff] hover:text-[#1e88e5]"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
>
{isOpen ? ">" : "<"}
{isOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div>
</div>
@@ -18,6 +18,7 @@ import {
buildPipeAnalysisScope,
normalizeNumberInput,
resolveElapsedYearBucketLabel,
resolveMaterialKoreanName,
resolveMaterialName,
resolvePipeAnalysisTypeLabel,
resolveRegionName,
@@ -27,6 +28,19 @@ import {
} from "./pipeAnalysisUtils";
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"rowNo",
"ctpvNm",
"sggNm",
"fcltMngNo",
"instMngNo",
"swtrmtZoneNm",
"instlYmd",
"useNm",
"pdmtVl",
"prlgVl",
"materialName",
] as const;
export default function PipeAnalysisDetailPopupPage() {
const searchParams = useSearchParams();
@@ -151,11 +165,25 @@ export default function PipeAnalysisDetailPopupPage() {
try {
setIsLoading(true);
const response = await fetchRows(1, filters, Math.max(total, 1000));
const exportRows = response.rows.map((row) => ({
rowNo: row.rowNo,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
fcltMngNo: row.fcltMngNo,
instMngNo: row.instMngNo,
swtrmtZoneNm: row.swtrmtZoneNm,
instlYmd: row.instlYmd,
useNm: row.useNm,
pdmtVl: row.pdmtVl,
prlgVl: row.prlgVl,
materialName: resolveMaterialKoreanName(row.materialCode) || row.materialName,
}));
const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`;
await downloadExcelWithKoreanHeaders({
data: response.rows as unknown as JSON[],
data: exportRows as unknown as JSON[],
fileName: `관로분석_관로현황_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로현황"],
headerRange: "A1:K1",
headerRange: detailHeaderRange,
headerMapping: PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
});
} catch (error) {
@@ -519,17 +519,69 @@ export const resolveMaterialName = (materialCode?: string) => {
return materialMap.get(code) ?? code;
};
/**
* 관재질 라벨에서 영문 약어/코드를 걷어내고 엑셀 헤더용 한글명을 우선 추출한다.
* - `염화비닐관(PVC)` -> `염화비닐관`
* - `PCF관(강판보강 PE 나선형관)` -> `강판보강 나선형관`
*/
const toKoreanMaterialLabel = (value?: string) => {
const normalized = String(value ?? "").trim();
if (!normalized) return "";
const withoutParenthesis = normalized
.replace(/\s*\([^)]*\)\s*/g, " ")
.replace(/\s+/g, " ")
.trim();
const withoutAsciiToken = withoutParenthesis
.replace(/[A-Za-z0-9-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (/[가-힣]/.test(withoutAsciiToken)) {
return withoutAsciiToken;
}
if (/[가-힣]/.test(withoutParenthesis)) {
return withoutParenthesis;
}
const parenthesisMatches = Array.from(normalized.matchAll(/\(([^)]*)\)/g))
.map((match) => String(match[1] ?? "").trim())
.filter(Boolean);
const koreanParenthesisText = parenthesisMatches.find((text) => /[가-힣]/.test(text));
if (koreanParenthesisText) {
const simplifiedParenthesisText = koreanParenthesisText
.replace(/[A-Za-z0-9-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return simplifiedParenthesisText || koreanParenthesisText;
}
return normalized;
};
export const resolveMaterialColumnName = (column: PipeAnalysisMaterialColumn) => {
const rawName = String(column.materialName ?? "").trim();
const rawCode = String(column.materialCode ?? "").trim();
const rawKey = String(column.key ?? "").trim();
if (!rawName) {
return resolveMaterialName(rawCode || rawKey);
}
if (rawName === rawCode || rawName === rawKey) {
return resolveMaterialName(rawCode || rawKey);
}
return rawName;
const mappedName = resolveMaterialName(rawCode || rawKey);
const candidates = [
toKoreanMaterialLabel(mappedName),
toKoreanMaterialLabel(rawName),
mappedName,
rawName,
rawCode,
rawKey,
].filter(Boolean);
return candidates[0] ?? "-";
};
export const resolveMaterialKoreanName = (materialCode?: string) => {
const resolved = resolveMaterialName(materialCode);
const koreanLabel = toKoreanMaterialLabel(resolved);
return koreanLabel || resolved;
};
export const resolveElapsedYearBucketLabel = (bucket?: string) => {
+82 -14
View File
@@ -36,11 +36,9 @@ import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
import { Button } from "@common/components";
import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
import type { PipeAnalysisDetailRow } from "./components/pipeAnalysis/pipeAnalysisUtils";
import {
AGED_PIPE_EXCEL_HEADER,
AGED_PIPE_MIN_ZOOM,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_PAGE_SIZE,
@@ -53,7 +51,9 @@ import {
type AgedPipeFilters,
type AgedPipeRow,
AGED_PIPE_DEFAULT_FILTERS,
AGED_PIPE_GUIDE_MIN_ZOOM,
} from "./components/agedPipe/agedPipeUtils";
import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport";
import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE } from "./components/pipeAnalysis/pipeAnalysisUtils";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
@@ -528,6 +528,7 @@ const Map = () => {
const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0);
const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1);
const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false);
const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState("");
const { ctpv, sgg } = useStore();
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef<Window | null>(null);
@@ -1018,21 +1019,32 @@ const Map = () => {
try {
setIsAgedPipeListLoading(true);
const response = await fetchAgedPipeRows(1, appliedAgedPipeFilters, 10000);
await downloadExcelWithKoreanHeaders({
data: response.rows as JSON[],
fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`,
sheetNameArray: ["노후관로 현황"],
headerRange: "A1:J1",
headerMapping: AGED_PIPE_EXCEL_HEADER,
setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => {
if (job.status === "QUEUED") {
setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다.");
return;
}
if (job.status === "RUNNING") {
setAgedPipeDownloadProgressText(
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
});
setAgedPipeDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
setAgedPipeDownloadProgressText("");
alert(
error instanceof Error
? error.message
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsAgedPipeListLoading(false);
}
}, [agedPipeTotalCount, appliedAgedPipeFilters, fetchAgedPipeRows]);
}, [agedPipeTotalCount, appliedAgedPipeFilters]);
useEffect(() => {
const handleAgedPipePopupMessage = (event: MessageEvent) => {
@@ -1224,13 +1236,21 @@ const Map = () => {
return (
<>
<div className="bg-container relative h-[calc(100dvh-5rem)] overflow-hidden">
<div className="sn-map-control-layout bg-container relative h-[calc(100dvh-5rem)] overflow-hidden">
{layerLoadError ? (
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-white px-4 py-3 text-sm text-red-700 shadow">
레이어 설정 조회 실패: {layerLoadError}
</div>
) : null}
<AnalysisPopupMenu onOpenPopup={openAnalysisPopup} />
{agedPipeDownloadProgressText ? (
<div className="absolute left-4 top-20 z-[60] max-w-[520px] rounded border border-emerald-200 bg-white px-4 py-3 text-sm text-emerald-700 shadow">
{agedPipeDownloadProgressText}
</div>
) : null}
<AnalysisPopupMenu
onOpenPopup={openAnalysisPopup}
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
/>
<Gis
treeData={treeData}
isSearch={true}
@@ -1247,7 +1267,13 @@ const Map = () => {
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={handleCheckedLayersChange}
onOpenPopup={handleOpenLayerPopup}
searchBar={<ProcSearchBar gisMaps={gisMaps} facilityTypes={facilityTypes} />}
searchBar={
<ProcSearchBar
gisMaps={gisMaps}
facilityTypes={facilityTypes}
sidoSearchZoom={isAgedPipeChecked ? AGED_PIPE_GUIDE_MIN_ZOOM : undefined}
/>
}
waterDepth={showWaterDepth ? WaterDepth : undefined}
inlineLayerSettingView={inlineLayerSettingView}
layerIconVisible={isAgedPipeLayerNode}
@@ -1257,6 +1283,17 @@ const Map = () => {
checkInterval={2500}
checkedLayerChangeDelay={0}
showZoomLevel={true}
showZoomScaleInfo={true}
showFloatingSearchButton={false}
treeTogglePosition="top"
zoomGuide={
isAgedPipeChecked
? {
label: "노후관로 기준",
zoom: AGED_PIPE_GUIDE_MIN_ZOOM,
}
: undefined
}
defaultZoom={7.5}
defaultLonLat={[127.75, 36]}
@@ -1293,6 +1330,37 @@ const Map = () => {
</Button>
</div>
)}
<style jsx global>{`
.sn-map-control-layout .mapbtn {
top: auto;
right: 15px;
bottom: 451px;
}
.sn-map-control-layout .custom-toolbar {
position: static;
}
.sn-map-control-layout .tool-bar {
top: auto;
right: 15px;
bottom: 248px;
}
.sn-map-control-layout .zoom-level-control {
right: 16px;
bottom: 56px;
}
.sn-map-control-layout .zoom-level-summary {
right: 56px;
bottom: -15px;
}
.sn-map-control-layout .ol-scale-line {
display: none;
}
`}</style>
</div>
</>
);
+40
View File
@@ -101,6 +101,46 @@ export const postData = async (url: string, data: object): Promise<any> => {
}
};
const parseDownloadFileName = (contentDisposition: string | null) => {
if (!contentDisposition) return undefined;
const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1].trim());
}
const plainMatch = contentDisposition.match(/filename\s*=\s*"?([^\";]+)"?/i);
return plainMatch?.[1]?.trim();
};
export const postBlobData = async (
url: string,
data: object,
): Promise<{ blob: Blob; fileName?: string }> => {
const res = await fetch(resolveApiUrl(url), {
method: "POST",
headers: withSiteIdHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(data),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`,
);
}
return {
blob: await res.blob(),
fileName: parseDownloadFileName(res.headers.get("content-disposition")),
};
};
export const postMutiPart = async (url: string, data: FormData): Promise<any> => {
const res = await axios.post(`${BASE_URL}/${url}`, data, {
headers: DEFAULT_HEADERS,
+4 -4
View File
@@ -33,8 +33,8 @@ export const fetchCtpv = async (set: (state: any) => void) => {
const list = res.features
.map((f: any) => ({
text: f.properties.mctpv_korn_nm,
val: f.properties.mctpv_cd,
text: String(f.properties.mctpv_korn_nm ?? "").trim(),
val: String(f.properties.mctpv_cd ?? "").trim(),
}))
.sort((a:any, b:any) => {
// 커스텀 순서 정의
@@ -83,8 +83,8 @@ export const fetchSgg = async (set: (state: any) => void) => {
const list = res.features
.map((f: any) => ({
text: f.properties.sgg_korn_nm,
val: f.properties.sgg_cd,
text: String(f.properties.sgg_korn_nm ?? "").trim(),
val: String(f.properties.sgg_cd ?? "").trim(),
}))
.sort((a:any, b:any) => a.text.localeCompare(b.text, 'ko'));
@@ -70,6 +70,10 @@ function Gis({
viewExtent,
viewMinZoom,
showZoomLevel = false,
zoomGuide,
showFloatingSearchButton = true,
treeTogglePosition = "center",
showZoomScaleInfo = false,
}) {
const mapRef = useRef();
const [mapObject, setMapObject] = useState({});
@@ -311,6 +315,7 @@ function Gis({
onProgressChange={handleProgressChange}
layerSettingView={LayerSettingView}
inlineLayerSettingView={inlineLayerSettingView}
toggleButtonPosition={treeTogglePosition}
/>
{typeof WaterDepth === "function" && (
@@ -345,13 +350,15 @@ function Gis({
</div>
)}
<GisToolBar />
{showZoomLevel && <GisZoomLevel />}
{showZoomLevel && (
<GisZoomLevel zoomGuide={zoomGuide} showZoomScaleInfo={showZoomScaleInfo} />
)}
<GisMapToggle />
<GisSelect onFeatureSelect={handleFeatureSelect} />
</div>
</div>
{isSearch && (
{isSearch && showFloatingSearchButton && (
<div className="absolute right-8 top-[248px] z-20">
<button
onClick={() => setIsSearchModalOpen(true)}
@@ -46,6 +46,7 @@ export default React.memo(function GisTree({
layerSettingView: LayerSettingView = null,
layerIconVisible,
inlineLayerSettingView,
toggleButtonPosition = "center",
}) {
const { gisCore, treeData } = useGisContext();
@@ -510,6 +511,9 @@ export default React.memo(function GisTree({
if (!treeData || treeData.length === 0) return null;
const toggleButtonPositionClass =
toggleButtonPosition === "top" ? "top-[5.5rem]" : "top-1/2 -translate-y-1/2";
return (
<>
{/* 사이드 패널 */}
@@ -538,6 +542,8 @@ export default React.memo(function GisTree({
(child) =>
child.parent === node.id && child.data.exprsYn === "Y"
);
const showLayerActionButton = layerIconVisible?.(node);
const showLayerActionIcon = depth > 0 && imageVisible;
const inlineLayerSetting = inlineLayerSettingView?.(node);
@@ -569,8 +575,24 @@ export default React.memo(function GisTree({
<span className="text-[1.3rem] font-medium text-label">
{node.text}
</span>
{((depth > 0 && imageVisible) || layerIconVisible?.(node)) && (
<span>
{showLayerActionButton && (
<button
type="button"
className="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]"
onClick={() =>
handleLayerImgClick(
node.data.lyrTyNm,
node.id,
node
)
}
title={`${node.text} 목록보기`}
>
목록보기
</button>
)}
{!showLayerActionButton && showLayerActionIcon && (
<span className="shrink-0">
<img
className="h-5 w-5 cursor-pointer"
src="/images/map/layericon/Layer_Select_BTN.png"
@@ -611,7 +633,7 @@ export default React.memo(function GisTree({
)}
<button
onClick={() => setVisible(false)}
className="absolute right-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm"
className={`absolute right-[-3px] z-50 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
>
<ChevronLeft size={16} />
</button>
@@ -619,7 +641,7 @@ export default React.memo(function GisTree({
{!visible && (
<button
onClick={() => setVisible(true)}
className="absolute left-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm"
className={`absolute left-[-3px] z-50 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
>
<ChevronRight size={16} />
</button>
@@ -1,13 +1,22 @@
import React, { useEffect, useRef, useState } from 'react';
import { useGisContext } from './Gis';
export default React.memo(function GisZoomLevel() {
const DPI = 25.4 / 0.28;
const INCHES_PER_METER = 39.37;
const formatScaleValue = scale => {
if (!Number.isFinite(scale) || scale <= 0) return '-';
return `1:${Math.round(scale).toLocaleString()}`;
};
export default React.memo(function GisZoomLevel({ zoomGuide, showZoomScaleInfo = false }) {
const { gisMap } = useGisContext();
const trackRef = useRef(null);
const [zoomInfo, setZoomInfo] = useState({
zoom: 0,
minZoom: 0,
maxZoom: 0,
scale: 0,
});
useEffect(() => {
@@ -17,10 +26,16 @@ export default React.memo(function GisZoomLevel() {
if (!view) return undefined;
const updateZoomInfo = () => {
const resolution = view.getResolution?.() ?? 0;
const projection = view.getProjection?.();
const metersPerUnit = projection?.getMetersPerUnit?.() ?? 1;
const scale = resolution * metersPerUnit * INCHES_PER_METER * DPI;
setZoomInfo({
zoom: view.getZoom() ?? 0,
minZoom: view.getMinZoom?.() ?? 0,
maxZoom: view.getMaxZoom?.() ?? 20,
scale,
});
};
@@ -34,11 +49,30 @@ export default React.memo(function GisZoomLevel() {
if (!gisMap) return <></>;
const { zoom, minZoom, maxZoom } = zoomInfo;
const { zoom, minZoom, maxZoom, scale } = zoomInfo;
const range = Math.max(maxZoom - minZoom, 1);
const normalized = Math.min(Math.max((zoom - minZoom) / range, 0), 1);
const fillHeight = `${normalized * 100}%`;
const thumbBottom = `calc(${fillHeight} - 1px)`;
const guideZoom = Number(zoomGuide?.zoom);
const hasGuide =
Boolean(zoomGuide?.label) &&
Number.isFinite(guideZoom) &&
zoomGuide?.visible !== false;
const guideNormalized = hasGuide
? Math.min(Math.max((guideZoom - minZoom) / range, 0), 1)
: 0;
const guideBottom = `calc(${guideNormalized * 100}% - 1px)`;
const isGuideActive = hasGuide ? zoom >= guideZoom : false;
const titleParts = [`현재 줌 레벨 ${zoom.toFixed(1)}`];
if (hasGuide) {
titleParts.push(
`${zoomGuide.label} 줌 ${guideZoom}${isGuideActive ? ' 이상' : ' 미만'}`,
);
}
const controlTitle = titleParts.join(' / ');
const updateZoomFromClientY = clientY => {
if (!gisMap || !trackRef.current) return;
@@ -89,7 +123,22 @@ export default React.memo(function GisZoomLevel() {
};
return (
<div className="zoom-level-control" title={`현재 줌 레벨 ${zoom.toFixed(1)}`}>
<div
className={`zoom-level-control ${hasGuide ? 'has-zoom-guide' : ''}`}
title={controlTitle}
>
{showZoomScaleInfo && (
<div className="zoom-level-summary" aria-live="polite">
<div className="zoom-level-summary-row">
<span className="zoom-level-summary-label">줌레벨</span>
<span className="zoom-level-summary-value">{zoom.toFixed(1)}</span>
</div>
<div className="zoom-level-summary-row">
<span className="zoom-level-summary-label">축척</span>
<span className="zoom-level-summary-value">{formatScaleValue(scale)}</span>
</div>
</div>
)}
<div className="zoom-level-panel">
<button type="button" className="zoom-level-btn" onClick={zoomIn} aria-label="확대">
+
@@ -100,6 +149,16 @@ export default React.memo(function GisZoomLevel() {
className="zoom-level-track"
onPointerDown={handleTrackPointerDown}
>
{hasGuide && (
<div
className={`zoom-level-guide ${isGuideActive ? 'is-active' : 'is-inactive'}`}
style={{ bottom: guideBottom }}
aria-hidden="true"
>
<span className="zoom-level-guide-label">{zoomGuide.label}</span>
<span className="zoom-level-guide-dot" />
</div>
)}
<div className="zoom-level-fill" style={{ height: fillHeight }} />
<div className="zoom-level-thumb" style={{ bottom: thumbBottom }} />
</div>
@@ -180,8 +180,44 @@
z-index: 2;
}
.zoom-level-summary {
position: absolute;
right: 56px;
bottom: 6px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 126px;
padding: 7px 10px;
border: 1px solid #9aa7b8;
border-radius: 6px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 0 5px #999;
box-sizing: border-box;
}
.zoom-level-summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 11px;
line-height: 1.2;
color: #334155;
}
.zoom-level-summary-label {
font-weight: 700;
color: #1d4f91;
}
.zoom-level-summary-value {
font-weight: 600;
color: #334155;
}
.zoom-level-panel {
overflow: hidden;
overflow: visible;
width: 40px;
background: #fff;
border: 1px solid #9aa7b8;
@@ -217,12 +253,14 @@
}
.zoom-level-gauge {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 128px;
background: #fff;
overflow: visible;
}
.zoom-level-track {
@@ -232,6 +270,7 @@
border-radius: 6px;
cursor: ns-resize;
touch-action: none;
overflow: visible;
background:
linear-gradient(to top, #b8b8b8, #b8b8b8) center / 4px 100% no-repeat,
repeating-linear-gradient(
@@ -267,6 +306,61 @@
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}
.zoom-level-guide {
position: absolute;
left: 50%;
display: inline-flex;
align-items: center;
gap: 6px;
transform: translate(calc(-100% - 10px), 50%);
pointer-events: none;
white-space: nowrap;
}
.zoom-level-guide::after {
content: "";
position: absolute;
top: 50%;
right: -10px;
width: 10px;
height: 2px;
background: currentColor;
transform: translateY(-50%);
opacity: 0.9;
}
.zoom-level-guide-label {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border: 1px solid currentColor;
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.12);
font-size: 11px;
font-weight: 700;
line-height: 1;
color: inherit;
}
.zoom-level-guide-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 999px;
background: currentColor;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.94);
}
.zoom-level-guide.is-active {
color: #d9363e;
}
.zoom-level-guide.is-inactive {
color: #9ca3af;
}
.ol-scale-line {
background: rgba(0, 60, 136, 0.3);
border-radius: 4px;
@@ -16,13 +16,18 @@ const FACILITY_TYPE_URL_MAP: Record<string, string> = {
interface SearchBarProps {
gisMaps: React.RefObject<any>;
facilityTypes: { text: string; val: string }[];
sidoSearchZoom?: number;
}
interface CenterSelectDataType extends SelectDataType {
center?: [number, number]; // 좌표 중심 위치
}
export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
export const ProcSearchBar = ({
gisMaps,
facilityTypes,
sidoSearchZoom = 11,
}: SearchBarProps) => {
const [sidoValue, setSidoValue] = useState("0");
const [sigunValue, setSigunValue] = useState("0");
const [sidoData, setSidoData] = useState<CenterSelectDataType[]>([
@@ -271,8 +276,8 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
}
}
moveMapToCenter(gisMaps, selectedSido.center, 11);
}, [gisMaps, sidoValue, sigunValue, sidoData, filteredSigunData, sigunData]);
moveMapToCenter(gisMaps, selectedSido.center, sidoSearchZoom);
}, [gisMaps, sidoValue, sigunValue, sidoData, filteredSigunData, sigunData, sidoSearchZoom]);
const handleFeatureFocus = (data: any) => {
const map = gisMaps.current;