@@ -270,7 +288,7 @@ export default function AgedPipePopupPage() {
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
- onClick={() => handleSearch(1)}
+ onClick={() => void handleSearch(1)}
>
조회
@@ -293,7 +311,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/agedPipeExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts
new file mode 100644
index 000000000..7230ccb3f
--- /dev/null
+++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts
@@ -0,0 +1,42 @@
+"use client";
+
+import { postBlobData } from "@/components/lib/api";
+import { fileDownload } from "@/components/lib/utils/fileDownload";
+import {
+ AGED_PIPE_EXCEL_FIELDS,
+ normalizeAgedPipeFilters,
+ type AgedPipeFilters,
+} from "./agedPipeUtils";
+
+type AgedPipeExportProgress = {
+ fileName?: string;
+ progressPercent?: number;
+ status: "QUEUED" | "RUNNING" | "COMPLETED";
+};
+
+/** 노후관로 CSV 다운로드를 요청한다. */
+export const downloadAgedPipeCsv = async (
+ filters: AgedPipeFilters,
+ onProgress?: (progress: AgedPipeExportProgress) => void,
+) => {
+ const normalizedFilters = normalizeAgedPipeFilters(filters);
+
+ onProgress?.({ status: "RUNNING", progressPercent: 0 });
+
+ const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", {
+ elapsedYears: normalizedFilters.elapsedYears,
+ ctpvCd: normalizedFilters.ctpvCd,
+ sggCd: normalizedFilters.sggCd,
+ format: "csv",
+ columns: [...AGED_PIPE_EXCEL_FIELDS],
+ });
+
+ const resolvedFileName = fileName || "노후관로_현황.csv";
+ fileDownload(blob, resolvedFileName);
+ onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 });
+
+ return {
+ status: "COMPLETED" as const,
+ fileName: resolvedFileName,
+ };
+};
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..e273d9c1c 100644
--- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts
+++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts
@@ -10,8 +10,9 @@ 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_INVALID_INSTALL_YMD_PREFIXES = ["19000101", "19990101"];
+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 = {
elapsedYears: number;
@@ -46,6 +47,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 },
@@ -57,8 +64,61 @@ export const AGED_PIPE_LIST_HEADLIST = [
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
];
+export const AGED_PIPE_EXCEL_FIELDS = [
+ "ftreSmbolCd",
+ "fcltMngNo",
+ "pbadmsEmdCd",
+ "mapshtNo",
+ "mngInstCd",
+ "instlYmd",
+ "usgCd",
+ "mqltCd",
+ "fcltyShpCd",
+ "pdmtVl",
+ "wdthLen",
+ "vrtcLen",
+ "prlgVl",
+ "tmptDpth",
+ "scendDpth",
+ "avgGrdn",
+ "trflnCnt",
+ "exclnMltvArea",
+ "drtwMltvArea",
+ "rnyssnFvlcVl",
+ "clarFvlcVl",
+ "tmptMhlMngNo",
+ "scendMhlMngNo",
+ "instMngNo",
+ "fid",
+ "lyrTypeNm",
+ "swtrmtFcltCd",
+ "elapsedYears",
+ "ctpvNm",
+ "sggNm",
+] as const;
+
export const AGED_PIPE_EXCEL_HEADER = {
- rowNumber: "NO",
+ ftreSmbolCd: "지형지물부호코드",
+ pbadmsEmdCd: "행정읍면동코드",
+ mapshtNo: "도엽번호",
+ mngInstCd: "관리기관코드",
+ wdthLen: "가로길이",
+ vrtcLen: "세로길이",
+ tmptDpth: "시점깊이",
+ scendDpth: "종점깊이",
+ avgGrdn: "평균구배",
+ trflnCnt: "차선수",
+ exclnMltvArea: "우수배수면적",
+ drtwMltvArea: "오수배수면적",
+ rnyssnFvlcVl: "우기유속값",
+ clarFvlcVl: "맑음유속값",
+ tmptMhlMngNo: "시점맨홀관리번호",
+ scendMhlMngNo: "종점맨홀관리번호",
+ instMngNo: "기관관리번호",
+ fid: "FID",
+ lyrTypeNm: "레이어타입명",
+ swtrmtFcltCd: "시설코드",
+ fcltyShpCd: "시설물형태코드",
fcltMngNo: "시설관리번호",
instlYmd: "설치일자",
pdmtVl: "관경(mm)",
@@ -70,6 +130,21 @@ export const AGED_PIPE_EXCEL_HEADER = {
sggNm: "시군구",
};
+const getExcelColumnLetter = (columnIndex: number) => {
+ let current = columnIndex;
+ let result = "";
+
+ while (current > 0) {
+ const remainder = (current - 1) % 26;
+ result = String.fromCharCode(65 + remainder) + result;
+ current = Math.floor((current - 1) / 26);
+ }
+
+ return result || "A";
+};
+
+export const AGED_PIPE_EXCEL_HEADER_RANGE = `A1:${getExcelColumnLetter(AGED_PIPE_EXCEL_FIELDS.length)}1`;
+
const pad2 = (value: number) => String(value).padStart(2, "0");
const normalizeYmd = (value: unknown) =>
@@ -82,6 +157,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
@@ -224,6 +301,16 @@ export const toDisplayPipeRow = (
};
};
+export const toAgedPipeExcelRow = (row: AgedPipeRow) => {
+ const exportRow: Record
= {};
+
+ AGED_PIPE_EXCEL_FIELDS.forEach((field) => {
+ exportRow[field] = row[field] ?? "-";
+ });
+
+ return exportRow;
+};
+
export const isAgedPipeLayerNode = (node: any) => {
const data = node?.data ?? node;
const layerName = String(data?.lyrNm ?? node?.text ?? "");
diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx
index f1d2590bc..53cf75946 100644
--- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx
+++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx
@@ -1,50 +1,101 @@
"use client";
import { useState } from "react";
+import {
+ ChartColumnBig,
+ ChevronLeft,
+ ChevronRight,
+ History,
+ PanelsTopLeft,
+ PencilRuler,
+ Search,
+ Settings2,
+ TriangleAlert,
+ Waves,
+} from "lucide-react";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => void;
+ onOpenInfoSearch: () => void;
};
-const ANALYSIS_POPUP_ITEMS: Array<{ key: WindowPopupKey; label: string }> = [
- { key: "pipeAnalysisPopup", label: "관로분석" },
- { key: "floodPredictionMapPopup", label: "침수예측지도" },
- { key: "floodHistoryPopup", label: "침수발생이력" },
- { key: "gisEditPopup", label: "GIS 편집" },
- { key: "gisManagementPopup", label: "GIS 관리" },
- { key: "flowRiskPopup", label: "통수 위험" },
+const ANALYSIS_POPUP_ITEMS: Array<{
+ key: WindowPopupKey | "infoSearch";
+ label: string;
+ icon: typeof ChartColumnBig;
+}> = [
+ { key: "infoSearch", label: "정보검색", icon: Search },
+ { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
+ { key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves },
+ { key: "floodHistoryPopup", label: "침수발생이력", icon: History },
+ { key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
+ { key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
+ { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
];
-export default function AnalysisPopupMenu({ onOpenPopup }: Props) {
+export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) {
const [isOpen, setIsOpen] = useState(true);
+ const [activeKey, setActiveKey] = useState(null);
return (
-
+
{isOpen ? (
-
- {ANALYSIS_POPUP_ITEMS.map((item) => (
-
- ))}
+
+
+
+
+ {ANALYSIS_POPUP_ITEMS.map((item) => {
+ const Icon = item.icon;
+ const isActive = activeKey === item.key;
+
+ return (
+
+ );
+ })}
+
) : null}
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) => {
diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx
index 635f9e1b3..7618ec521 100644
--- a/apps/sn/app/(map-layout)/map/page.tsx
+++ b/apps/sn/app/(map-layout)/map/page.tsx
@@ -36,11 +36,9 @@ import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
import { Button } from "@common/components";
import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
-import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
import type { PipeAnalysisDetailRow } from "./components/pipeAnalysis/pipeAnalysisUtils";
import {
- AGED_PIPE_EXCEL_HEADER,
AGED_PIPE_MIN_ZOOM,
AGED_PIPE_LAYER_TYPE,
AGED_PIPE_PAGE_SIZE,
@@ -53,7 +51,9 @@ 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";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
@@ -528,6 +528,7 @@ const Map = () => {
const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0);
const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1);
const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false);
+ const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState("");
const { ctpv, sgg } = useStore();
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef(null);
@@ -1018,21 +1019,32 @@ const Map = () => {
try {
setIsAgedPipeListLoading(true);
- const response = await fetchAgedPipeRows(1, appliedAgedPipeFilters, 10000);
- await downloadExcelWithKoreanHeaders({
- data: response.rows as JSON[],
- fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`,
- sheetNameArray: ["노후관로 현황"],
- headerRange: "A1:J1",
- headerMapping: AGED_PIPE_EXCEL_HEADER,
+ setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
+ await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => {
+ if (job.status === "QUEUED") {
+ setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다.");
+ return;
+ }
+
+ if (job.status === "RUNNING") {
+ setAgedPipeDownloadProgressText(
+ `CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
+ );
+ }
});
+ setAgedPipeDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
- alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
+ setAgedPipeDownloadProgressText("");
+ alert(
+ error instanceof Error
+ ? error.message
+ : "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
+ );
} finally {
setIsAgedPipeListLoading(false);
}
- }, [agedPipeTotalCount, appliedAgedPipeFilters, fetchAgedPipeRows]);
+ }, [agedPipeTotalCount, appliedAgedPipeFilters]);
useEffect(() => {
const handleAgedPipePopupMessage = (event: MessageEvent) => {
@@ -1224,13 +1236,21 @@ const Map = () => {
return (
<>
-
+
{layerLoadError ? (
레이어 설정 조회 실패: {layerLoadError}
) : null}
-
+ {agedPipeDownloadProgressText ? (
+
+ {agedPipeDownloadProgressText}
+
+ ) : null}
+
setIsSearchModalOpen(true)}
+ />
{
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={handleCheckedLayersChange}
onOpenPopup={handleOpenLayerPopup}
- searchBar={}
+ searchBar={
+
+ }
waterDepth={showWaterDepth ? WaterDepth : undefined}
inlineLayerSettingView={inlineLayerSettingView}
layerIconVisible={isAgedPipeLayerNode}
@@ -1257,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]}
@@ -1293,6 +1330,37 @@ const Map = () => {
)}
+
>
);
diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts
index 08d2dfbe8..28c9f4500 100644
--- a/apps/sn/app/components/lib/api/index.ts
+++ b/apps/sn/app/components/lib/api/index.ts
@@ -101,6 +101,46 @@ export const postData = async (url: string, data: object): Promise => {
}
};
+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,
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'));
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 && (
@@ -619,7 +641,7 @@ export default React.memo(function GisTree({
{!visible && (
setVisible(true)}
- className="absolute left-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm"
+ className={`absolute left-[-3px] z-50 transform rounded-r border border-r border-gray-300 bg-white px-1 py-2 shadow-sm ${toggleButtonPositionClass}`}
>
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)}
+
+
+ )}
+
@@ -100,6 +149,16 @@ export default React.memo(function GisZoomLevel() {
className="zoom-level-track"
onPointerDown={handleTrackPointerDown}
>
+ {hasGuide && (
+
+ {zoomGuide.label}
+
+
+ )}
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;