diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index 3626054c7..1b6542816 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -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() { + {downloadProgressText ? ( +
+ {downloadProgressText} +
+ ) : null} +
@@ -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)} > 조회 @@ -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} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts new file mode 100644 index 000000000..7230ccb3f --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts @@ -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, + }; +}; diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts index 62bccc416..e273d9c1c 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -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 = {}; + + 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 ?? ""); diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index f1d2590bc..53cf75946 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -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(null); return ( -
+
{isOpen ? ( -
- {ANALYSIS_POPUP_ITEMS.map((item) => ( - - ))} +
+
+
+ + 분석도구 +
+
+ +
+ {ANALYSIS_POPUP_ITEMS.map((item) => { + const Icon = item.icon; + const isActive = activeKey === item.key; + + return ( + + ); + })} +
) : null}
diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx index 200405c9a..c958d5caa 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -18,6 +18,7 @@ import { buildPipeAnalysisScope, normalizeNumberInput, resolveElapsedYearBucketLabel, + resolveMaterialKoreanName, resolveMaterialName, resolvePipeAnalysisTypeLabel, resolveRegionName, @@ -27,6 +28,19 @@ import { } from "./pipeAnalysisUtils"; type DetailViewRow = ReturnType[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) { diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts index 7eeaa934b..fddb1717c 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts @@ -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) => { diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 635f9e1b3..7618ec521 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -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(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 ( <> -
+
{layerLoadError ? (
레이어 설정 조회 실패: {layerLoadError}
) : null} - + {agedPipeDownloadProgressText ? ( +
+ {agedPipeDownloadProgressText} +
+ ) : null} + setIsSearchModalOpen(true)} + /> { setIsSearchModalOpen={setIsSearchModalOpen} onCheckedLayersChange={handleCheckedLayersChange} onOpenPopup={handleOpenLayerPopup} - searchBar={} + searchBar={ + + } 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 = () => {
)} +
); diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 08d2dfbe8..28c9f4500 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -101,6 +101,46 @@ export const postData = async (url: string, data: object): Promise => { } }; +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 => { const res = await axios.post(`${BASE_URL}/${url}`, data, { headers: DEFAULT_HEADERS, diff --git a/apps/sn/app/store/index.ts b/apps/sn/app/store/index.ts index 91577ae1a..7d761f91a 100644 --- a/apps/sn/app/store/index.ts +++ b/apps/sn/app/store/index.ts @@ -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')); diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index cf6411dd4..4b903d087 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -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({
)} - {showZoomLevel && } + {showZoomLevel && ( + + )}
- {isSearch && ( + {isSearch && showFloatingSearchButton && (
+ )} + {!showLayerActionButton && showLayerActionIcon && ( + 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}`} > @@ -619,7 +641,7 @@ export default React.memo(function GisTree({ {!visible && ( diff --git a/packages/common/business_components/src/ui/map/GisZoomLevel.js b/packages/common/business_components/src/ui/map/GisZoomLevel.js index 34d74f252..4409ece9c 100644 --- a/packages/common/business_components/src/ui/map/GisZoomLevel.js +++ b/packages/common/business_components/src/ui/map/GisZoomLevel.js @@ -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 ( -
+
+ {showZoomScaleInfo && ( +
+
+ 줌레벨 + {zoom.toFixed(1)} +
+
+ 축척 + {formatScaleValue(scale)} +
+
+ )}