From 446bd83210879ffd6051d86009fc1ad7902bec4d Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 12 May 2026 10:06:17 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20-=20=EC=84=9C=EC=9A=B8,=20=EB=B6=80?= =?UTF-8?q?=EC=82=B0=20=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/components/agedPipe/agedPipeUtils.ts | 10 +++++++++- apps/sn/app/store/index.ts | 8 ++++---- 2 files changed, 13 insertions(+), 5 deletions(-) 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..588dc2a5a 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -11,7 +11,7 @@ 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_INVALID_INSTALL_YMD_PREFIXES = ["19000101"]; export type AgedPipeFilters = { elapsedYears: number; @@ -46,6 +46,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 }, @@ -82,6 +88,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 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')); From 0b441494ba0a2334983c145bf6b1d13cc9586cc1 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 12 May 2026 16:29:12 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20-=20=EC=97=91=EC=85=80=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agedPipe/AgedPipePopupPage.tsx | 38 ++++++--- .../map/components/agedPipe/agedPipeExport.ts | 42 ++++++++++ .../map/components/agedPipe/agedPipeUtils.ts | 80 ++++++++++++++++++- apps/sn/app/(map-layout)/map/page.tsx | 38 ++++++--- apps/sn/app/components/lib/api/index.ts | 40 ++++++++++ 5 files changed, 216 insertions(+), 22 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts 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..d7fe9dc1f 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( () => @@ -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} +
) : null} + {agedPipeDownloadProgressText ? ( +
+ {agedPipeDownloadProgressText} +
+ ) : null} => { } }; +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, From cc24a16c77f45e9bec84699e400899e85f81261f Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 12 May 2026 16:39:36 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20-=20=EB=AA=A9=EB=A1=9D=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business_components/src/ui/map/GisTree.js | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index 38077c00a..549f5905a 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -538,6 +538,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 +571,24 @@ export default React.memo(function GisTree({ {node.text} - {((depth > 0 && imageVisible) || layerIconVisible?.(node)) && ( - + {showLayerActionButton && ( + + )} + {!showLayerActionButton && showLayerActionIcon && ( + Date: Tue, 12 May 2026 17:32:24 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EA=B4=80=EB=A1=9C=20=EB=B6=84=EC=84=9D=20-?= =?UTF-8?q?=20=EC=97=91=EC=84=B8=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=EB=A7=B5=ED=95=91=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PipeAnalysisDetailPopupPage.tsx | 32 ++++++++- .../pipeAnalysis/pipeAnalysisUtils.ts | 66 +++++++++++++++++-- 2 files changed, 89 insertions(+), 9 deletions(-) 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) => { From 9604e4591bef18bb97decce635985ce86af6c571 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Wed, 13 May 2026 11:11:54 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20-=20=EC=8B=9C=EB=8F=84=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=8B=9C=20=EC=A7=80=EB=8F=84=20=EC=A4=8C=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agedPipe/AgedPipePopupPage.tsx | 21 +++-- .../map/components/agedPipe/agedPipeUtils.ts | 2 +- apps/sn/app/(map-layout)/map/page.tsx | 78 ++++++++++++++++++- 3 files changed, 94 insertions(+), 7 deletions(-) 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 d7fe9dc1f..a1c0c8d24 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -24,6 +24,7 @@ import { downloadAgedPipeCsv } from "./agedPipeExport"; const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; +const POPUP_CTPV_FOCUS_MESSAGE = "SN_AGED_PIPE_CTPV_FOCUS"; export default function AgedPipePopupPage() { const searchParams = useSearchParams(); @@ -123,7 +124,7 @@ export default function AgedPipePopupPage() { return () => window.removeEventListener("message", handleFilterSyncMessage); }, [loadRows]); - const handleSearch = (page = 1) => { + const handleSearch = async (page = 1, shouldFocusCtpv = false) => { if (!validateElapsedYears(Number(filters.elapsedYears))) { alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); return; @@ -131,7 +132,17 @@ export default function AgedPipePopupPage() { const normalized = normalizeAgedPipeFilters(filters); setFilters(normalized); - loadRows(page, normalized); + await loadRows(page, normalized); + + if (shouldFocusCtpv && normalized.ctpvCd !== "0") { + window.opener?.postMessage( + { + type: POPUP_CTPV_FOCUS_MESSAGE, + ctpvCd: normalized.ctpvCd, + }, + window.location.origin, + ); + } }; const handleRowClick = (index?: number) => { @@ -239,7 +250,7 @@ export default function AgedPipePopupPage() { }); }} onKeyDown={(event) => { - if (event.key === "Enter") handleSearch(1); + if (event.key === "Enter") void handleSearch(1, true); }} /> @@ -288,7 +299,7 @@ export default function AgedPipePopupPage() { size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" - onClick={() => handleSearch(1)} + onClick={() => void handleSearch(1, true)} > 조회 @@ -311,7 +322,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/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts index bfe8d5023..2c51616b5 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -10,7 +10,7 @@ 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_MIN_ZOOM = 13; export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101"]; export type AgedPipeFilters = { diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 7e9450b90..9eb592abc 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -68,6 +68,10 @@ const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT"; const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; +// 노후관로 팝업에서 조회 버튼 클릭 시, 부모 지도를 선택 시도 범위로 이동시키는 메시지이다. +const AGED_PIPE_CTPV_FOCUS_MESSAGE = "SN_AGED_PIPE_CTPV_FOCUS"; +// 시도 기준 자동 이동은 조회 직후 노후관로가 바로 보이는 수준까지 한 단계 더 당겨 고정한다. +const AGED_PIPE_CTPV_FOCUS_ZOOM = 14; const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [ 720000, 1445000, @@ -952,6 +956,72 @@ const Map = () => { } }, [draftAgedPipeFilters, isAgedPipeListOpen, loadAgedPipeList, syncAgedPipePopupFilters]); + // 노후관로 팝업에서 시도 기준 조회를 실행하면, 부모 지도창을 해당 시도 extent로 이동시킨다. + const focusMapToAgedPipeCtpv = useCallback(async (ctpvCd: string) => { + const normalizedCtpvCd = String(ctpvCd ?? "").replace(/[^0-9]/g, "").slice(0, 2); + if (!normalizedCtpvCd || normalizedCtpvCd === "0") return; + + const map = gisMaps.current; + if (!map) return; + + try { + const params = new URLSearchParams({ + service: "WFS", + version: "1.0.0", + request: "GetFeature", + typeName: "hasudo:tb_ms_ctpv", + outputFormat: "application/json", + CQL_FILTER: `mctpv_cd='${normalizedCtpvCd}'`, + }); + const payload = await getRawJson(`/geoserver/hasudo/ows?${params.toString()}`); + // 시도 WFS는 환경마다 3857/4326 등 다른 CRS로 내려올 수 있으므로 응답 crs를 우선 신뢰한다. + const responseCrsName = String(payload?.crs?.properties?.name ?? ""); + const resolvedDataProjection = responseCrsName.includes("EPSG::") + ? `EPSG:${responseCrsName.split("EPSG::")[1]}` + : responseCrsName.includes("EPSG:") + ? responseCrsName.slice(responseCrsName.lastIndexOf("EPSG:")) + : GisApp.FROM_PROJ; + const features = new GeoJSON().readFeatures(payload, { + dataProjection: resolvedDataProjection, + featureProjection: map.getView().getProjection(), + }); + + let extent: [number, number, number, number] | null = null; + features.forEach((feature) => { + const geometry = feature.getGeometry(); + if (!geometry) return; + const geometryExtent = geometry.getExtent(); + if (!extent) { + extent = [...geometryExtent] as [number, number, number, number]; + return; + } + extent = [ + Math.min(extent[0], geometryExtent[0]), + Math.min(extent[1], geometryExtent[1]), + Math.max(extent[2], geometryExtent[2]), + Math.max(extent[3], geometryExtent[3]), + ]; + }); + + if (!extent) return; + + const view = map.getView(); + view.fit(extent, { + padding: AGED_PIPE_FOCUS_PADDING, + maxZoom: 18, + constrainResolution: true, + nearest: true, + }); + + const fittedZoom = view.getZoom() ?? 0; + if (fittedZoom < AGED_PIPE_CTPV_FOCUS_ZOOM) { + view.setZoom(AGED_PIPE_CTPV_FOCUS_ZOOM); + } + } catch (error) { + console.error("노후관로 시도 이동 중 오류:", error); + } + }, []); + const openAgedPipePopupWindow = useCallback(() => { const popup = openWindowPopup("agedPipePopup", { elapsedYears: String(appliedAgedPipeFilters.elapsedYears), @@ -1067,6 +1137,12 @@ const Map = () => { return; } + if (event.data?.type === AGED_PIPE_CTPV_FOCUS_MESSAGE) { + if (!event.data?.ctpvCd) return; + void focusMapToAgedPipeCtpv(String(event.data.ctpvCd)); + return; + } + if (event.data?.type === PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE) { if (!event.data?.row) return; handlePipeAnalysisRowSelect(event.data.row as PipeAnalysisDetailRow); @@ -1075,7 +1151,7 @@ const Map = () => { window.addEventListener("message", handleAgedPipePopupMessage); return () => window.removeEventListener("message", handleAgedPipePopupMessage); - }, [agedPipeTotalCount, handleAgedPipeRowDoubleClick, handlePipeAnalysisRowSelect]); + }, [agedPipeTotalCount, focusMapToAgedPipeCtpv, handleAgedPipeRowDoubleClick, handlePipeAnalysisRowSelect]); // WaterDepth 표시 여부를 결정하는 함수 const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => { From c12169871359b1b4b40a694001b75c2efdf98d8a Mon Sep 17 00:00:00 2001 From: kiminchan Date: Wed, 13 May 2026 15:48:38 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=EB=85=B8=ED=9B=84=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20-=20=ED=91=9C=EC=8B=9C=20=EC=A4=91=20UX=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=8C=9D=EC=97=85=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=9D=B4=EB=8F=99=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agedPipe/AgedPipePopupPage.tsx | 17 +-- .../map/components/agedPipe/agedPipeUtils.ts | 1 + apps/sn/app/(map-layout)/map/page.tsx | 136 ++++++++---------- .../src/ui/map/GisZoomLevel.js | 65 ++++++++- .../src/ui/map/lib/css/gis.common.css | 96 ++++++++++++- .../src/ui/map/searchBar/procSearchBar.tsx | 11 +- 6 files changed, 225 insertions(+), 101 deletions(-) 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 a1c0c8d24..1b6542816 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -24,7 +24,6 @@ import { downloadAgedPipeCsv } from "./agedPipeExport"; const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; -const POPUP_CTPV_FOCUS_MESSAGE = "SN_AGED_PIPE_CTPV_FOCUS"; export default function AgedPipePopupPage() { const searchParams = useSearchParams(); @@ -124,7 +123,7 @@ export default function AgedPipePopupPage() { return () => window.removeEventListener("message", handleFilterSyncMessage); }, [loadRows]); - const handleSearch = async (page = 1, shouldFocusCtpv = false) => { + const handleSearch = async (page = 1) => { if (!validateElapsedYears(Number(filters.elapsedYears))) { alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); return; @@ -133,16 +132,6 @@ export default function AgedPipePopupPage() { const normalized = normalizeAgedPipeFilters(filters); setFilters(normalized); await loadRows(page, normalized); - - if (shouldFocusCtpv && normalized.ctpvCd !== "0") { - window.opener?.postMessage( - { - type: POPUP_CTPV_FOCUS_MESSAGE, - ctpvCd: normalized.ctpvCd, - }, - window.location.origin, - ); - } }; const handleRowClick = (index?: number) => { @@ -250,7 +239,7 @@ export default function AgedPipePopupPage() { }); }} onKeyDown={(event) => { - if (event.key === "Enter") void handleSearch(1, true); + if (event.key === "Enter") void handleSearch(1); }} /> @@ -299,7 +288,7 @@ export default function AgedPipePopupPage() { size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" - onClick={() => void handleSearch(1, true)} + onClick={() => void handleSearch(1)} > 조회 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 2c51616b5..e273d9c1c 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -11,6 +11,7 @@ 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 = 13; +export const AGED_PIPE_GUIDE_MIN_ZOOM = 13.1; export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101"]; export type AgedPipeFilters = { diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 9eb592abc..7618ec521 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -51,6 +51,7 @@ 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"; @@ -68,10 +69,6 @@ const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT"; const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; -// 노후관로 팝업에서 조회 버튼 클릭 시, 부모 지도를 선택 시도 범위로 이동시키는 메시지이다. -const AGED_PIPE_CTPV_FOCUS_MESSAGE = "SN_AGED_PIPE_CTPV_FOCUS"; -// 시도 기준 자동 이동은 조회 직후 노후관로가 바로 보이는 수준까지 한 단계 더 당겨 고정한다. -const AGED_PIPE_CTPV_FOCUS_ZOOM = 14; const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [ 720000, 1445000, @@ -956,72 +953,6 @@ const Map = () => { } }, [draftAgedPipeFilters, isAgedPipeListOpen, loadAgedPipeList, syncAgedPipePopupFilters]); - // 노후관로 팝업에서 시도 기준 조회를 실행하면, 부모 지도창을 해당 시도 extent로 이동시킨다. - const focusMapToAgedPipeCtpv = useCallback(async (ctpvCd: string) => { - const normalizedCtpvCd = String(ctpvCd ?? "").replace(/[^0-9]/g, "").slice(0, 2); - if (!normalizedCtpvCd || normalizedCtpvCd === "0") return; - - const map = gisMaps.current; - if (!map) return; - - try { - const params = new URLSearchParams({ - service: "WFS", - version: "1.0.0", - request: "GetFeature", - typeName: "hasudo:tb_ms_ctpv", - outputFormat: "application/json", - CQL_FILTER: `mctpv_cd='${normalizedCtpvCd}'`, - }); - const payload = await getRawJson(`/geoserver/hasudo/ows?${params.toString()}`); - // 시도 WFS는 환경마다 3857/4326 등 다른 CRS로 내려올 수 있으므로 응답 crs를 우선 신뢰한다. - const responseCrsName = String(payload?.crs?.properties?.name ?? ""); - const resolvedDataProjection = responseCrsName.includes("EPSG::") - ? `EPSG:${responseCrsName.split("EPSG::")[1]}` - : responseCrsName.includes("EPSG:") - ? responseCrsName.slice(responseCrsName.lastIndexOf("EPSG:")) - : GisApp.FROM_PROJ; - const features = new GeoJSON().readFeatures(payload, { - dataProjection: resolvedDataProjection, - featureProjection: map.getView().getProjection(), - }); - - let extent: [number, number, number, number] | null = null; - features.forEach((feature) => { - const geometry = feature.getGeometry(); - if (!geometry) return; - const geometryExtent = geometry.getExtent(); - if (!extent) { - extent = [...geometryExtent] as [number, number, number, number]; - return; - } - extent = [ - Math.min(extent[0], geometryExtent[0]), - Math.min(extent[1], geometryExtent[1]), - Math.max(extent[2], geometryExtent[2]), - Math.max(extent[3], geometryExtent[3]), - ]; - }); - - if (!extent) return; - - const view = map.getView(); - view.fit(extent, { - padding: AGED_PIPE_FOCUS_PADDING, - maxZoom: 18, - constrainResolution: true, - nearest: true, - }); - - const fittedZoom = view.getZoom() ?? 0; - if (fittedZoom < AGED_PIPE_CTPV_FOCUS_ZOOM) { - view.setZoom(AGED_PIPE_CTPV_FOCUS_ZOOM); - } - } catch (error) { - console.error("노후관로 시도 이동 중 오류:", error); - } - }, []); - const openAgedPipePopupWindow = useCallback(() => { const popup = openWindowPopup("agedPipePopup", { elapsedYears: String(appliedAgedPipeFilters.elapsedYears), @@ -1137,12 +1068,6 @@ const Map = () => { return; } - if (event.data?.type === AGED_PIPE_CTPV_FOCUS_MESSAGE) { - if (!event.data?.ctpvCd) return; - void focusMapToAgedPipeCtpv(String(event.data.ctpvCd)); - return; - } - if (event.data?.type === PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE) { if (!event.data?.row) return; handlePipeAnalysisRowSelect(event.data.row as PipeAnalysisDetailRow); @@ -1151,7 +1076,7 @@ const Map = () => { window.addEventListener("message", handleAgedPipePopupMessage); return () => window.removeEventListener("message", handleAgedPipePopupMessage); - }, [agedPipeTotalCount, focusMapToAgedPipeCtpv, handleAgedPipeRowDoubleClick, handlePipeAnalysisRowSelect]); + }, [agedPipeTotalCount, handleAgedPipeRowDoubleClick, handlePipeAnalysisRowSelect]); // WaterDepth 표시 여부를 결정하는 함수 const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => { @@ -1311,7 +1236,7 @@ const Map = () => { return ( <> -
+
{layerLoadError ? (
레이어 설정 조회 실패: {layerLoadError} @@ -1322,7 +1247,10 @@ const Map = () => { {agedPipeDownloadProgressText}
) : null} - + setIsSearchModalOpen(true)} + /> { setIsSearchModalOpen={setIsSearchModalOpen} onCheckedLayersChange={handleCheckedLayersChange} onOpenPopup={handleOpenLayerPopup} - searchBar={} + searchBar={ + + } waterDepth={showWaterDepth ? WaterDepth : undefined} inlineLayerSettingView={inlineLayerSettingView} layerIconVisible={isAgedPipeLayerNode} @@ -1349,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]} @@ -1385,6 +1330,37 @@ const Map = () => {
)} +
); 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)} +
+
+ )}
- ))} +
+
+
+ + 분석도구 +
+
+ +
+ {ANALYSIS_POPUP_ITEMS.map((item) => { + const Icon = item.icon; + const isActive = activeKey === item.key; + + return ( + + ); + })} +
) : null}
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 && (
@@ -637,7 +641,7 @@ export default React.memo(function GisTree({ {!visible && (