Merge branch 'iipbs/sn/dev_kic' into iipbs/sn/dev
This commit is contained in:
@@ -3,13 +3,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
|
||||
import { UiTable } from "@common/business_components/ui";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import {
|
||||
AGED_PIPE_DEFAULT_FILTERS,
|
||||
AGED_PIPE_EXCEL_HEADER,
|
||||
AGED_PIPE_LAYER_TYPE,
|
||||
AGED_PIPE_LIST_HEADLIST,
|
||||
AGED_PIPE_PAGE_SIZE,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
type AgedPipeFilters,
|
||||
type AgedPipeRow,
|
||||
} from "./agedPipeUtils";
|
||||
import { downloadAgedPipeCsv } from "./agedPipeExport";
|
||||
|
||||
const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
|
||||
const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
|
||||
@@ -40,6 +40,7 @@ export default function AgedPipePopupPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [downloadProgressText, setDownloadProgressText] = useState("");
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
@@ -122,7 +123,7 @@ export default function AgedPipePopupPage() {
|
||||
return () => window.removeEventListener("message", handleFilterSyncMessage);
|
||||
}, [loadRows]);
|
||||
|
||||
const handleSearch = (page = 1) => {
|
||||
const handleSearch = async (page = 1) => {
|
||||
if (!validateElapsedYears(Number(filters.elapsedYears))) {
|
||||
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
|
||||
return;
|
||||
@@ -130,7 +131,7 @@ export default function AgedPipePopupPage() {
|
||||
|
||||
const normalized = normalizeAgedPipeFilters(filters);
|
||||
setFilters(normalized);
|
||||
loadRows(page, normalized);
|
||||
await loadRows(page, normalized);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
@@ -156,17 +157,28 @@ export default function AgedPipePopupPage() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchRows(1, filters, 10000);
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: response.rows as JSON[],
|
||||
fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`,
|
||||
sheetNameArray: ["노후관로 현황"],
|
||||
headerRange: "A1:J1",
|
||||
headerMapping: AGED_PIPE_EXCEL_HEADER,
|
||||
setDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
|
||||
await downloadAgedPipeCsv(filters, (job) => {
|
||||
if (job.status === "QUEUED") {
|
||||
setDownloadProgressText("CSV export 작업이 대기 중입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.status === "RUNNING") {
|
||||
setDownloadProgressText(
|
||||
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
setDownloadProgressText("");
|
||||
} catch (error) {
|
||||
console.error("노후관로 목록 다운로드 중 오류:", error);
|
||||
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
|
||||
setDownloadProgressText("");
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -202,6 +214,12 @@ export default function AgedPipePopupPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{downloadProgressText ? (
|
||||
<div className="border-b border-slate-200 bg-emerald-50 px-5 py-2 text-[1rem] text-emerald-700">
|
||||
{downloadProgressText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[160px_320px_320px_auto] md:items-end md:justify-between">
|
||||
<label className="block md:max-w-[160px]">
|
||||
@@ -221,7 +239,7 @@ export default function AgedPipePopupPage() {
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch(1);
|
||||
if (event.key === "Enter") void handleSearch(1);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -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)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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<string, unknown> = {};
|
||||
|
||||
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 ?? "");
|
||||
|
||||
@@ -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<WindowPopupKey | "infoSearch" | null>(null);
|
||||
|
||||
return (
|
||||
<div className="absolute right-[0.35rem] top-[53%] z-[70]">
|
||||
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
|
||||
<div className="relative">
|
||||
{isOpen ? (
|
||||
<div className="absolute right-[21px] top-1/2 flex w-[86px] -translate-y-1/2 flex-col gap-[3px]">
|
||||
{ANALYSIS_POPUP_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="min-h-[40px] rounded-md border border-primary bg-primary px-2 py-1 text-center text-[1rem] font-semibold leading-[1.2] text-white shadow transition hover:brightness-95"
|
||||
onClick={() => onOpenPopup(item.key)}
|
||||
>
|
||||
<span className="break-keep whitespace-normal">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="absolute right-[28px] w-[132px] overflow-hidden rounded-[6px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
|
||||
<div className="flex h-[36px] items-center gap-2 border-b border-[#e5eaf1] bg-[#f8fafc] px-[10px] text-[13px] font-bold text-[#1f2937]">
|
||||
<div className="flex items-center gap-2">
|
||||
<PanelsTopLeft className="h-4 w-4 text-[#1e88e5]" />
|
||||
<span>분석도구</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 p-[6px]">
|
||||
{ANALYSIS_POPUP_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeKey === item.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`relative flex h-[34px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
|
||||
isActive
|
||||
? "border-[#b9dcff] bg-[#eaf4ff] font-bold text-[#0f5fa8] before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-[#1e88e5]"
|
||||
: "border-transparent bg-white text-[#374151] hover:border-[#cfe5ff] hover:bg-[#eef6ff] hover:text-[#1e3a5f]"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveKey(item.key);
|
||||
|
||||
if (item.key === "infoSearch") {
|
||||
onOpenInfoSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenPopup(item.key);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className={`h-[15px] w-[15px] shrink-0 ${
|
||||
isActive ? "text-[#1e88e5]" : "text-[#64748b]"
|
||||
}`}
|
||||
/>
|
||||
<span className="break-keep whitespace-normal">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[36px] w-[18px] items-center justify-center rounded-sm border border-[#2f5c9b] bg-white text-[0.9rem] font-bold text-[#2f5c9b] shadow transition hover:bg-slate-50"
|
||||
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-[#d8dee8] bg-[rgba(255,255,255,0.96)] text-[#64748b] shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-[#eaf4ff] hover:text-[#1e88e5]"
|
||||
onClick={() => setIsOpen((value) => !value)}
|
||||
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
|
||||
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
|
||||
>
|
||||
{isOpen ? ">" : "<"}
|
||||
{isOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+30
-2
@@ -18,6 +18,7 @@ import {
|
||||
buildPipeAnalysisScope,
|
||||
normalizeNumberInput,
|
||||
resolveElapsedYearBucketLabel,
|
||||
resolveMaterialKoreanName,
|
||||
resolveMaterialName,
|
||||
resolvePipeAnalysisTypeLabel,
|
||||
resolveRegionName,
|
||||
@@ -27,6 +28,19 @@ import {
|
||||
} from "./pipeAnalysisUtils";
|
||||
|
||||
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<Window | null>(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 (
|
||||
<>
|
||||
<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}
|
||||
</div>
|
||||
) : null}
|
||||
<AnalysisPopupMenu onOpenPopup={openAnalysisPopup} />
|
||||
{agedPipeDownloadProgressText ? (
|
||||
<div className="absolute left-4 top-20 z-[60] max-w-[520px] rounded border border-emerald-200 bg-white px-4 py-3 text-sm text-emerald-700 shadow">
|
||||
{agedPipeDownloadProgressText}
|
||||
</div>
|
||||
) : null}
|
||||
<AnalysisPopupMenu
|
||||
onOpenPopup={openAnalysisPopup}
|
||||
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
|
||||
/>
|
||||
<Gis
|
||||
treeData={treeData}
|
||||
isSearch={true}
|
||||
@@ -1247,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}
|
||||
@@ -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 = () => {
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -101,6 +101,46 @@ export const postData = async (url: string, data: object): Promise<any> => {
|
||||
}
|
||||
};
|
||||
|
||||
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<any> => {
|
||||
const res = await axios.post(`${BASE_URL}/${url}`, data, {
|
||||
headers: DEFAULT_HEADERS,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
<GisToolBar />
|
||||
{showZoomLevel && <GisZoomLevel />}
|
||||
{showZoomLevel && (
|
||||
<GisZoomLevel zoomGuide={zoomGuide} showZoomScaleInfo={showZoomScaleInfo} />
|
||||
)}
|
||||
<GisMapToggle />
|
||||
<GisSelect onFeatureSelect={handleFeatureSelect} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSearch && (
|
||||
{isSearch && showFloatingSearchButton && (
|
||||
<div className="absolute right-8 top-[248px] z-20">
|
||||
<button
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
|
||||
@@ -46,6 +46,7 @@ export default React.memo(function GisTree({
|
||||
layerSettingView: LayerSettingView = null,
|
||||
layerIconVisible,
|
||||
inlineLayerSettingView,
|
||||
toggleButtonPosition = "center",
|
||||
}) {
|
||||
const { gisCore, treeData } = useGisContext();
|
||||
|
||||
@@ -510,6 +511,9 @@ 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";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 사이드 패널 */}
|
||||
@@ -538,6 +542,8 @@ export default React.memo(function GisTree({
|
||||
(child) =>
|
||||
child.parent === node.id && child.data.exprsYn === "Y"
|
||||
);
|
||||
const showLayerActionButton = layerIconVisible?.(node);
|
||||
const showLayerActionIcon = depth > 0 && imageVisible;
|
||||
|
||||
const inlineLayerSetting = inlineLayerSettingView?.(node);
|
||||
|
||||
@@ -569,8 +575,24 @@ export default React.memo(function GisTree({
|
||||
<span className="text-[1.3rem] font-medium text-label">
|
||||
{node.text}
|
||||
</span>
|
||||
{((depth > 0 && imageVisible) || layerIconVisible?.(node)) && (
|
||||
<span>
|
||||
{showLayerActionButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 shrink-0 rounded-sm border border-[#9cc6f0] bg-white px-2 py-[1px] text-[1.1rem] font-medium leading-5 text-[#2c6fb7] transition-colors hover:bg-[#eef6ff]"
|
||||
onClick={() =>
|
||||
handleLayerImgClick(
|
||||
node.data.lyrTyNm,
|
||||
node.id,
|
||||
node
|
||||
)
|
||||
}
|
||||
title={`${node.text} 목록보기`}
|
||||
>
|
||||
목록보기
|
||||
</button>
|
||||
)}
|
||||
{!showLayerActionButton && showLayerActionIcon && (
|
||||
<span className="shrink-0">
|
||||
<img
|
||||
className="h-5 w-5 cursor-pointer"
|
||||
src="/images/map/layericon/Layer_Select_BTN.png"
|
||||
@@ -611,7 +633,7 @@ export default React.memo(function GisTree({
|
||||
)}
|
||||
<button
|
||||
onClick={() => setVisible(false)}
|
||||
className="absolute right-[-3px] top-1/2 z-50 -translate-y-1/2 transform rounded-l-none rounded-r border border-l border-gray-300 bg-white px-1 py-2 shadow-sm"
|
||||
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>
|
||||
@@ -619,7 +641,7 @@ export default React.memo(function GisTree({
|
||||
{!visible && (
|
||||
<button
|
||||
onClick={() => 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}`}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user