노후관로 분석 - 표시 중 UX 개선 및 팝업 자동이동 제거

This commit is contained in:
kiminchan
2026-05-13 15:48:38 +09:00
parent 9604e4591b
commit c121698713
6 changed files with 225 additions and 101 deletions
@@ -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);
}}
/>
</label>
@@ -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)}
>
조회
</Button>
@@ -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 = {
+56 -80
View File
@@ -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 (
<>
<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}
@@ -1322,7 +1247,10 @@ const Map = () => {
{agedPipeDownloadProgressText}
</div>
) : null}
<AnalysisPopupMenu onOpenPopup={openAnalysisPopup} />
<AnalysisPopupMenu
onOpenPopup={openAnalysisPopup}
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
/>
<Gis
treeData={treeData}
isSearch={true}
@@ -1339,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}
@@ -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 = () => {
</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>
</>
);
@@ -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;