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)}
+
+
+ )}
diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.common.css b/packages/common/business_components/src/ui/map/lib/css/gis.common.css
index 5ce46a912..1ecf520ab 100644
--- a/packages/common/business_components/src/ui/map/lib/css/gis.common.css
+++ b/packages/common/business_components/src/ui/map/lib/css/gis.common.css
@@ -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;
diff --git a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx
index 31500959b..6d042f62a 100644
--- a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx
+++ b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx
@@ -16,13 +16,18 @@ const FACILITY_TYPE_URL_MAP: Record
= {
interface SearchBarProps {
gisMaps: React.RefObject;
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([
@@ -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;