시설물 레이어 ui 변경 작업
This commit is contained in:
@@ -7,12 +7,14 @@ type Props = {
|
||||
filters: AgedPipeFilters;
|
||||
onFiltersChange: (filters: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => void;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export default function AgedPipeLayerSettingPanel({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onApply,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? ""));
|
||||
@@ -30,8 +32,48 @@ export default function AgedPipeLayerSettingPanel({
|
||||
onApply(nextFilters);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-3 py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex min-h-[32px] items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[1.15rem] font-medium text-slate-700">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="whitespace-nowrap">노후관로 경과년도(년)</label>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={elapsedYearsText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setElapsedYearsText(digits);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitElapsedYears();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[20px] w-[20px] place-items-center rounded-full bg-[#31a83b] text-[1.15rem] font-bold leading-none text-white"
|
||||
onClick={commitElapsedYears}
|
||||
title="적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
|
||||
<div className="w-full overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
|
||||
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
|
||||
<button
|
||||
type="button"
|
||||
@@ -47,7 +89,7 @@ export default function AgedPipeLayerSettingPanel({
|
||||
{!isCollapsed && (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
|
||||
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
|
||||
<label htmlFor="aged-pipe-elapsed-years">노후관로 경과년도(년)</label>
|
||||
<input
|
||||
id="aged-pipe-elapsed-years"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
onApplyRadius: (radiusMeters: number) => void;
|
||||
lastSyncSummary?: SinkholeSyncSummary | null;
|
||||
isAdmin?: boolean;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export default function SinkholeLayerSettingPanel({
|
||||
@@ -15,6 +16,7 @@ export default function SinkholeLayerSettingPanel({
|
||||
onApplyRadius,
|
||||
lastSyncSummary,
|
||||
isAdmin = false,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [radiusText, setRadiusText] = useState(String(radiusMeters));
|
||||
@@ -32,8 +34,45 @@ export default function SinkholeLayerSettingPanel({
|
||||
onApplyRadius(nextValue);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-3 py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
<div className="flex min-h-[32px] items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[1.15rem] font-medium text-slate-700">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />
|
||||
<label htmlFor="sinkhole-radius" className="whitespace-nowrap">지반침하반경(m)</label>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
id="sinkhole-radius"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete="off"
|
||||
value={radiusText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitRadius();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-[20px] w-[20px] place-items-center rounded-full bg-[#31a83b] text-[1.15rem] font-bold leading-none text-white"
|
||||
onClick={commitRadius}
|
||||
title="적용"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[215px] overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
|
||||
<div className="w-full overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
|
||||
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-white px-8">
|
||||
<button
|
||||
type="button"
|
||||
@@ -49,7 +88,7 @@ export default function SinkholeLayerSettingPanel({
|
||||
{!isCollapsed && (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
|
||||
<label htmlFor="sinkhole-radius">반경(m)</label>
|
||||
<label htmlFor="sinkhole-radius">지반침하반경(m)</label>
|
||||
<input
|
||||
id="sinkhole-radius"
|
||||
className="h-[22px] w-14 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import Collection from "ol/Collection.js";
|
||||
@@ -111,7 +111,7 @@ import {
|
||||
} from "./components/sinkhole/sinkholeTypes";
|
||||
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
|
||||
import SearchModal from "./searchModal/searchModal";
|
||||
import { WATER_DEPTH_LAYER_TYPES, WaterDepth } from "./waterDepth/waterDepth";
|
||||
import { WATER_DEPTH_LAYER_TYPES, WATER_DEPTH_LEGEND_ITEMS } from "./waterDepth/waterDepth";
|
||||
import {
|
||||
buildWaterRiskStatusLabel,
|
||||
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
|
||||
@@ -2237,7 +2237,7 @@ const Map = () => {
|
||||
const gisMaps = useRef<any | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
|
||||
const [featureData, setFeatureData] = useState<any>(null); // 선택된 피쳐 데이터
|
||||
const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부
|
||||
const [isWaterDepthLayerActive, setIsWaterDepthLayerActive] = useState(false); // 침수심 레이어 활성 여부
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
|
||||
@@ -4336,7 +4336,7 @@ const Map = () => {
|
||||
);
|
||||
|
||||
const hasWaterDepthLayer = waterDepthLayers.length > 0;
|
||||
setShowWaterDepth(hasWaterDepthLayer);
|
||||
setIsWaterDepthLayerActive(hasWaterDepthLayer);
|
||||
}, []);
|
||||
|
||||
// 체크된 레이어 변경 처리 함수
|
||||
@@ -4408,41 +4408,128 @@ const Map = () => {
|
||||
[openAgedPipeList, openSinkholePopupWindow],
|
||||
);
|
||||
|
||||
const inlineLayerSettingView = useCallback(
|
||||
(node: any) => {
|
||||
if (
|
||||
isSinkholeYearNode(node) &&
|
||||
String(node?.text ?? node?.data?.lyrNm ?? "").trim() === "전체"
|
||||
) {
|
||||
return (
|
||||
// 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다.
|
||||
const activeLayerSettingItems = useMemo<{ key: string; label: string; content: ReactNode }[]>(() => {
|
||||
const nextItems: { key: string; label: string; content: ReactNode; order: number }[] = [];
|
||||
const checkedSettingNodes = checkedNodesDataRef.current ?? [];
|
||||
const resolveSettingOrder = (predicate: (node: any) => boolean, fallback: number) => {
|
||||
const index = checkedSettingNodes.findIndex((node) => predicate(node));
|
||||
return index >= 0 ? index : fallback;
|
||||
};
|
||||
|
||||
if (isSinkholeChecked) {
|
||||
nextItems.push({
|
||||
key: "sinkhole",
|
||||
label: "지반침하정보",
|
||||
order: resolveSettingOrder((node) => isSinkholeLayerNode(node), 9998),
|
||||
content: (
|
||||
<SinkholeLayerSettingPanel
|
||||
radiusMeters={sinkholeRadiusMeters}
|
||||
onApplyRadius={applySinkholeRadius}
|
||||
lastSyncSummary={lastSinkholeSyncSummary}
|
||||
isAdmin={userInfo?.adminRoleYn === "Y"}
|
||||
compact
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!isAgedPipeChecked || !isAgedPipeLayerNode(node)) return null;
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AgedPipeLayerSettingPanel
|
||||
filters={draftAgedPipeFilters}
|
||||
onFiltersChange={setDraftAgedPipeFilters}
|
||||
onApply={handleApplyAgedPipeFilters}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
applySinkholeRadius,
|
||||
draftAgedPipeFilters,
|
||||
handleApplyAgedPipeFilters,
|
||||
isAgedPipeChecked,
|
||||
lastSinkholeSyncSummary,
|
||||
sinkholeRadiusMeters,
|
||||
userInfo?.adminRoleYn,
|
||||
],
|
||||
);
|
||||
if (isAgedPipeChecked) {
|
||||
nextItems.push({
|
||||
key: "agedPipe",
|
||||
label: "노후관로",
|
||||
order: resolveSettingOrder((node) => isAgedPipeLayerNode(node), 9999),
|
||||
content: (
|
||||
<AgedPipeLayerSettingPanel
|
||||
filters={draftAgedPipeFilters}
|
||||
onFiltersChange={setDraftAgedPipeFilters}
|
||||
onApply={handleApplyAgedPipeFilters}
|
||||
compact
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (isWaterDepthLayerActive) {
|
||||
nextItems.push({
|
||||
key: "waterDepthLegend",
|
||||
label: "침수심 범례",
|
||||
order: resolveSettingOrder(
|
||||
(node) => WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")),
|
||||
9997,
|
||||
),
|
||||
content: (
|
||||
<div className="overflow-hidden rounded-sm border border-sky-100 bg-sky-50/40">
|
||||
<div className="px-3 py-3">
|
||||
<div className="rounded-sm border border-slate-200 bg-white px-3 py-3">
|
||||
<div className="mb-2 bg-blue-900 py-1 text-center text-[1.1rem] font-semibold text-white">
|
||||
침수심 범례
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{WATER_DEPTH_LEGEND_ITEMS.map((item, index) => (
|
||||
<div key={`${item.label}-${index}`} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-4 w-10 shrink-0 border border-slate-300"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-[1rem] font-medium text-slate-700">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return nextItems
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(({ key, label, content }) => ({ key, label, content }));
|
||||
}, [
|
||||
applySinkholeRadius,
|
||||
draftAgedPipeFilters,
|
||||
handleApplyAgedPipeFilters,
|
||||
isAgedPipeChecked,
|
||||
isSinkholeChecked,
|
||||
isWaterDepthLayerActive,
|
||||
lastSinkholeSyncSummary,
|
||||
sinkholeRadiusMeters,
|
||||
userInfo?.adminRoleYn,
|
||||
]);
|
||||
|
||||
// 선택된 설정이 있을 때만 하단 전용 섹션을 노출하고, 세로 공간이 부족하면 내부 스크롤로 처리한다.
|
||||
const ActiveLayerSettingView = useCallback(() => {
|
||||
if (activeLayerSettingItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 overflow-hidden rounded-sm border border-slate-200 bg-white shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
|
||||
<div className="border-b border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[1.2rem] font-semibold text-slate-700">선택 레이어 설정</span>
|
||||
<span className="text-[1rem] text-slate-500">{activeLayerSettingItems.length}건</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{activeLayerSettingItems.map((item) => (
|
||||
<span
|
||||
key={item.key}
|
||||
className="rounded-full border border-slate-200 bg-white px-2 py-[1px] text-[1rem] text-slate-600"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[28vh] overflow-y-auto px-1 py-2 [scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeLayerSettingItems.map((item) => (
|
||||
<Fragment key={item.key}>{item.content}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [activeLayerSettingItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady || !gisMaps.current) return;
|
||||
@@ -6297,8 +6384,9 @@ const Map = () => {
|
||||
sidoSearchZoom={isAgedPipeChecked ? AGED_PIPE_GUIDE_MIN_ZOOM : undefined}
|
||||
/>
|
||||
}
|
||||
waterDepth={showWaterDepth ? WaterDepth : undefined}
|
||||
inlineLayerSettingView={inlineLayerSettingView}
|
||||
waterDepth={undefined}
|
||||
layerSettingView={ActiveLayerSettingView}
|
||||
treeSectionClassName="rounded-sm border border-slate-300 px-1 py-2"
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying as any}
|
||||
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
|
||||
@@ -6307,7 +6395,7 @@ const Map = () => {
|
||||
showZoomLevel={true}
|
||||
showZoomScaleInfo={true}
|
||||
showFloatingSearchButton={false}
|
||||
treeTogglePosition="top"
|
||||
treeTogglePosition="center"
|
||||
zoomGuide={zoomGuide}
|
||||
layerIconVisible={(node: any) => isAgedPipeLayerNode(node) || isSinkholeRootNode(node)}
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ export const WATER_DEPTH_LAYER_TYPES: string[] = [
|
||||
'tb_ms_sbmrg_dngr_map' // 침수위험지도
|
||||
];
|
||||
|
||||
export const WATER_DEPTH_LEGEND_ITEMS = [
|
||||
{ color: "rgb(255, 255, 127)", label: "0.0~0.5m 미만" },
|
||||
{ color: "rgb(191, 255, 0)", label: "0.5~1.0m 미만" },
|
||||
{ color: "rgb(0, 255, 255)", label: "1.0~2.0m 미만" },
|
||||
{ color: "rgb(191, 127, 255)", label: "2.0~5.0m 미만" },
|
||||
{ color: "rgb(255, 0, 127)", label: "5.0m 이상" },
|
||||
] as const;
|
||||
|
||||
interface WaterDepthProps {
|
||||
onClose?: () => void;
|
||||
isPlaying: boolean;
|
||||
@@ -70,13 +78,15 @@ export function WaterDepth({
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[140px] mx-auto flex flex-col items-center">
|
||||
<div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침 수 심</div>
|
||||
<div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침수심 범례</div>
|
||||
<div className="space-y-1 w-full">
|
||||
<LegendItem color="rgb(255, 255, 127)" label="0.0~0.5m 미만" /> {/* 코드: N330 */}
|
||||
<LegendItem color="rgb(191, 255, 0)" label="0.5~1.0m 미만" /> {/* 코드: N331 */}
|
||||
<LegendItem color="rgb(0, 255, 255)" label="1.0~2.0m 미만" /> {/* 코드: N332 */}
|
||||
<LegendItem color="rgb(191, 127, 255)" label="2.0~5.0m 미만" /> {/* 코드: N333 */}
|
||||
<LegendItem color="rgb(255, 0, 127)" label="5.0m 이상" /> {/* 코드: N334 */}
|
||||
{WATER_DEPTH_LEGEND_ITEMS.map((item, index) => (
|
||||
<LegendItem
|
||||
key={`${item.label}-${index}`}
|
||||
color={item.color}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-2 border-blue-900 mt-1 mx-auto w-full" />
|
||||
</div>
|
||||
@@ -95,4 +105,4 @@ function LegendItem({ color, label }: { color: string; label: string }) {
|
||||
<span className="text-xl text-gray-900 font-bold">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +217,28 @@
|
||||
- 이번 유지보수에서는 `SinkholePopupPage.tsx`의 목록 테이블 헤더를 `지반침하 조회 결과 총 {total.toLocaleString()}건`으로 지정하고, `isHideCount={true}`를 함께 적용해 `UiTable` 기본 건수 문구와 중복되지 않도록 정리했다.
|
||||
- 결과적으로 노후관로 팝업과 같은 위치/형식으로 총 건수가 표시되며, 별도 API 변경 없이 기존 `total` 응답을 그대로 재사용한다.
|
||||
|
||||
### 4.29 2026-06-11 시설물 트리 하단 설정영역 분리
|
||||
|
||||
- 기존에는 `지반침하정보`의 반경 설정 패널이 트리 노드 바로 아래 인라인으로 붙어 있었는데, 생활안전지도처럼 “레이어를 사용할 때만 하단 설정영역에 따로 모아 보여주는 구조” 요청이 추가됐다.
|
||||
- 이번 유지보수에서는 패널 디자인과 입력/적용 동작은 그대로 두고, `map/page.tsx`에서 인라인 노출 분기를 제거한 뒤 `Gis`의 `layerSettingView` 하단 영역으로 이동시켰다.
|
||||
- 따라서 `지반침하정보`를 체크하지 않으면 설정 패널이 보이지 않고, 연도/전체 노드가 활성화된 동안에만 트리 하단 별도 영역에 나타난다.
|
||||
- 후속 보정으로 하단 영역 자체에 `선택 레이어 설정` 헤더와 선택된 레이어명 표시를 추가하고, 세로 공간이 부족할 때는 내부 스크롤이 생기도록 정리했다.
|
||||
- `노후관로 설정`과 패널 폭이 달라 보이지 않도록 `지반침하정보` 설정 패널 폭도 하단 영역 기준 `w-full`로 통일했다.
|
||||
- 추가 보정으로 별도 상단 타이틀 줄은 제거하고, 대신 시설물 레이어 목록 영역 자체를 얇은 직사각형 테두리로 감싸서 위 레이어 영역과 아래 설정영역이 자연스럽게 구분되도록 정리했다.
|
||||
- 추가 점검으로 `선택 레이어 설정` 노출 기준을 연도 자식 노드 탐지 대신 실제 `isSinkholeChecked` 상태로 바꿔, 지반침하 레이어 체크 시 하단 설정영역 반영이 누락되지 않도록 보정했다.
|
||||
- 공용 트리 접기 버튼이 세로 스크롤바 위를 덮어 보이는 문제를 줄이기 위해, 버튼을 패널 안쪽이 아니라 우측 바깥 경계 쪽으로 이동시켜 핸들처럼 보이도록 조정했다.
|
||||
- 후속 조정으로 버튼이 아예 보이지 않는 문제를 해결하기 위해, 접기/펼치기 버튼을 패널 밖 가운데에 붙는 단일 토글 핸들 구조로 재배치했다.
|
||||
- 추가 수정으로 토글 버튼이 `overflow-hidden` 패널 박스에 같이 들어가 잘리던 구조를 분리해, 버튼을 패널 박스 바깥 형제 위치로 옮겨 실제 화면에서 보이도록 보정했다.
|
||||
- 최종 보정으로 토글 버튼 시각 스타일은 분석도구 패널에서 사용 중인 `36x20` 핸들 디자인과 동일하게 맞추고, 위치는 패널 바깥 중앙을 유지하도록 정리했다.
|
||||
- 추가 구조 보정으로 시설물 레이어 직사각형 박스는 화면에 항상 보이는 고정 영역으로 두고, 실제 롤업/스크롤은 그 내부에서만 일어나도록 바꿨다. 토글 핸들도 이제 패널 전체가 아니라 이 직사각형 박스 기준 중앙에 붙는다.
|
||||
- 후속 위치 보정으로 토글 핸들이 박스 바깥으로 너무 멀리 빠지지 않도록 `left-full` 배치를 제거하고, 레이어 직사각형 박스 오른쪽 경계에 걸치는 `right` 음수 오프셋 기준으로 다시 맞췄다.
|
||||
- 후속 가시성 보정으로 토글 핸들 크기를 키우고 파란 계열 대비와 그림자를 강화해, 배경 지도와 겹쳐도 버튼이 바로 보이도록 조정했다.
|
||||
- 추가 구조 보정으로 생활안전지도처럼 `레이어 박스 내부 스크롤`과 `패널 전체 스크롤`을 분리했다. 시설물 레이어 박스는 고정 높이와 고정 테두리를 유지하고, 선택 레이어 설정이 많아질 때는 패널 전체 스크롤로 아래 내용을 보도록 정리했다.
|
||||
- `침수위험지도(중점관리지역)`, `도시침수지도(정보제공포털)` 선택 시 `침수심 범례`는 토글 제어가 아니라 선택 레이어 설정 안에서 실제 범례 항목을 그대로 보여주는 정보 카드로 정리했다.
|
||||
- 스크롤바 폭은 줄이되 색상은 브라우저 기본색으로 되돌려, 이전보다 얇지만 과한 커스텀 색은 보이지 않도록 맞췄다.
|
||||
- 후속 정리로 선택 레이어 설정 안의 침수심 범례 카드는 실제 범례 본문만 남기고, 지도 우측 하단에 별도로 뜨던 기존 범례 박스는 제거했다.
|
||||
- 추가 구조 보정으로 토글 핸들을 패널 이동 영역과 완전히 분리된 형제로 빼고, 열림 시에는 레이어 박스 옆에, 닫힘 시에는 화면 왼쪽 가장자리에 남도록 바꿔 다시 열기 버튼이 사라지지 않게 정리했다.
|
||||
|
||||
## 5. 승인 전 보류 사항
|
||||
|
||||
- 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요
|
||||
|
||||
@@ -30,6 +30,28 @@
|
||||
- `UiTable`에 전달하는 외곽 마진을 제거하고, 중복 총 건수 문구를 숨겨 팝업 내부 가로/세로 스크롤 발생 가능성을 낮췄다.
|
||||
- 표 영역은 현재 팝업 폭과 높이 안에서 보이도록 유지하는 방향으로 정리했다.
|
||||
|
||||
### 3.3 2026-06-11 시설물 트리 하단 설정영역 분리
|
||||
|
||||
- 기존 `노후관로 설정` 패널은 시설물 트리 안에서 `노후관로` 노드 바로 아래에 인라인으로 열렸지만, 지반침하정보와 동일하게 “레이어 사용 시 하단 설정영역에 추가되는 방식”으로 맞춰 달라는 요청이 들어왔다.
|
||||
- 이번 유지보수에서는 `노후관로 설정` 패널 컴포넌트 자체는 유지하고, `map/page.tsx`에서 `isAgedPipeChecked`일 때만 `Gis` 하단 `layerSettingView` 영역에 렌더되도록 위치만 조정했다.
|
||||
- 체크 해제 시에는 설정 패널도 같이 사라지고, 목록보기/필터 적용/지도 표시 흐름은 기존과 동일하게 유지한다.
|
||||
- 후속 보정으로 하단 영역은 `선택 레이어 설정` 묶음으로 감싸고, 선택된 설정이 없으면 섹션 자체가 사라지도록 해 “선택된 게 있을 때만 뜨는 느낌”을 명확히 했다.
|
||||
- 같은 영역 안에서 `지반침하정보`와 `노후관로 설정` 패널이 서로 다른 폭으로 보이지 않도록 두 패널 모두 하단 영역 기준으로 동일 폭을 쓰게 맞췄다.
|
||||
- 추가 보정으로 별도 상단 구분 타이틀은 제거하고, 시설물 레이어 목록을 얇은 사각형 프레임으로 감싸 위 레이어 영역과 아래 설정영역의 구획만 남기도록 정리했다.
|
||||
- 스크롤바가 생길 때 레이어명이 2줄로 꺾이는 현상을 줄이기 위해, 공용 트리 스크롤 영역에 `scrollbar-gutter: stable`을 적용하고 레이어명은 한 줄 `truncate` 기준으로 보정했다.
|
||||
- 공용 트리 접기 버튼이 스크롤바를 가려 보이는 배치도 함께 조정해, 버튼이 패널 외곽 핸들처럼 보이도록 옮겼다.
|
||||
- 후속 조정으로 버튼이 잘려 보이지 않는 문제를 해결하기 위해, 접기/펼치기 버튼을 패널 밖 가운데에 붙는 단일 토글 핸들 구조로 재배치했다.
|
||||
- 추가 수정으로 토글 버튼이 패널의 `overflow-hidden` 범위에 포함돼 실제로는 잘려 보이지 않던 구조를 분리해, 패널 바깥 형제 위치에서 항상 보이도록 보정했다.
|
||||
- 최종 보정으로 토글 시각 스타일은 분석도구 패널과 같은 `36x20` 핸들 디자인을 재사용하고, 위치는 패널 바깥 중앙으로 유지하도록 정리했다.
|
||||
- 추가 구조 보정으로 시설물 레이어 박스는 항상 윤곽이 유지되게 하고, 스크롤은 그 박스 내부에서만 동작하도록 바꿨다. 토글 핸들 위치도 박스 기준 중앙으로 다시 정리했다.
|
||||
- 후속 위치 보정으로 토글 핸들이 박스 오른쪽 경계에 자연스럽게 걸치도록 다시 맞춰, 지도 영역 쪽으로 과하게 밀려나 보이던 배치를 줄였다.
|
||||
- 후속 가시성 보정으로 토글 핸들 크기, 파란 계열 대비, 그림자를 함께 올려 지도 배경 위에서도 더 눈에 띄도록 정리했다.
|
||||
- 추가 구조 보정으로 패널은 `바깥 전체 스크롤`, 시설물 레이어 박스는 `고정 높이 + 내부 스크롤`로 분리해 생활안전지도와 비슷한 사용 흐름으로 정리했다.
|
||||
- `침수심 범례`는 선택 레이어 설정 안에서 별도 토글을 두지 않고, 관련 침수 레이어가 선택된 경우 실제 범례 항목을 그대로 확인하는 정보 카드로 반영했다.
|
||||
- 스크롤바는 폭만 얇게 유지하고 색상은 기본값으로 되돌려, 생활안전지도처럼 덜 눈에 띄는 톤으로 정리했다.
|
||||
- 후속 정리로 선택 레이어 설정에는 침수심 범례 본문만 남기고, 지도 우측 하단에 따로 뜨던 기존 범례 박스는 제거했다.
|
||||
- 추가 구조 보정으로 토글 핸들을 패널 본체와 분리된 고정 핸들로 바꿔, 닫았을 때도 화면 왼쪽 가장자리에 다시 열기 버튼이 남도록 정리했다.
|
||||
|
||||
## 4. 실제 수정 파일
|
||||
|
||||
- `FE_APP_ROOT/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx`
|
||||
|
||||
@@ -56,6 +56,7 @@ function Gis({
|
||||
waterDepth: WaterDepth = null,
|
||||
layerSettingView: LayerSettingView = null,
|
||||
inlineLayerSettingView,
|
||||
treeSectionClassName = "",
|
||||
layerIconVisible,
|
||||
isPlaying = false,
|
||||
setIsPlaying = () => { },
|
||||
@@ -316,6 +317,7 @@ function Gis({
|
||||
onProgressChange={handleProgressChange}
|
||||
layerSettingView={LayerSettingView}
|
||||
inlineLayerSettingView={inlineLayerSettingView}
|
||||
treeSectionClassName={treeSectionClassName}
|
||||
toggleButtonPosition={treeTogglePosition}
|
||||
/>
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export default React.memo(function GisTree({
|
||||
layerSettingView: LayerSettingView = null,
|
||||
layerIconVisible,
|
||||
inlineLayerSettingView,
|
||||
treeSectionClassName = "",
|
||||
toggleButtonPosition = "center",
|
||||
}) {
|
||||
const { gisCore, treeData } = useGisContext();
|
||||
@@ -513,31 +514,37 @@ export default React.memo(function GisTree({
|
||||
if (!treeData || treeData.length === 0) return null;
|
||||
|
||||
const toggleButtonPositionClass =
|
||||
toggleButtonPosition === "top" ? "top-[5.5rem]" : "top-1/2 -translate-y-1/2";
|
||||
toggleButtonPosition === "top" ? "top-4" : "top-1/2 -translate-y-1/2";
|
||||
const thinScrollbarClass =
|
||||
"[scrollbar-width:thin] [&::-webkit-scrollbar]:w-[4px]";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 사이드 패널 */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 z-40 flex h-full w-[250px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300 ${visible
|
||||
? "translate-x-0 opacity-100"
|
||||
: "pointer-events-none -translate-x-full opacity-0"
|
||||
className={`absolute left-0 top-0 z-40 h-full transition-transform duration-300 ${visible
|
||||
? "translate-x-0"
|
||||
: "-translate-x-[250px]"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`grow overflow-y-auto border-none p-2 pt-0 shadow-none ${activeTab === "layers" ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
|
||||
<hr className="mb-4" />
|
||||
{/* DnD Tree */}
|
||||
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
|
||||
<Tree
|
||||
tree={nodes}
|
||||
rootId={null}
|
||||
initialOpen={expanded}
|
||||
onOpenChange={setExpanded}
|
||||
render={(node, { depth, isOpen, onToggle }) => {
|
||||
<div className="flex h-full w-[250px] flex-col overflow-hidden border-r border-gray-300 bg-white">
|
||||
<div
|
||||
className={`grow overflow-y-auto border-none p-2 pt-0 shadow-none ${thinScrollbarClass} ${activeTab === "layers" ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<h3 className="mx-2 my-4 text-3xl font-bold text-gray-600 text-center">시설물</h3>
|
||||
<hr className="mb-4" />
|
||||
<div className="relative h-[58vh] min-h-[280px] max-h-[58vh]">
|
||||
<div className={`h-full overflow-hidden ${treeSectionClassName}`}>
|
||||
<div className={`h-full overflow-y-auto pr-[2px] [scrollbar-gutter:stable] ${thinScrollbarClass}`}>
|
||||
{/* DnD Tree */}
|
||||
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
|
||||
<Tree
|
||||
tree={nodes}
|
||||
rootId={null}
|
||||
initialOpen={expanded}
|
||||
onOpenChange={setExpanded}
|
||||
render={(node, { depth, isOpen, onToggle }) => {
|
||||
const isVisible = node.data.exprsYn === "Y";
|
||||
const hasVisibleChildren = nodes.some(
|
||||
(child) =>
|
||||
@@ -554,7 +561,7 @@ export default React.memo(function GisTree({
|
||||
return (
|
||||
<div style={{ display: isVisible ? "block" : "none" }}>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
|
||||
className="flex min-w-0 items-center gap-2 rounded px-2 py-1 hover:bg-gray-100"
|
||||
style={{
|
||||
marginInlineStart: depth * 16,
|
||||
}}
|
||||
@@ -576,7 +583,10 @@ export default React.memo(function GisTree({
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{!isSinkholeRootTreeNode && node.icon && <span>{node.icon}</span>}
|
||||
<span className="text-[1.3rem] font-medium text-label">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate whitespace-nowrap text-[1.3rem] font-medium text-label"
|
||||
title={String(node.text ?? "")}
|
||||
>
|
||||
{node.text}
|
||||
</span>
|
||||
{showLayerActionButton && (
|
||||
@@ -621,35 +631,34 @@ export default React.memo(function GisTree({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
sort={(a, b) => {
|
||||
const aOrder = parseInt(a.data?.sortOrdr ?? "0");
|
||||
const bOrder = parseInt(b.data?.sortOrdr ?? "0");
|
||||
return aOrder - bOrder;
|
||||
}}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
{LayerSettingView && (
|
||||
<div className="shrink-0 bg-white px-2 pb-3">
|
||||
<LayerSettingView />
|
||||
}}
|
||||
sort={(a, b) => {
|
||||
const aOrder = parseInt(a.data?.sortOrdr ?? "0");
|
||||
const bOrder = parseInt(b.data?.sortOrdr ?? "0");
|
||||
return aOrder - bOrder;
|
||||
}}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{LayerSettingView && (
|
||||
<div className="bg-white px-2 pb-3 pt-3">
|
||||
<LayerSettingView />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setVisible(false)}
|
||||
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}`}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!visible && (
|
||||
<button
|
||||
onClick={() => setVisible(true)}
|
||||
className={`absolute left-[-3px] z-50 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((prev) => !prev)}
|
||||
className={`absolute z-50 flex h-[44px] w-[24px] items-center justify-center rounded-[6px] border border-[#bfd0e3] bg-[rgba(255,255,255,0.98)] text-[#2c6fb7] shadow-[0_10px_22px_rgba(15,23,42,0.22)] transition-all duration-300 hover:bg-[#eef6ff] hover:text-[#1e88e5] ${toggleButtonPositionClass} ${visible ? "left-[250px]" : "left-0"}`}
|
||||
title={visible ? "레이어 패널 닫기" : "레이어 패널 열기"}
|
||||
aria-label={visible ? "레이어 패널 닫기" : "레이어 패널 열기"}
|
||||
>
|
||||
{visible ? <ChevronLeft className="h-5 w-5" strokeWidth={2.6} /> : <ChevronRight className="h-5 w-5" strokeWidth={2.6} />}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user