From c12169871359b1b4b40a694001b75c2efdf98d8a Mon Sep 17 00:00:00 2001 From: kiminchan Date: Wed, 13 May 2026 15:48:38 +0900 Subject: [PATCH] =?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)} +
+
+ )}